Repository navigation
Conversation
…ools/list filtering Closes #806
Corrects the connecting section of the MCP design, notes the 796 response-shape decision and the 843 privilege rename, and maps the guard rows #806 implements.
…olds its body marker
…e /mcp exemption The exemption returns before any claim query, so the four allow-listed bypasses are unchanged.
Owner
Author
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.
Closes #806
Maps
/mcp, the Streamable HTTP endpoint fromModelContextProtocol.AspNetCore2.2.0, behind the existing OAuth server. An assistant can now discover the farm's authorization server, connect with an OAuth token bound to/mcp, list the tools its role and scopes allow, and call them. Production ships no tools yet. The read tools come with #807 and the write tool with #809.What changed
Endpoint.
Hosting/CluckworkMcp.csregisters the SDK in stateless mode withAddAuthorizationFilters()and maps/mcponly when the OAuth server runs. The mapping carriesAcceptOAuthTokens(farm:read, daily-entries:write)as the outer gate, so it also gets theoauth-apirate limit. It also carries a 64 KiB body cap,ReadsRequestBodyAttributeand a newMcpEndpointmetadata record. Both MCP 2026-07-28 and 2025-11-25 clients work on the one endpoint.Audience binding (RFC 8707). The server registers one resource,
<OAuth:Issuer>/mcp, and refuses any other withinvalid_target. Each code and token carries that resource as its audience. A client that sends noresourcegets/mcp, the way an empty scope request getsfarm:read. Validation refuses a token with no audience or another audience. This needs no new config key.Discovery and challenges. The SDK's
McpAuthenticationHandlerserves/.well-known/oauth-protected-resource/mcp. The document lists the resource, the issuer as authorization server and both scopes. The bearer selector sends/mcpthrough that scheme, which forwards authentication to OpenIddict, so a missing, revoked or wrong-audience token gets401withWWW-Authenticate: Bearer resource_metadata="...". A token holding neither MCP scope gets403witherror="insufficient_scope". Both URLs come from the issuer, never from the request's Host.Tool authorization.
AuthPoliciesregisters one policy per OAuth scope, named for the scope. A tool carries a scope[Authorize]and a role[Authorize]. The SDK ANDs them, sotools/listshows a tool only to role ∩ scope, and calling a hidden tool is refused.McpToolSurfaceTestsnow fails on a tool missing either policy.Pipeline.
IdempotencyMiddlewarelets/mcpthrough by endpoint metadata, because no MCP client sends anIdempotency-Key. A429on/mcpis a JSON-RPC error withRetry-After. Theoauth-apidefault rises from 120 to 300 requests per minute per token, and its non-MCP text no longer says "from this address". The exemption editsIdempotencyMiddleware.InvokeAsync, so its tenant-bypass allow-list row is re-pinned from81a2888eto3892826c(#1072). The new branch returns before any claim query and leaves the four allow-listed bypasses unchanged.Architecture row. The
Cluckwork.Api.Mcptier privilege is renamed fromDirectRepositorytoContractsOnly, with a reason that matches CW1004.McpTierRow_IsDormantTodayis deleted because the row is now active.01-design.mdno longer says a session token connects, and the 796, 843 and02-guards.mdrecords note what #806 decided.Decisions to check
403. The MCP spec says a server SHOULD answer a runtime scope miss with403 insufficient_scope. The SDK only checks tool authorization inside its dispatch, after the HTTP status is chosen, and its own tests do this with a middleware that parses the body first. Becausetools/listalready hides a tool the token cannot use, I left that out. The route-level403is built.WWW-Authenticate. These are disabled user, suspended farm, superseded credentials and connected apps off. The MCP authorization spec requires clients to fall back to the well-known metadata URL when the header is absent, and that URL works.scope, so clients ask for every scope inscopes_supported: both of them. Consent is all or nothing (OAuth slice 4: consent screen, with step-up and return-URL validation #798). A user who wants a read-only connection needs a client that asks forfarm:readalone.Verification
New
Mcp/McpEndpointTests(22 tests) run through the real pipeline, mostly with the SDK's client. They cover: the challenge and metadata chain, session JWT refusal, both protocol revisions, the tool body resolvingMcpCallContextin the request scope, role ∩ scope listing, a hidden tool refused, audience binding (none, another, unknown resource), insufficient scope, Disconnect, the connected-apps switch, must-change-password, the JSON-RPC429, the body cap, the marker sitting onPOST /mcp/only, and stateless transport with the idle-session service never started.OAuthServerProductionTestsnow reaches/mcpunder Production configuration with only the issuer set (design row 11).Mutation check. Each mutant was applied, built and run against its tests, then reverted:
AddAuthorizationFilters()ToolsList_IsFilteredByRoleAndScope(4 cases)/mcpidempotency exemptionBothRevisions_ListAndCallATool(both)ReadsRequestBodyAttributeOversizedBody_IsRefusedSessionMode = StatefulForInitializeClientsTransport_IsStatelessAndKeepsNoSessions,BothRevisions(2025-11-25)AddAudiencesTokenForAnotherResource_IsRefused,TokenWithNoAudience_IsRefused/mcpchallenges to OpenIddictNoToken_GetsAChallengeNamingTheResourceMetadata,RevokedToken_IsChallengedTokenWithNoMcpScope_GetsInsufficientScopeToolsList_IsFilteredByRoleAndScope,UnlistedTool_IsRefusedWhenCalled429RateLimitRefusal_IsAJsonRpcErrorTokenRequestedWithoutAResource_IsBoundToMcpOnlyTheMcpPost_CarriesTheMcpMarker/mcpto the must-change-password allow listMustChangePassword_IsRefusedDesign row 10 had a false premise.
MapMcpmaps aRequestDelegate, which carries noMethodInfo, soBodyReadingEndpointTestsskips/mcp./mcpkeeps the marker because it reads its body, but no guard holds it.02-guards.mdand a comment at the mapping say so.Ran locally: the
Mcptests plus the OAuth, rate-limit, idempotency, credential-epoch, process-role, serving-guard, one-shot, body-reading, security-header and validation suites (493 passed), and the whole Application test project (765 passed).Instruction file changes needed
In
src/AGENTS.md, the rule "Declare a tier's otherwise-unavailable module privilege before its surface exists (#514/#843)":Privilege(DirectRepositorytoday)" with "closed-setPrivilege(ContractsOnlytoday)".Cluckwork.Api.Mcprow (MCP slice 3: map /mcp, wire RBAC and tools/list filtering #806) is dormant while its surface is unmapped" through "Drop that assertion in the same commit that activates the tier." with: "TheCluckwork.Api.Mcprow is active: MCP slice 3: map /mcp, wire RBAC and tools/list filtering #806 mapsMapMcp."