Skip to content

feat(mcp): record a draft daily entry through an MCP write tool - #1210

Merged
mforce merged 11 commits into
mainfrom
feat/809-mcp-record-daily-entry
Oct 11, 2026
Merged

mforce merged 11 commits into
mainfrom
feat/809-mcp-record-daily-entry

Conversation

@mforce

@mforce mforce commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #809

An MCP client can now record a flock's daily production as a draft. It sends cluckwork_record_daily_entry with a flock name and the day's numbers, and gets back the draft's id. A person still reviews and submits the draft in Cluckwork.

What changed

The #307 protocol moved into IdempotentUnit. IdempotencyMiddleware used to hold the claim, execute and publish code itself. That code now lives in src/Cluckwork.Api/Middleware/IdempotentUnit.cs, with its comments, and the middleware is its HTTP adapter. The unit takes an explicit outcome from the operation and publishes only a success. The middleware treats a 2xx as a success, as before. The #307 suites pass with no test edited.

The tool. It lives in src/Cluckwork.Api/Mcp/DailyEntryTools.cs, beside #807's cluckwork_list_daily_entries, and mirrors the RecordDailyEntry route. It resolves the flock by name or id through FlockTools.ResolveAsync, the read tools' resolver. A shared name is refused with the candidate ids, and past the ten-candidate cap the refusal says more flocks share the name and points to cluckwork_list_flocks. It never picks one. The tool takes FarmId and HouseId from FlockDetails and resolves grades by name or id against the farm's active grades. It then runs IValidator<RecordDailyEntryCommand> and IEggOperationsModule.RecordDailyEntryAsync. It needs ProductionWrite and daily-entries:write, and it works on a connection without farm:read.

Idempotency. idempotencyKey is required. The claim is keyed on mcp:tool:cluckwork_record_daily_entry, on the calling user and the key, and it fingerprints the arguments. A replay returns the first reply with replayed: true and writes nothing. A reused key with different values is refused. A domain refusal, such as a depleted flock, releases the claim, so the same call records once the farm fixes the flock. docs/decisions/809-mcp-write-idempotency.md records why loopback was dropped and why the key is scoped to the user.

Where to look

  1. ae72d251 is the extraction alone. Compare IdempotentUnit.RunAsync with the old IdempotencyMiddleware.InvokeAsync side by side. The steps and comments are the same, apart from three changes. Success is now IdempotentResponse.Succeeded. The ambiguous-commit probe returns Executed, not Replayed. The middleware now writes the response after the unit returns, not inside the transaction callback. So if that write fails after a committed success, the client sees a failed request instead of the middleware trying a second write, and the same-key retry replays the stored result. The decision record states this, and IdempotencyResponseWriteFailureTests proves the retry.
  2. 32350983 is the tool, its tests and the docs.
  3. 06ae1b82, 0a901369 and f0bc165f answer review round 1: a replay re-checks flock access, guard 21 refuses a key with a default, and the response-write test.
  4. 1eff1309 merges feat(mcp): read tools for flocks, stock, egg lots and daily entries #1209. The add/add conflict on DailyEntryTools.cs became one class holding both tools, and the write tool now uses FlockTools.ResolveAsync instead of its own resolver.

Tests

  • Make API write idempotency atomic across replicas #307 family, unedited, on the extraction: AtomicIdempotencyProtocolTests, IdempotencyBoundedWaitTests, IdempotencyReplayTests, IdempotencyUserScopeTests, MultiInstanceIdempotencyTests, StealLossConnectionReleaseTests, RetryBoundaryTests, KestrelResponseWriteTests, ExceptionHandlerReExecutionTests, ConfirmSaleAtomicityTests, MortalityLedgerAtomicityTests, IdempotencyRecordPurgeSweepTests, KestrelRequestBodyLimitTests, ChangeUserEmailRaceTests. 63 passed.
  • After merging feat(mcp): read tools for flocks, stock, egg lots and daily entries #1209: Cluckwork.Api.IntegrationTests.Mcp (including MCP slice 4: read tools — flocks, stock, egg lots, daily entries #807's route-parity tests), IdempotencyResponseWriteFailureTests, FlockNameResolutionTests and the Make API write idempotency atomic across replicas #307 family together: 214 passed. Architecture, tenant-bypass and documentation guards: 470 passed. New tests are in RecordDailyEntryToolTests and RecordDailyEntryToolFaultTests. They cover role and scope refusals, a write-only connection, a Worker refused for an unassigned flock (with a positive control on their own flock), another farm's flock id, the farm switch, the audit row's connected app, replay without a second DailyEntryUpdate row (with a control that a new key does write one), a replay refused once the Worker is unassigned from the flock (the same refusal a fresh key gets), a replay refused for a token without the write scope, a reused key, a released refusal, a shared flock name, eleven flocks sharing a name (refused, no count stated, pointer to cluckwork_list_flocks), validator and grade refusals, REST and MCP keys not aliasing, a stolen lease rolled back, and a lost commit acknowledgment.
  • Architecture and tenant-bypass guards: 450 passed. The registry rows moved from the middleware's methods to the unit's. IdempotencyMiddleware.cs left the file-size legacy list. DailyEntryTools has an adapter row for Egg Operations and Flock Management. The coupling matrix was regenerated.

Mutation checks

Each mutation was applied to the source, the named test was run, and the file was restored. All sixteen went red.

