Repository navigation
MCP slice 2: the identity bridge (McpCallContext) and the dependency-graph walk #805
Description
Activity
- addedsliceThin vertical work itemThin vertical work itemarea:apiAPI/endpoint layerAPI/endpoint layerpriority:tier3Real product weight, real costReal product weight, real costsize:LSeveral days; wide blast radius or unresolved scopeSeveral days; wide blast radius or unresolved scopeepic-789MCP server support (#789)MCP server support (#789)
on Sep 13, 2026 Resized L to M in the 2026-10-06 re-evaluation.
Since #514, a class in
Cluckwork.Api.Mcpcan reach only moduleContractsand Platform types. CW1004 andAdapterReachRealTreeTestsenforce this, and both cover tier namespaces. A tool can no longer inject a repository whose graph you would need to walk. Most of guard 7b therefore becomes a ban onIServiceProviderandIServiceScopeFactoryin that namespace. Keep the causal second-scope test, and keep the factory-delegate mutation, because a Platform helper registered withsp => ...can still hide a scope.McpCallContextand its four throwing cases are unchanged.ResolveSystemActorstill yields a resolved actor with an empty id, so check 4 still matters.TenantContext,CurrentUserContextandFlockScopeare undersrc/Cluckwork.Infrastructure/Persistence/.- addedsize:MA day or two; migration or a multi-state UIA day or two; migration or a multi-state UIand removedsize:LSeveral days; wide blast radius or unresolved scopeSeveral days; wide blast radius or unresolved scope
on Oct 6, 2026 Re-check after milestone 10, 2026-10-10. Add a fifth throwing case:
CurrentUserContext.ConnectedAppmust be non-null.TenantResolutionMiddleware.cs:43-65sets it from the token'sclient_id, andAuditWriterrecords it on every audit row. A tool call that runs without it has lost its app attribution, or arrived on a session JWT.AcceptOAuthTokensrefuses session JWTs at the route, so this check is the second line. ExposeConnectedApponMcpCallContext. Granted scopes do not need to be exposed: tools check scope through[Authorize](see #806).Size stays M.
Slice 2 of #789. Startable now. It is independent of #788. Build this before any tool exists.
This slice is the identity bridge.
McpCallContextis the only object a tool may learn "who is calling" from.The hazard
The MCP SDK's
McpServerOptions.ScopeRequestsdefaults totrue, which gives each tool call a fresh DI scope. Every Cluckwork identity primitive is scoped and populated by middleware acting on the HTTP request's scope. In a fresh scope the failure is asymmetric and both halves are bad. Writes throw (IAuditWriterfails closed, #500). Reads go silently wrong: EF filters matchAccountId == Guid.Empty, andFlockScope.IsUnrestricteddefaults totrue, widening a #388-narrowed Worker to the whole farm.Under
SessionMode.Statelessthe SDK setsScopeRequests = falseitself. The realistic regression is therefore a laterSessionModechange, not a deleted flag.Scope
McpCallContext, scoped, whose factory throws unless all of these hold:HttpContextis present;TenantContextis reference-equal to the one inHttpContext.RequestServices. This is the only check that detects aSessionModeflip;TenantContext.IsResolved && CurrentUserContext.IsResolved && FlockScope.IsResolved. NoteIsResolved, neverIsUnrestricted;UserId != Guid.Empty.ResolveSystemActorsetsIsResolved = truewith an empty id and no roles. That resolves to Worker with zero assignment rows, which isFlockScopeGuard's account-wide case.The slice also adds the assembly walk forbidding a tool from injecting
TenantContext/CurrentUserContext/FlockScope/AppDbContext/IServiceProvider/IServiceScopeFactory/HttpContext/IHttpContextAccessor.What this slice cannot fully close, and must not claim to
The parameter list is not the boundary. The dependency graph is. A tool taking only
McpCallContextcan inject a helper that opens a scope via a factory registration (sp => new Helper(() => sp.CreateScope())), whose constructor exposes only a delegate. Guard 7b walks transitive dependencies through reviewed registrations, but detached background work changes no graph at all.So bound the hazard, name the residue, and do not write that the branch is unreachable. #787 is the fail-closed backstop for what remains, which is why slice 6 is blocked on it.
Done when
The four throwing cases pass with no tools and no
MapMcp. This is testable before a single tool exists, and it is the design's load-bearing claim.Design
docs/plans/770-mcp-server/02-guards.mdrows 1-7b. Row 3b names the plausible wrong fix: assertingIsUnrestrictedinstead ofIsResolvedpasses while the hole stays open.