Skip to content

MCP slice 2: the identity bridge (McpCallContext) and the dependency-graph walk #805

Description

@mforce

Slice 2 of #789. Startable now. It is independent of #788. Build this before any tool exists.

This slice is the identity bridge. McpCallContext is the only object a tool may learn "who is calling" from.

The hazard

The MCP SDK's McpServerOptions.ScopeRequests defaults to true, 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 (IAuditWriter fails closed, #500). Reads go silently wrong: EF filters match AccountId == Guid.Empty, and FlockScope.IsUnrestricted defaults to true, widening a #388-narrowed Worker to the whole farm.

Under SessionMode.Stateless the SDK sets ScopeRequests = false itself. The realistic regression is therefore a later SessionMode change, not a deleted flag.

Scope

McpCallContext, scoped, whose factory throws unless all of these hold:

  1. an HttpContext is present;
  2. the injected TenantContext is reference-equal to the one in HttpContext.RequestServices. This is the only check that detects a SessionMode flip;
  3. TenantContext.IsResolved && CurrentUserContext.IsResolved && FlockScope.IsResolved. Note IsResolved, never IsUnrestricted;
  4. UserId != Guid.Empty. ResolveSystemActor sets IsResolved = true with an empty id and no roles. That resolves to Worker with zero assignment rows, which is FlockScopeGuard'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 McpCallContext can 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.md rows 1-7b. Row 3b names the plausible wrong fix: asserting IsUnrestricted instead of IsResolved passes while the hole stays open.

Activity

  1. added
    sliceThin vertical work item
    area:apiAPI/endpoint layer
    priority:tier3Real product weight, real cost
    size:LSeveral days; wide blast radius or unresolved scope
    epic-789MCP server support (#789)
    on Sep 13, 2026
  2. added this to the MCP server support milestone on Sep 13, 2026
  3. mforce commented on Oct 6, 2026

    @mforce
    OwnerAuthor

    Resized L to M in the 2026-10-06 re-evaluation.

    Since #514, a class in Cluckwork.Api.Mcp can reach only module Contracts and Platform types. CW1004 and AdapterReachRealTreeTests enforce 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 on IServiceProvider and IServiceScopeFactory in that namespace. Keep the causal second-scope test, and keep the factory-delegate mutation, because a Platform helper registered with sp => ... can still hide a scope.

    McpCallContext and its four throwing cases are unchanged. ResolveSystemActor still yields a resolved actor with an empty id, so check 4 still matters. TenantContext, CurrentUserContext and FlockScope are under src/Cluckwork.Infrastructure/Persistence/.

  4. added
    size:MA day or two; migration or a multi-state UI
    and removed
    size:LSeveral days; wide blast radius or unresolved scope
    on Oct 6, 2026
  5. mforce commented on Oct 10, 2026

    @mforce
    OwnerAuthor

    Re-check after milestone 10, 2026-10-10. Add a fifth throwing case: CurrentUserContext.ConnectedApp must be non-null.

    TenantResolutionMiddleware.cs:43-65 sets it from the token's client_id, and AuditWriter records it on every audit row. A tool call that runs without it has lost its app attribution, or arrived on a session JWT. AcceptOAuthTokens refuses session JWTs at the route, so this check is the second line. Expose ConnectedApp on McpCallContext. Granted scopes do not need to be exposed: tools check scope through [Authorize] (see #806).

    Size stays M.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiAPI/endpoint layerepic-789MCP server support (#789)priority:tier3Real product weight, real costsize:MA day or two; migration or a multi-state UIsliceThin vertical work item

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions