Skip to content

feat(mcp): map /mcp with OAuth audience binding and role and scope tool filtering - #1192

Closed
mforce wants to merge 4 commits into
mainfrom
feat/806-map-mcp-a
Closed

mforce wants to merge 4 commits into
mainfrom
feat/806-map-mcp-a

Conversation

@mforce

@mforce mforce commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #806

Maps /mcp, the Streamable HTTP endpoint from ModelContextProtocol.AspNetCore 2.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.cs registers the SDK in stateless mode with AddAuthorizationFilters() and maps /mcp only when the OAuth server runs. The mapping carries AcceptOAuthTokens(farm:read, daily-entries:write) as the outer gate, so it also gets the oauth-api rate limit. It also carries a 64 KiB body cap, ReadsRequestBodyAttribute and a new McpEndpoint metadata 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 with invalid_target. Each code and token carries that resource as its audience. A client that sends no resource gets /mcp, the way an empty scope request gets farm:read. Validation refuses a token with no audience or another audience. This needs no new config key.

Discovery and challenges. The SDK's McpAuthenticationHandler serves /.well-known/oauth-protected-resource/mcp. The document lists the resource, the issuer as authorization server and both scopes. The bearer selector sends /mcp through that scheme, which forwards authentication to OpenIddict, so a missing, revoked or wrong-audience token gets 401 with WWW-Authenticate: Bearer resource_metadata="...". A token holding neither MCP scope gets 403 with error="insufficient_scope". Both URLs come from the issuer, never from the request's Host.

Tool authorization. AuthPolicies registers one policy per OAuth scope, named for the scope. A tool carries a scope [Authorize] and a role [Authorize]. The SDK ANDs them, so tools/list shows a tool only to role ∩ scope, and calling a hidden tool is refused. McpToolSurfaceTests now fails on a tool missing either policy.

Pipeline. IdempotencyMiddleware lets /mcp through by endpoint metadata, because no MCP client sends an Idempotency-Key. A 429 on /mcp is a JSON-RPC error with Retry-After. The oauth-api default rises from 120 to 300 requests per minute per token, and its non-MCP text no longer says "from this address". The exemption edits IdempotencyMiddleware.InvokeAsync, so its tenant-bypass allow-list row is re-pinned from 81a2888e to 3892826c (#1072). The new branch returns before any claim query and leaves the four allow-listed bypasses unchanged.

Architecture row. The Cluckwork.Api.Mcp tier privilege is renamed from DirectRepository to ContractsOnly, with a reason that matches CW1004. McpTierRow_IsDormantToday is deleted because the row is now active. 01-design.md no longer says a session token connects, and the 796, 843 and 02-guards.md records note what #806 decided.

Decisions to check

  • Per-tool scope misses are JSON-RPC errors, not 403. The MCP spec says a server SHOULD answer a runtime scope miss with 403 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. Because tools/list already hides a tool the token cannot use, I left that out. The route-level 403 is built.
  • Shared middleware 401s have no 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.
  • The 401 challenge names no scope, so clients ask for every scope in scopes_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 for farm:read alone.
  • Rate limit. I measured a session with the SDK's own client: connect, list tools and call one tool. It takes 3 HTTP requests on 2026-07-28 and 5 on 2025-11-25, well under 300 a minute. No account-wide ceiling, as decided on EPIC: MCP server support #789.
  • Design rows 13 to 15 (route parity for tool roles) wait for MCP slice 4: read tools — flocks, stock, egg lots, daily entries #807, because with no tools a parity walk checks nothing.

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 resolving McpCallContext in 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-RPC 429, the body cap, the marker sitting on POST /mcp/ only, and stateless transport with the idle-session service never started.

  • OAuthServerProductionTests now reaches /mcp under Production configuration with only the issuer set (design row 11).

  • Mutation check. Each mutant was applied, built and run against its tests, then reverted:

    Mutant Result
    drop AddAuthorizationFilters() red: ToolsList_IsFilteredByRoleAndScope (4 cases)
    skip the /mcp idempotency exemption red: BothRevisions_ListAndCallATool (both)
    drop ReadsRequestBodyAttribute green, see below
    drop the body cap red: OversizedBody_IsRefused
    SessionMode = StatefulForInitializeClients red: Transport_IsStatelessAndKeepsNoSessions, BothRevisions (2025-11-25)
    drop AddAudiences red: TokenForAnotherResource_IsRefused, TokenWithNoAudience_IsRefused
    route /mcp challenges to OpenIddict red: NoToken_GetsAChallengeNamingTheResourceMetadata, RevokedToken_IsChallenged
    drop the insufficient-scope header red: TokenWithNoMcpScope_GetsInsufficientScope
    scope policies ignore the scope red: ToolsList_IsFilteredByRoleAndScope, UnlistedTool_IsRefusedWhenCalled
    ProblemDetails instead of JSON-RPC on 429 red: RateLimitRefusal_IsAJsonRpcError
    no default resource red: TokenRequestedWithoutAResource_IsBoundToMcp
    mark a second endpoint red: OnlyTheMcpPost_CarriesTheMcpMarker
    add /mcp to the must-change-password allow list red: MustChangePassword_IsRefused

    Design row 10 had a false premise. MapMcp maps a RequestDelegate, which carries no MethodInfo, so BodyReadingEndpointTests skips /mcp. /mcp keeps the marker because it reads its body, but no guard holds it. 02-guards.md and a comment at the mapping say so.

  • Ran locally: the Mcp tests 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)":

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.
…e /mcp exemption

The exemption returns before any claim query, so the four allow-listed bypasses are unchanged.
@mforce

mforce commented Oct 10, 2026

Copy link
Copy Markdown
Owner Author

Closed without merging. #1195 carries #806.

@mforce mforce closed this Oct 10, 2026
@mforce
mforce deleted the feat/806-map-mcp-a branch October 10, 2026 22:03
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