Mutation Test that went red
Unit publishes a failed outcome DomainFailure_IsReleased_SoTheSameKeyRecordsOnceFixed
Unit ignores the argument fingerprint SameKey_DifferentArguments_IsRefused_AndChangesNothing
Tool runs the write without the unit SameKeyAndArguments_Replays_WithoutASecondAuditRow, on the second DailyEntry.Update row
Unit drops the ambiguous-commit probe LostCommitAcknowledgment_ReturnsThePublishedEntry_WrittenOnce
Unit commits after its lease was stolen StolenLease_RollsTheWriteBack_AndTheKeyRecovers
Tool uses the REST route's endpoint hash and an account-wide key SameKey_OnTheRestRouteAndTheTool_DoNotAlias
Tool renames idempotencyKey McpToolSurfaceTests.RealTree_EveryWriteToolTakesAnIdempotencyKey
The shared resolver takes the first flock of a shared name SharedFlockName_IsAmbiguous_AndAnIdRecords
The shared resolver drops the MoreExist pointer ElevenFlocksSharingAName_AreRefused_WithThePointerToTheFlockList
The write tool takes the first candidate past the cap ElevenFlocksSharingAName_AreRefused_WithThePointerToTheFlockList
Tool drops its scope policy RoleOrScopeWithoutTheWrite_HidesAndRefusesTheTool (Manager with farm:read only)
AuditWriter drops the connected app RecordsADraft_ByFlockAndGradeNames_AndTheAuditRowNamesTheConnectedApp
Tool skips the flock re-check on replay Replay_AfterTheFlockIsUnassigned_IsRefusedLikeAFreshCall
Guard 21 accepts a key with a default WriteToolWithoutARequiredKey_IsAFinding_AndAReadOrAKeyedWriteIsNot
Middleware ignores the Idempotency-Key IdempotencyResponseWriteFailureTests, on a second DailyEntry.Update row
Tool drops its scope policy (replay case) Replay_WithATokenLackingTheWriteScope_IsRefusedBeforeTheUnit

Not in this PR

  • Guard 20 (two tools, one caller key) needs a second write tool.
  • The Worker refusal comes from the flock-scoped read before the handler. FlockScopeGuard is behind it, and no MCP test reaches it alone.
  • The tool does not call FlockDetails.CanRecordProductionOn itself. The handler already runs that check, and a second copy in the tool is what guard 25 forbids. DomainFailure_IsReleased_SoTheSameKeyRecordsOnceFixed proves the handler's check runs on the MCP path.
  • User documentation belongs to MCP slice 8: GLOSSARY, SPA Help and in-app glossary, in all three locales #811.

IdempotencyMiddleware becomes the HTTP adapter: it scopes the claim from the
request, runs the pipeline as the operation (a 2xx is a success) and writes
the outcome. The unit publishes only on an explicit success, so a second
adapter cannot cache a failure. The #307 suites pass unedited.

Refs #809
…entry

The tool takes a flock and grades by name or id, derives the farm and house
from the flock, validates and records through the Egg Operations contract,
and runs under IdempotentUnit with a required idempotencyKey keyed on the
tool and the user. It needs the ProductionWrite role and the
daily-entries:write scope.

Closes #809
Guard 23 is the absence of a second DailyEntryUpdate row. Asserting the
replayed flag first let a bypassed unit fail on the flag, so the audit
assertion was never shown to catch it. The tool description also says that
omitting grades keeps the draft's grade lines.

Refs #809
A replay never ran the filtered flock lookup, so a Worker unassigned from a
flock since the first call still got that entry's id, flock and date back.
The tool now reads the stored flock through IFlockLookup.GetAsync first and
answers with the same Flock.NotFound a fresh call gets. A token without the
write scope is refused by [Authorize] before the unit runs.

Refs #809
A default lets calls that omit the key share it.

Refs #809
…ey retry

The middleware now writes the response after the unit returns, so a write
failure reaches the error handling instead of re-emitting the stored result.
The decision record states it, and the test proves the retry replays without
a second write.

Refs #809
…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.
@mforce

mforce commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Live MCP run against a running stack

I ran the full procedure from #1214 against 681e8f6a on an isolated stack, using real SDK clients and the browser consent flow. The head is now 25824b61, which only merges main (#1213, a web chart fix). Every read check from #1209 passed again, and so did every write check below.

Check Result
Consent Both scopes show "Read farm data" and "Record daily entries". A write-only token shows "Record daily entries" alone.
Owner records by flock name cluckwork_record_daily_entry returned the entry id with replayed:false.
Same key, same arguments The call returned the same id with replayed:true. The database holds one entry row and one DailyEntry.Create audit row, attributed to the app.
Same key, different arguments Refused with Idempotency.KeyReused.
Restricted Worker The Worker recorded to its own flock. Sim House B, by name or by id, gets "No flock you can see is named or numbered ...".
farm:read-only token The write call gets JSON-RPC -32600.
Ambiguous and unknown names With two flocks named MCP Twin, the reply lists both ids and asks for one. An unknown name gets "No flock you can see ...".
Visible elsewhere GET /daily-entries/<id> returns the draft with the recorded counts, and the SPA's daily entry page shows it.
Revocation With the farm switch off, the tool gets 401 Auth.ConnectedAppsOff, and works again once it is back on. Disconnect stops only the disconnected app's token.

Two behaviours match the design. Recording without grades keeps the draft's existing grade lines. A refused call frees its idempotency key.

consent both scopes 1280 light
consent both scopes 1280 dark
write-only consent 1280 light
write-only consent 390 light
recorded draft in the SPA

These findings do not block this PR:

  • Expected refusals log at Error level with a stack trace, as on feat(mcp): read tools for flocks, stock, egg lots and daily entries #1209.
  • Every idempotent replay logs a 23505 duplicate-key error twice. A plain REST replay does the same, so this comes from the shared idempotency path and predates this PR.
  • The tool schema marks grades, its items and grade as nullable, although GradeLine.Grade is a non-nullable string. Inspector reports 10 portability warnings and no errors.

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 6: write tool — record daily entry

1 participant