fix(timesheets): shared create service for ts create and MCP - #70
Merged
Conversation
MCP create_timesheet built its own TimesheetRequest with no sell price, so a server that
cannot derive one answered 400 and entries created through MCP could differ from CLI ones
on the same inputs.
TimesheetCreateService now owns every resolution a create needs — sell price from the
client rate for the billable type, category from the repo mapping then recent entries,
location from the WFH defaults, deducted minutes, iteration, and the read-back that turns
an empty write response into the saved row. `ts create` and the MCP tool are adapters over
it. The interactive missing-rate workflow stays in the command: PrepareAsync reports a
missing rate and never writes one, and MCP answers with the recovery command from
RateGuard's shared recipe instead.
A malformed date now raises a validation error rather than escaping as a FormatException,
so both surfaces answer with their own error shape.
The CreateTimesheet result golden changes deliberately. The tool returned the API's empty
body serialised as literal `null`, which no client could use; it now returns the CLI's
`{"success":true,"timesheetId":N,"timesheet":{...}}` document, serialised with the CLI's
options so the two are byte-identical. The create row in McpCliParityTable is at parity
with no permitted differences.
Adding that row shifts the five parity rows after it, whose goldens carry the row index in
their name, so those ten snapshots are renamed with their contents untouched.
The populated golden case now writes the note and start time of the day's existing row so
the empty-body read-back has something to find.
Closes #57
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 12, 2026
There was a problem hiding this comment.
🟡 Changes recommended
The MCP smoke check must require a present, matching reported timesheet ID; the create-path guidance should also account for ts copy.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR centralizes CLI and MCP timesheet creation through a shared service, including pricing, validation, resolution, missing-rate handling, and read-back.
Changes:
- Adds
TimesheetCreateServiceand registers it in CLI, MCP, and test hosts. - Aligns CLI/MCP payloads and missing-rate behavior.
- Adds unit, integration, parity, golden, and smoke-test coverage.
- Updates documentation and parity snapshots.
File summaries
| File | Summary |
|---|---|
tests/SSW.TimePro.Cli.Tests/Features/Timesheets/TimesheetCreateServiceTests.cs |
Service unit tests |
tests/SSW.TimePro.Cli.Tests/Features/Timesheets/LessOptionTests.cs |
CLI test dependency updates |
tests/SSW.TimePro.Cli.Integration/Mcp/McpToolHost.cs |
Test MCP host wiring |
tests/SSW.TimePro.Cli.Integration/Mcp/McpToolCatalog.cs |
MCP fixture updates |
tests/SSW.TimePro.Cli.Integration/Mcp/McpCliParityTable.cs |
Create parity coverage |
tests/SSW.TimePro.Cli.Integration/Mcp/CliRunner.cs |
CLI test host registration |
tests/SSW.TimePro.Cli.Integration/Mcp/CliMcpCreatePayloadTests.cs |
CLI/MCP payload and result tests |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Tools/CreateTimesheet.populated.json |
Create result golden |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/SearchClients.13.mcp.json |
Renumbered MCP parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/SearchClients.13.cli.json |
Renumbered CLI parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ListIterations.12.mcp.json |
Renumbered MCP parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/ListIterations.12.cli.json |
Renumbered CLI parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetProjectsForClient.10.mcp.json |
Renumbered MCP parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetProjectsForClient.10.cli.json |
Renumbered CLI parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetCrmBookings.14.mcp.json |
Renumbered MCP parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetCrmBookings.14.cli.json |
Renumbered CLI parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetClientRate.11.mcp.json |
Renumbered MCP parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/GetClientRate.11.cli.json |
Renumbered CLI parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.mcp.json |
MCP create parity snapshot |
tests/SSW.TimePro.Cli.Integration/Goldens/Mcp/Parity/CreateTimesheet.9.cli.json |
CLI create parity snapshot |
tests/SSW.TimePro.Cli.Integration/Features/McpTimesheetWriteTests.cs |
MCP write test updates |
src/SSW.TimePro.Cli/Program.cs |
CLI service registration |
src/SSW.TimePro.Cli/Features/Timesheets/TimesheetCreateService.cs |
Shared create orchestration |
src/SSW.TimePro.Cli/Features/Timesheets/CreateCommand.cs |
CLI adapter integration |
src/SSW.TimePro.Cli/Features/Rates/RateGuard.cs |
Missing-rate recovery |
src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs |
MCP adapter integration |
src/SSW.TimePro.Cli/Features/Mcp/McpHostCommand.cs |
MCP service registration |
scripts/e2e/mcp_smoke.py |
MCP create/read-back validation |
AGENTS.md |
Timesheet write guidance |
Review details
Suppressed comments (1)
AGENTS.md:117
CopyCommandis another create surface: it still builds aTimesheetRequestinCopyCommand.cs:137and callsCreateTimesheetAsyncat line 153 without aSellPrice. That bypasses this service and leavestp ts copyexposed to the same missing-price 400 this change fixes forts create/MCP, while this new guidance says no create should build requests elsewhere. Please either routets copythrough the service or narrow this statement to the migrated surfaces.
read-back that turns an empty write response into the saved row. `ts create` and the
`CreateTimesheet` MCP tool are adapters over it; never build a `TimesheetRequest` for a create
elsewhere either.
- Files reviewed: 19/29 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+337
to
+338
| reported_id = create_result.get("timesheetId") if isinstance(create_result, dict) else None | ||
| if reported_id not in (None, created_id): |
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.
Summary
Slice 2 of #38. MCP
create_timesheetbuilt its ownTimesheetRequestwith noSellPrice, so areal server that cannot derive one answered 400 — the regression harness's staging smoke gate was
red on exactly this.
ts createmeanwhile resolved the client rate and sell price, discovered thecategory, handled deducted minutes and iteration, and returned the saved entry.
Both surfaces are now adapters over one
TimesheetCreateService.Closes #57
Supersedes #68, which GitHub auto-closed when its base branch (the harness branch) was deleted at
#65's merge. Same work, rebased onto
mainon top of #65, #66 and #69.What changed
Features/Timesheets/TimesheetCreateService.cs, same prepare/apply shape asTimesheetUpdateService:TimesheetCreateOptions→PrepareAsync→ a plan carrying the fullyresolved
TimesheetRequest→ApplyAsyncposts and re-reads. It owns sell price (regular rate forB/W, prepaid forBPP), category (explicit → repo mapping → last fortnight's entries),location (WFH defaults + alias resolution), deducted minutes → hours, iteration and the read-back.
ts createis an adapter and keeps its preview, confirmation and interactive missing-rateworkflow:
PrepareAsyncreturns a missing-rate outcome and never creates a rate.CreateTimesheetcalls the same service. On a missing rate it returns an error carryingRateGuard's recovery recipe (the same shape the CLI envelope uses) and writes nothing — noimplicit rate, no timesheet.
datenow raises aTimesheetValidationExceptioninstead of escaping as aFormatException, so MCP answers with its error payload and the CLI with its--jsonenvelope andexit 1 rather than an unhandled exception.
TimesheetCreateServiceregistered in both DI containers (Program.cs,McpHostCommand.cs).scripts/e2e/mcp_smoke.pynow cross-checks thetimesheetIdthe create tool reports against therow it reads back.
Wire shape (per the agreed policy)
The CLI
ts create --jsondocument is unchanged. The MCP result does change, deliberately: thetool used to serialise the API's empty body as literal
null(locked inGoldens/Mcp/Tools/CreateTimesheet.populated.json), which no client could use. It now returns theCLI's
{"success":true,"timesheetId":N,"timesheet":{...}}, serialised with the CLI's options so thetwo documents are byte-identical — the policy's permitted exception, since
nullwas never a usablecontract. The create row in
McpCliParityTableisExpectParity = truewith no permitteddifferences. The
tools/listdiscovery goldens are untouched: no argument, description or schemachanged.
Golden renames
Goldens/Mcp/Parity/*carries the parity row index in its filename, so inserting the create rowshifts the five rows after it. Ten snapshots are renamed with their contents byte-identical —
git diff -Mshows them as pure renames.How verified
By execution:
New tests:
CliMcpCreatePayloadTests(13) — canonicalisedSaveTimesheetpayload equality betweents createand the tool forB/BPP/Wpricing, category precedence (explicit / repo mapping /recent entries, asserting the query is skipped when the mapping hits), location alias and WFH
default, iteration, and the identical result document. Plus, on both surfaces: an expired rate
produces zero
SaveClientRateand zeroSaveTimesheetrequests; an empty write response isfollowed by a
GetTimesheetListViewModelafter the single POST and the returned documentcarries the saved entry's id, project, note and start time; a malformed date writes nothing and
returns the surface's error shape.
TimesheetCreateServiceTests(22) — the service's own resolution rules and apply outcomes.Forced failures (the checks can fail):
SellPrice = nullin the service → 4 unit tests, the three pricing payload-equality cases and thecreate parity row fail.
ApplyAsync→Create_WhenTheApiAnswersWithAnEmptyBody_...fails onboth surfaces.
Staging (
--tenant ssw-staging,isProduction: false,apiUrlcontainsstaging), CLI from sourceon a Northwind project that uses iterations:
The MCP stdio gate is green end to end for the first time:
Every staging row created during verification was deleted and its absence verified; the day ends with
0 rows. No staging rate was created.
By inspection only: the interactive missing-rate prompt (
ts createwithout--yes/--json) — itsnon-interactive and
--reject-if-rate-expiredbranches are covered by tests, the prompt itself isnot automatable here.
Left out
create_timesheetstill has no deducted-minutes argument, so that dimension of thepayload-equality suite is CLI-only. Adding the argument changes the tool schema and the discovery
goldens, which the policy puts in a contract release, not this patch.
DateTime.Parseon the rate expiry keeps its existing culture-sensitive behaviour; moving it toinvariant is a separate fix.
Risks
method/path/body, forbidden requests, call counts) are the guard, and staging confirmed a real
create/read-back/delete on both surfaces.
nullcreate result see a JSON object instead. That is the intended,documented break; nothing could have been reading a value out of
null.will do the same until the harness keys those snapshots on something stable.
🤖 Generated with Claude Code