Repository navigation
feat(mcp): read tools for customers, orders, payments and balances - #1216
Conversation
The balance read now runs as one SQL query that joins two grouped subqueries, largest outstanding first with the customer id breaking ties. A paged overload on ICommerceModule reads only the rows it returns, for the MCP balance tool (#808).
Amounts cross as McpMoney, a decimal string in the currency's precision. The order list asks the SalesAccess policy live for what an order still owes. Guard row 28 walks every reply for minor units.
Three ties came back in customer-id order without the tie-break, because Postgres groups in that order. Twenty-four ties read three at a time make it use its unstable top-N sort, so dropping the tie-break now fails.
…-reads # Conflicts: # tests/Cluckwork.Api.IntegrationTests/Mcp/McpAuthorizationTests.cs
[MirrorsRoute] takes several route names, and a tool's roles must sit inside their intersection. The gate walk now requires exactly one scope policy, farm:read for a read tool and daily-entries:write for a write, and every OAuth scope must be required by some tool (#810).
…el scopes The money guard now covers the write tool's reply as well as the reads. The scope-use check reads policies from the tool's declaring class, as the SDK and the gate walk do. Both gaps were found in a Codex review.
Live MCP run against a running stackI ran the full procedure in
The default farm's currency is locked once it has sales ( These findings do not block this PR:
|
|
A Codex review at
|




Closes #808
Closes #810
Four read tools let a connected app answer sales questions: who the customers are, what was sold, what was paid, and who still owes money. Every amount arrives as a decimal string in its currency's precision, so a model never sees minor units. The PR also finishes #810: a tool can mirror several REST routes, and every tool's scope is checked.
Tools
cluckwork_list_customersSalesFlowfarm:readListCustomerscluckwork_list_sales_ordersSalesFlowfarm:readListSalesOrders,GetSalesOrdercluckwork_list_order_paymentsSalesAccessfarm:readListOrderPaymentscluckwork_list_customer_balancesSalesAccessfarm:readListCustomerBalancesEach tool is
ReadOnly = true, constructsMcpCallContext, and returnsMcpPage<T>built byMcpPage<T>.ReadAsync.GET /salesdoes (Sales: the Orders list cannot show which orders are unpaid #769), it asksAuthPolicies.SalesAccessthroughIAuthorizationServiceon each call. A Worker gets each order withoutstanding: null, andunpaid: trueis refused.McpCallContextnow exposes the request'sClaimsPrincipalfor this.ListSalesOrdersandGetSalesOrder.ponytail:comment names that ceiling.CustomerBalancehas no currency, so the tool readsIFarmModule.GetSettingsAsync, asPaymentEndpointsdoes. If the settings are missing, the call fails instead of falling back to two decimals.Contract and repository
ICommerceModule.ListCustomerBalancesAsync(int limit, int offset, ct)returns one page ofCustomerBalance.PaymentRepositorynow builds balances as one SQL query: two grouped subqueries joined by customer, ordered by outstanding descending, then customer id. The old code loaded both grouped lists and joined them in memory. The REST route keeps its unpaged method, which uses the same query and is now ordered too.Free text left out (#792)
These fields are not in any tool result:
CustomerDetails:Phone,Email,Address,Note.SalesOrderDetails:VoidReason,DiscountReasonNote.PaymentDetails:ReferenceNumber,Note,VoidReason.Phone and address are free text with only a length limit, and a Worker can set them. #792 allows names but no other text, so they stay out. Email goes with them. The tools return customer names, grade names, the order reference (
SO-plus eight hex digits, set by code) and enum values.Route and scope parity (#810)
[MirrorsRoute]takes several names.McpRouteParityTestschecks each named route separately. The tool's roles must sit inside the intersection, and every name must resolve.[MirrorsNoRoute]is not added. Every tool here has a REST twin.farm:readfor aReadOnlytool anddaily-entries:writefor any other.RealTree_EveryOAuthScopeIsRequiredByAToolfails if any scope inOAuthScopes.Allis required by no tool. It reads the scopes from the policies'OAuthScopeRequirement, not from their names, and it reads the policies on the tool's class as well as on the method, as the SDK does.HiddenTool_IsRefused_WhenCalledAnywaynow also calls the realcluckwork_record_daily_entrywith afarm:read-only token, which is refused. Thetools/listtheory already shows that token gets no write tool.The proof mutation.
/api/v1/customersrestricted toAdminOnlyinProgram.cs, nothing underMcp/changed:The balance route lives under the same group, so its tool is named too.
The parity guard compares declared policies only. Changing
McpCallContext's principal, or making an authorization decision inside a tool body, is a review obligation; the guard does not prove it.Guards
McpToolSurfaceTests.RealTree_ToolRepliesCarryNoMinorUnitswalks every tool's reply type, read and write, the way the serializer does. It fails on any field whose name containsMinorUnit. That covers the amount and the exponent, including fields inside a nestedMoney.McpCommerceToolsTests.EveryCommerceTool_PagesWithCompletenessAndContinuationchecks all four tools.BalancePage_ReadsOnlyThePageFromTheDatabasecounts the rows each data reader returned, usingReaderRowCountsfrom MCP slice 4: read tools — flocks, stock, egg lots, daily entries #807, now shared inMcpConnection.BalancePages_OrderByOutstanding_ThenCustomerIdpages three at a time through 24 tied balances against real Postgres, which pushes Postgres into its unstable top-N sort. Only the customer-id tie-break keeps every customer, once, in order.CrossFarmBearer_ReadsNoneOfTheOtherFarmsSales.Amounts_AreDecimalsInTheirCurrencysPrecisionplants a note and a reference and checks that no reply contains them.Mutations
Each mutant below is a row in
tools/oauth/mutation-check.sh, run withMUTANT_FILTER. The baseline and the restore run were green (415/415 for the #808 rows, 444/444 for the #810 rows).reply-minor-unitslong? OutstandingMinorUnitsonSalesOrderRowRealTree_ToolRepliesCarryNoMinorUnitswrite-reply-minor-unitslong TotalMinorUnitson the write tool'sRecordedDailyEntryRealTree_ToolRepliesCarryNoMinorUnitsbalances-paged-in-memoryBalancePage_ReadsOnlyThePageFromTheDatabase(row 31 stays green)balances-no-tie-breakBalancePages_OrderByOutstanding_ThenCustomerIdmoney-tier-ignoredWorker_ListsOrdersWithoutWhatTheyOwe_AndCannotFilterByItpayments-wider-than-routeSalesFlowRealTree_EveryToolMirrorsARoute_AndAdmitsNoRoleItDeniescustomer-note-returnedNoteonCustomerRowRealTree_ReadToolRepliesReturnOnlyReviewedTextbalance-currency-assumedAmounts_AreDecimalsInTheirCurrencysPrecisioncustomers-route-admin-only/api/v1/customersonAdminOnly, nothing underMcp/RealTree_EveryToolMirrorsARoute_AndAdmitsNoRoleItDeniesparity-first-route-onlyToolBreakingParity_IsAFindingscope-unmatched-to-readonlyReadOnlyToolMissingAGate_IsAGateFindingwrite-tool-on-read-scopecluckwork_record_daily_entryonfarm:readRealTree_EveryToolCarriesARolePolicyAndAScopePolicywrite-scope-unusedRealTree_EveryOAuthScopeIsRequiredByAToolscope-use-method-onlyScopeDeclaredOnTheToolClass_CountsAsRequiredThe #808 rows ran against a 415-test baseline, and the #810 rows against 444 after merging
main. After the review fixes,reply-minor-units,write-reply-minor-unitsandscope-use-method-onlyran against 445, with baseline and restore green. A first #808 run left its mutations applied, because the two new files were missing from the script's restore list. That is fixed, and the rerun above restored each file between mutants.Verification
Local, filtered runs: the Application architecture and
CommerceModuleTestssuites (391 passed). Integration suites:Mcp.*,PaymentsTests,RoleMatrixTests,AdminGatingTestsandBodyReadingEndpointTests(173 passed before the #810 changes). After mergingmain, the parity, surface, authorization and endpoint suites passed (89 tests).User-facing docs (GLOSSARY, Help) belong to #811, and the tools are listed there in a comment.