Skip to content

feat(mcp): read tools for flocks, stock, egg lots and daily entries - #1209

Merged
mforce merged 10 commits into
mainfrom
feat/807-mcp-read-tools
Oct 11, 2026
Merged

mforce merged 10 commits into
mainfrom
feat/807-mcp-read-tools

Conversation

@mforce

@mforce mforce commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Five read-only MCP tools for the production side, so a connected assistant can answer questions about flocks, stock, egg lots and daily entries by name.

Closes #807

Tools

Every tool calls module contracts only, takes McpCallContext, is ReadOnly = true, and carries [Authorize] (the default policy, the same as its REST route) plus the farm:read scope policy.

Tool Mirrors route Contract calls Bound
cluckwork_list_flocks ListFlocks IFlockModule.SearchAsync, GetBirdsRemovedAsync 50 per page by default, 100 at most, paged in SQL
cluckwork_read_stock GetStock IEggOperationsModule.GetStockByGradeAsync the same, paged in memory over one SQL-summed row per grade
cluckwork_list_egg_lots ListEggLots ListLotsAsync, ListAllGradesAsync the same, paged in SQL
cluckwork_list_egg_lot_movements ListEggLotMovements ListLotMovementsAsync(lotId, limit, offset) (new) the same, paged in SQL
cluckwork_list_daily_entries ListDailyEntries ListDailyEntriesAsync, IFlockLookup, ListAllGradesAsync the same, paged in SQL

Every reply is an McpPage<T> with items, complete and nextOffset. McpPage<T>.ReadAsync is the only way to build one, because the constructor is private. It clamps limit to 1..100, keeps a nonnegative offset as given, and calls the tool's query with one row past the page. A shorter answer means the page is the last. No tool chooses that row count. If the next offset would not fit in an int, the call is refused with a message telling the caller to narrow the request. It is never moved back onto a page already read.

cluckwork_read_stock is the one tool that pages in memory. Its contract sums lots in SQL and returns one row per grade, so its size is the grade catalogue. Owners and Managers curate that catalogue, and the lot and entry tools and the SPA's capture form already load it whole. A code comment in StockTools names that ceiling.

