Repository navigation
feat(mcp): read tools for flocks, stock, egg lots and daily entries - #1209
Merged
Merged
Conversation
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
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.
Owner
Author
Live MCP run against a running stackRun at
Consent screen at 1:1: These findings do not block this PR:
The procedure and client scripts will follow in a separate PR under |
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.
This was referenced Oct 11, 2026
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.





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, isReadOnly = true, and carries[Authorize](the default policy, the same as its REST route) plus thefarm:readscope policy.cluckwork_list_flocksListFlocksIFlockModule.SearchAsync,GetBirdsRemovedAsynccluckwork_read_stockGetStockIEggOperationsModule.GetStockByGradeAsynccluckwork_list_egg_lotsListEggLotsListLotsAsync,ListAllGradesAsynccluckwork_list_egg_lot_movementsListEggLotMovementsListLotMovementsAsync(lotId, limit, offset)(new)cluckwork_list_daily_entriesListDailyEntriesListDailyEntriesAsync,IFlockLookup,ListAllGradesAsyncEvery reply is an
McpPage<T>withitems,completeandnextOffset.McpPage<T>.ReadAsyncis the only way to build one, because the constructor is private. It clampslimitto 1..100, keeps a nonnegativeoffsetas 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 anint, 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_stockis 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 inStockToolsnames that ceiling.A model holds names, so
list_daily_entriestakesflockas a name or an id, andlist_egg_lotstakesgradethe 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 tocluckwork_list_flocks. Resolution reads at most 11 rows (#852'sIFlockLookup.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.FlockRowreportsbirdsAlive(initial count minus net removals) andacceptsProductionTodayon 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.BreedEggInventoryMovement.Reason(from the lot movement reads)DailyEntry.AdjustReason,DailyEntry.VoidReason, and theAdjustedFromJsonsnapshotThe provenance emails,
FarmIdandHouseIdare left out too (House is still a phantom id). The other text fields are enum names andReferenceType, a type name that code sets.Not built
get_flockandget_daily_entry.list_flockswithnameContains, andlist_daily_entriesfiltered 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 headabfdaf0c, all 20 were killed with their declared messages, and the suite passed 401 of 401 before and after. Rerun them withsg 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'.McpRouteParityTests(rows 13 to 15)IAuthorizationServiceroute-tightened(ListFlocksmadeAdminOnly, nothing underMcp/touched): "ListFlocksAsync admits Sales, which ListFlocks denies".route-renamed.tool-mirrors-nothingMcpPageTests(row 31, no host)take + 1rows at a clamped window. Pages at and under the limit are complete, and a page over it keepstakerows and continues atskip + take. Covers limits -5, 0, 1, 100 and 500, negative, large andint.MaxValueoffsets, and empty fetches. Nearint.MaxValue, two successive calls move forward, and the call whose continuation would overflow is refusedpage-without-lookahead(asks fortakerows),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)Task<McpPage<T>>read-tool-unpagedMcpReadToolsTests.EveryReadTool_PagesWithCompletenessAndContinuation,OversizedLimit_IsCappedAndSaysSo(row 31)limit = 10000returns 100 rowsMcpPagemutantsMcpReadToolsTests.LotMovementPage_ReadsOnlyThePageFromTheDatabase(row 31, query)DataReaderClosingEventData.ReadCountmovements-paged-in-memory(load the ledger, thenSkip/Take)McpReadToolsTests.SharedFlockName_ListsBoundedCandidates_AndReadsBoundedRows(row 32)name-resolution-unbounded(repositoryTakeremoved),name-candidates-uncapped(lookup cap removed)McpReadToolsTests.SharedFlockName_IsRefusedWithTheCandidates_AndAnIdResolvesIt(row 32)shared-name-first-matchMcpReadToolsTests.CrossFarmBearer_ReadsNothingOfTheOtherFarm(row 26)flock-id-uncheckedMcpReadToolsTests.AssignedWorker_ReadsOnlyTheirFlocksflock-scope-ignoredMcpReadToolsTests.AssignedWorker_ReadsOnlyTheirFlocksEggsegg-lot-scope-ignored(only theEggLotflock predicate removed)McpToolSurfaceTests.RealTree_ReadToolRepliesReturnOnlyReviewedTextandTextWalk_ReachesNestedCollectionAndOpaqueText(#792)DailyEntryDetails.AdjustReason, anIReadOnlyList<string>, astring[], and a[JsonIgnore(Condition = WhenWritingNull)]field. A plain[JsonIgnore]field is skipped, matching whatJsonSerializerwrites for the same row.objectandJsonElementare flagged as opaquereply-free-text(aReasonfield on a row),text-walk-namespace-bound(stop outsideCluckwork.Api),text-walk-skips-elements(walk arrays only),text-walk-ignores-conditional(skip every[JsonIgnore])McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllownow expects the real read tools beside the probe tools whenever the token holdsfarm:read, and none without it.Review round 1
IEggOperationsModule.ListLotMovementsAsync(lotId, limit, offset)pages in SQL behind the same filtered lot check. The REST route keeps the whole ledger.IFlockLookup, so every caller benefits, not only MCP.AssignedWorker_ReadsOnlyTheirFlocksEggswas added.McpPage<T>.ReadAsyncreplacesWindowandFrom, withMcpPageTestsadded.Review round 2
JsonIgnoreCondition.Always, so a conditionally ignored field that still serializes is reached.int.MaxValueis 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.IntegrationTestsfiltered toMcp, 124 pass after round 1. After round 2,McpPageTestsandMcpToolSurfaceTestspass (50). Filtered toFlockNameResolutionTests,ConfirmSaleAtomicityTestsandEggLedger, they pass too.Docs
02-guards.mdrecords rows 13 to 15, 26, 31 and 32 and the #792 walk.806-mcp-endpoint.mddrops its "arrives with #807" line. GLOSSARY and Help belong to #811, and the tool list is posted there.