A model holds names, so list_daily_entries takes flock as a name or an id, and list_egg_lots takes grade the same way. Duplicate flock names are legal. A shared name is refused, never resolved to the first match. The refusal lists at most 10 candidate ids with their status (FlockNameResolution.MaxCandidates). When more flocks share the name, it says so and points to cluckwork_list_flocks. Resolution reads at most 11 rows (#852's IFlockLookup.ResolveByNameAsync, now bounded for every caller, the simulation seeder included). Grade names are unique per farm, ignoring case (IX_EggGrades_AccountId_FarmId_LowerName), so grade candidates can never outnumber an account's farms. Not found and outside the caller's scope get the same answer, for flocks and for lots.

FlockRow reports birdsAlive (initial count minus net removals) and acceptsProductionToday on the farm clock, which the record-entry tool (#809) will need.

Free text and #792

#792 decided that phase 1 tools return no farm-entered free text. Its 2026-10-11 clarification allows flock and grade names, so the tools return those. These fields are left out, and no tool description or schema names them:

  • Flock.Breed
  • EggInventoryMovement.Reason (from the lot movement reads)
  • DailyEntry.AdjustReason, DailyEntry.VoidReason, and the AdjustedFromJson snapshot

The provenance emails, FarmId and HouseId are left out too (House is still a phantom id). The other text fields are enum names and ReferenceType, a type name that code sets.

Not built

get_flock and get_daily_entry. list_flocks with nameContains, and list_daily_entries filtered by flock and date, answer the same questions. [MirrorsNoRoute] is also not built, because every tool here has a route. The guard table says to add it with the first tool that has none.

Guards

Every mutant below is in tools/oauth/mutation-check.sh. At head abfdaf0c, all 20 were killed with their declared messages, and the suite passed 401 of 401 before and after. Rerun them with sg docker -c 'MUTANT_FILTER="^(route-|tool-mirrors|read-tool|page-|flock-|shared-name|reply-|movements-|name-|text-walk-|egg-lot-)" bash tools/oauth/mutation-check.sh'.

Guard What it holds Mutants
McpRouteParityTests (rows 13 to 15) every tool names its route, the name resolves, and the tool admits no role the route denies, through the live IAuthorizationService route-tightened (ListFlocks made AdminOnly, nothing under Mcp/ touched): "ListFlocksAsync admits Sales, which ListFlocks denies". route-renamed. tool-mirrors-nothing
McpPageTests (row 31, no host) the query is asked for take + 1 rows at a clamped window. Pages at and under the limit are complete, and a page over it keeps take rows and continues at skip + take. Covers limits -5, 0, 1, 100 and 500, negative, large and int.MaxValue offsets, and empty fetches. Near int.MaxValue, two successive calls move forward, and the call whose continuation would overflow is refused page-without-lookahead (asks for take rows), page-lookahead-ignored (Count <= take + 1), page-uncapped (clamp removed), page-offset-clamped-back (the old backwards clamp), page-next-overflows (refusal removed)
McpToolSurfaceTests.RealTree_EveryReadToolReturnsAPage (row 31) every read tool returns Task<McpPage<T>> read-tool-unpaged
McpReadToolsTests.EveryReadTool_PagesWithCompletenessAndContinuation, OversizedLimit_IsCappedAndSaysSo (row 31) each tool's first page says it is incomplete, its continuation is the rest, and limit = 10000 returns 100 rows covered by the McpPage mutants
McpReadToolsTests.LotMovementPage_ReadsOnlyThePageFromTheDatabase (row 31, query) a page of a 41-movement ledger reads 2 + 1 rows from the database, counted from DataReaderClosingEventData.ReadCount movements-paged-in-memory (load the ledger, then Skip/Take)
McpReadToolsTests.SharedFlockName_ListsBoundedCandidates_AndReadsBoundedRows (row 32) 30 same-name flocks give a refusal with 10 ids that says more exist, and the name query reads 11 rows name-resolution-unbounded (repository Take removed), name-candidates-uncapped (lookup cap removed)
McpReadToolsTests.SharedFlockName_IsRefusedWithTheCandidates_AndAnIdResolvesIt (row 32) a shared name is refused, an id resolves it shared-name-first-match
McpReadToolsTests.CrossFarmBearer_ReadsNothingOfTheOtherFarm (row 26) a bearer reads nothing of another farm, by id or by name flock-id-unchecked
McpReadToolsTests.AssignedWorker_ReadsOnlyTheirFlocks #388 flock scope for flocks and daily entries flock-scope-ignored
McpReadToolsTests.AssignedWorker_ReadsOnlyTheirFlocksEggs with one grade shared by an assigned and an unassigned flock: stock sums only the assigned lot (30, not 100), only that lot is listed, and an unassigned lot id is refused word for word like a missing one egg-lot-scope-ignored (only the EggLot flock predicate removed)
McpToolSurfaceTests.RealTree_ReadToolRepliesReturnOnlyReviewedText and TextWalk_ReachesNestedCollectionAndOpaqueText (#792) the walk follows each reply as the serializer does, through nested types from any namespace and through collections and arrays. The real tree's text fields equal a reviewed list of names and code-set values. Fixtures prove it reaches a nested DailyEntryDetails.AdjustReason, an IReadOnlyList<string>, a string[], and a [JsonIgnore(Condition = WhenWritingNull)] field. A plain [JsonIgnore] field is skipped, matching what JsonSerializer writes for the same row. object and JsonElement are flagged as opaque reply-free-text (a Reason field on a row), text-walk-namespace-bound (stop outside Cluckwork.Api), text-walk-skips-elements (walk arrays only), text-walk-ignores-conditional (skip every [JsonIgnore])

McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllow now expects the real read tools beside the probe tools whenever the token holds farm:read, and none without it.

Review round 1

  • F1. IEggOperationsModule.ListLotMovementsAsync(lotId, limit, offset) pages in SQL behind the same filtered lot check. The REST route keeps the whole ledger.
  • F2. Name resolution is bounded in IFlockLookup, so every caller benefits, not only MCP.
  • F3. The text walk was rewritten as described above.
  • F4. AssignedWorker_ReadsOnlyTheirFlocksEggs was added.
  • F5. Names are allowed under the 2026-10-11 clarification on Decide how farm-controlled free text is presented to a model before the MCP tool surface exists #792. There is no code change.
  • F6. McpPage<T>.ReadAsync replaces Window and From, with McpPageTests added.

Review round 2

  • R1. The text walk now skips only JsonIgnoreCondition.Always, so a conditionally ignored field that still serializes is reached.
  • R2. The offset is no longer clamped back. A continuation past int.MaxValue is refused.

The review loop was stopped after round 2. That round raised no P0 or P1 and no isolation leak. Its yield was test rigour (R1) and a boundary no farm reaches (R2).

Tests run locally

  • Cluckwork.Application.Tests, all 771 pass, including the adapter-reach walk and the coupling matrix.
  • Cluckwork.Api.IntegrationTests filtered to Mcp, 124 pass after round 1. After round 2, McpPageTests and McpToolSurfaceTests pass (50). Filtered to FlockNameResolutionTests, ConfirmSaleAtomicityTests and EggLedger, they pass too.

Docs

02-guards.md records rows 13 to 15, 26, 31 and 32 and the #792 walk. 806-mcp-endpoint.md drops its "arrives with #807" line. GLOSSARY and Help belong to #811, and the tool list is posted there.

Five read-only MCP tools, each behind the default role policy and the
farm:read scope, calling module contracts only. Every reply is an
McpPage with completeness and continuation fields, and every paged
query fetches one row past its page. A flock filter takes a name or an
id; a shared name is refused with every candidate's id.

Guards: route parity (rows 13-15), bounded replies (row 31), the
cross-farm read (row 26), and a walk listing every text field a read
tool returns, since #792 keeps farm-entered free text out.
Ten mutants, one per read-tool guard. The paging test now asserts the
first page before it asks for the continuation, so a page that claims to
be complete fails on that claim rather than on a null offset.
@mforce
mforce marked this pull request as ready for review October 11, 2026 01:24
Review round 1 on #1209:
- F1: a paged lot-movement read in the EggOperations contract, paged in
  SQL behind the same filtered lot check.
- F2: flock name resolution reads at most MaxCandidates + 1 rows and says
  when more flocks share the name; the MCP refusal points to list_flocks.
- F3: the reply text walk follows the serialized graph through any
  namespace, collections and arrays, and flags opaque shapes.
- F4: Worker scope tested for stock, lots and lot movements.
- F6: McpPage<T>.ReadAsync is the only way to build a page and owns the
  one-row lookahead; McpPageTests pins it without a host.
… offset back

Review round 2 on #1209:
- R1: the reply text walk skips only always-ignored properties, matching
  what System.Text.Json writes.
- R2: McpPage keeps a nonnegative offset as given and refuses a
  continuation an int cannot express, rather than clamping it back onto
  a page already read.
@mforce mforce mentioned this pull request Oct 11, 2026
16 tasks done
@mforce

mforce commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Live MCP run against a running stack

Run at 4d37f09a on an isolated stack behind a local TLS proxy at https://localhost:8495/. The head has since moved to 577abd01, a merge of main that changes only the leader-gate tests (#1207). Three real clients connected over HTTP. They were the TypeScript MCP SDK 1.32.1, the C# MCP SDK 2.2.0 and the MCP Inspector CLI 2.10.1. Every step passed.

Check Result
Discovery An unauthenticated POST /mcp returns 401 with resource_metadata pointing at the issuer. Both metadata documents name the issuer, even with a different Host header.
Registration Dynamic registration works. The server refuses a loopback client ID metadata URL before fetching it. A public URL answering 404 is refused and logged.
Consent The page shows the app name, "Unverified app", the scope and the role. Allow without the password stays on the page. With the password, the code reaches the redirect URI.
Audience An SPA token and a garbage token get 401. resource=https://other.example/api gets invalid_target at authorize and at token.
Owner, farm:read tools/list shows five tools. Paging returns complete:false, nextOffset:2 for limit:2, and offset:2 continues. The name, grade, date and flock filters work.
Free text I wrote a discard reason MCPCANARY and voided an entry through REST first. 45 KB of tool replies had no hit for the marker, reason, note or breed.
Restricted Worker Only Sim House A is visible. Stock equals REST /stock for the same Worker. Another flock's lot gives the same message as a missing lot.
daily-entries:write only tools/list is empty. A call gets JSON-RPC -32600, as decision record 806 describes.
Revocation With the farm switch off, the token gets 401 with error="invalid_token". Switching it on restores access. After Disconnect on the Account page, the token gets 401.
Protocol versions The C# client negotiated 2026-07-28 and 2025-11-25 and called every tool on both. The TS SDK supports up to 2025-11-25.

Consent screen at 1:1:

consent 1280 light
consent 1280 dark
consent 390 light
consent 390 dark
worker consent 1280 light

These findings do not block this PR:

  • In dark mode, the farm name and role badge on the consent page use var(--brand) (#4a154b) on #302733, about 1.3:1 contrast. This came in with feat: add the connected-app consent screen with step-up, and run the OAuth server in Production #1144. It is not part of this PR.
  • Expected tool refusals (not found, ambiguous name, missing scope) log "threw an unhandled exception" at Error level with a stack trace.
  • A revoked, garbage or wrong-audience token gets a challenge without error="invalid_token". Only the switch-off path adds it.
  • Optional tool parameters use type: ["string","null"]. Inspector --strict reports 7 warnings for this and no errors.

The procedure and client scripts will follow in a separate PR under tools/verify/, so later MCP slices can rerun them.

@mforce
mforce merged commit 0f341f0 into main Oct 11, 2026
21 checks passed
@mforce
mforce deleted the feat/807-mcp-read-tools branch October 11, 2026 03:19
mforce added a commit that referenced this pull request Oct 11, 2026
…ntry

DailyEntryTools now holds both tools: #807's cluckwork_list_daily_entries
and #809's cluckwork_record_daily_entry. The write tool resolves flocks
through FlockTools.ResolveAsync, so a shared name, including one past the
ten-candidate cap (MoreExist), is refused with the pointer to
cluckwork_list_flocks and never picked. It mirrors RecordDailyEntry.
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 4: read tools — flocks, stock, egg lots, daily entries

1 participant