diff --git a/docs/decisions/806-mcp-endpoint.md b/docs/decisions/806-mcp-endpoint.md index 84b56abba..4500390d9 100644 --- a/docs/decisions/806-mcp-endpoint.md +++ b/docs/decisions/806-mcp-endpoint.md @@ -96,7 +96,8 @@ which only classifies binding errors. `tools/call` for a tool the caller cannot see with `InvalidRequest` inside a 200. Only the endpoint gate answers `insufficient_scope`. A client that follows `tools/list` never calls a hidden tool. -- **Route parity for tools** (guard rows 13 to 15) arrives with the first tool (#807). +- **Route parity for tools** (guard rows 13 to 15) arrived with the first tools (#807), as + `McpRouteParityTests`. ## How it is enforced diff --git a/docs/plans/770-mcp-server/02-guards.md b/docs/plans/770-mcp-server/02-guards.md index c99e0d4ef..cb38cebfd 100644 --- a/docs/plans/770-mcp-server/02-guards.md +++ b/docs/plans/770-mcp-server/02-guards.md @@ -50,8 +50,12 @@ only proves that each serving guard fails a boot, not that `/mcp` works.) | 16 | `ToolsList_IsFilteredByRole` (integration, real request) | unauthorized tools are invisible, not merely refused | remove `AddAuthorizationFilters()`. Single point of proof for SDK list-filtering behaviour — flagged as a risk in [`01-design.md`](01-design.md). | #806 implements row 16 as `McpAuthorizationTests.ToolsList_ShowsOnlyToolsTheRoleAndScopesAllow`, -over probe tools that exist only in its test host, for role and for scope. Rows 13 to 15 wait -for the first tool (#807): with no tool there is no route to mirror. #806 adds a walk the +over probe tools that exist only in its test host, for role and for scope. #807 implements rows +13 to 15 as `McpRouteParityTests`. A tool method carries `[MirrorsRoute("")]`, and the +test combines the tool's role gates and the route's authorization metadata through the live +`IAuthorizationService`, then checks Owner, Manager, Sales, ReadOnly and Worker callers. No tool +needs `[MirrorsNoRoute]` yet, so that attribute does not exist; add it with the first tool that +has no route. #806 adds a walk the table did not have, `McpToolSurfaceTests.RealTree_EveryToolCarriesARolePolicyAndAScopePolicy`: the SDK runs a tool with no `[Authorize]`, or with `[AllowAnonymous]`, for any caller. @@ -86,6 +90,26 @@ and `McpMessages_WithoutAnIdempotencyKey_AreServed`. | 31b | `BalanceReads_AreBoundedInTheDATABASE` (row-consumption assertion) | the **query** is bounded, not just the reply | keep the reply's `Take(cap)` and remove the bound from the query. Row 31 stays GREEN — every response is correctly capped with correct continuation metadata — while `ListCustomerBalancesAsync` materializes the entire farm's balance book first. `PaymentRepository` takes no paging arguments and materializes both grouped queries before building its list, so **reusing it at all is the defect**; this needs a paged balance query, and the test must assert rows consumed from the database, not items returned. A signature walk cannot establish this. **Measure rows CONSUMED, not SQL shape.** `ReportQueryBoundingTests`' `SqlCaptureInterceptor` is the right harness shape and the right precedent (#311 was the identical "loaded the whole ledger, walked it in memory" defect), but it records `(Sql, Parameters)` only — so an implementation that fetches every page in a loop and accumulates the book emits perfectly paged SQL and passes. Keep SQL inspection as *supporting* evidence and count consumption across the whole operation: wrap the returned `DbDataReader` and count successful `Read`/`ReadAsync` calls, covering both aggregate queries and any page-fetch loop. (Round-3 finding 3 — the round-2 fix named a mechanism that cannot measure what the row requires.) (Round-2 finding 2 — row 31 alone claimed an invariant its mutation did not test.) | | 32 | `NameResolution_IsExactlyOneAccessibleMatch` | a name-addressed write cannot hit the wrong row | make resolution take the first match. **Duplicate flock names are legal** (`FlockRepository.cs` says so, and its `ThenBy(f => f.Id)` tie-break exists because of it), so first-match records production against an arbitrary flock and `Single` throws on valid farm data. Zero matches → not-found; two or more → an ambiguity error listing candidate ids and distinguishing fields, and the follow-up write must pass an id. Test two same-name flocks in one accessible scope. (#770 adversarial review, finding 3.) | +#807 implements row 26 as `McpReadToolsTests.CrossFarmBearer_ReadsNothingOfTheOtherFarm`, and +row 31 for both the reply and the query. `McpPage.ReadAsync` is the only way to build a reply. +It clamps the limit, keeps a nonnegative offset as given, and asks the query for one row past the +page, so no tool chooses the row count. A continuation past `int.MaxValue` is refused, never +clamped back onto a page already read. `McpPageTests` pins it without a host. `McpToolSurfaceTests.RealTree_EveryReadToolReturnsAPage` +walks every read tool for an `McpPage` reply, and `McpReadToolsTests` drives each tool through its +page. `LotMovementPage_ReadsOnlyThePageFromTheDatabase` and +`SharedFlockName_ListsBoundedCandidates_AndReadsBoundedRows` count the rows each data reader +returned, so a query that loads everything and pages in memory fails. Flock name resolution +reads at most `FlockNameResolution.MaxCandidates + 1` rows and says when more flocks share the +name. One tool still pages in memory: `cluckwork_read_stock` gets one SQL-summed row per grade, +bounded by the grade catalogue, and a code comment names that ceiling. Row 32's ambiguity +contract applies to the read filter too. #388's flock scope is tested for the flock, entry and +egg reads (`AssignedWorker_ReadsOnlyTheirFlocks`, `AssignedWorker_ReadsOnlyTheirFlocksEggs`). +#792 decided that phase 1 tools return no farm-entered free text, and its 2026-10-11 +clarification allows flock and grade names. `McpToolSurfaceTests.RealTree_ReadToolRepliesReturnOnlyReviewedText` +follows each reply as the serializer does, through nested types from any namespace, collections, +arrays and conditionally ignored properties, and lists every text field it reaches. A new note or reason field, or a +reply shape it cannot see into, fails. + #806 implements rows 29 and 30 as `McpEndpointTests.McpPost_CarriesTheBodyCap_TheBudget_AndTheOAuthGate` and `OversizedBody_IsRefused`. `/mcp` takes its budget from `AcceptOAuthTokens`, the `oauth-api` policy, which `OAuthFailClosedTests.OAuthLimits_AreDecidedByTheSharedCounter` diff --git a/src/Cluckwork.Api/Mcp/DailyEntryTools.cs b/src/Cluckwork.Api/Mcp/DailyEntryTools.cs new file mode 100644 index 000000000..27d9df426 --- /dev/null +++ b/src/Cluckwork.Api/Mcp/DailyEntryTools.cs @@ -0,0 +1,61 @@ +using System.ComponentModel; +using Cluckwork.Application.Modules.EggOperations.Contracts; +using Cluckwork.Application.Modules.FlockManagement.Contracts; +using Microsoft.AspNetCore.Authorization; +using ModelContextProtocol.Server; + +namespace Cluckwork.Api.Mcp; + +// #807 — daily production entries. The adjust and void reasons are farm-entered text and +// are left out (#792). Flock scope comes from the request's query filters. +[McpServerToolType] +public sealed class DailyEntryTools +{ + private readonly IEggOperationsModule eggs; + private readonly IFlockLookup flocks; + + // Constructing McpCallContext is the identity check (#805); nothing here reads it. + public DailyEntryTools(McpCallContext _, IEggOperationsModule eggs, IFlockLookup flocks) + { + this.eggs = eggs; + this.flocks = flocks; + } + + [McpServerTool(Name = "cluckwork_list_daily_entries", ReadOnly = true)] + [Authorize] + [Authorize(Policy = AuthPolicies.FarmReadScope)] + [MirrorsRoute("ListDailyEntries")] + [Description("Daily production entries, newest first: eggs collected by grade, cracked, dirty and discarded eggs, " + + "and mortality, per flock and day. Paged: when complete is false, call again with offset set to nextOffset.")] + public async Task> ListDailyEntriesAsync( + [Description("Only this flock's entries, by name or id. A name shared by several flocks is refused with their ids.")] + string? flock = null, + [Description("Earliest day, yyyy-mm-dd.")] DateOnly? from = null, + [Description("Latest day, yyyy-mm-dd.")] DateOnly? to = null, + [Description("Entries per page, 1 to 100.")] int limit = McpPage.DefaultLimit, + int offset = 0, + CancellationToken ct = default) + { + var flockId = flock is null ? (Guid?)null : (await FlockTools.ResolveAsync(flocks, flock, ct)).Id; + var grades = (await eggs.ListAllGradesAsync(ct)).ToDictionary(g => g.Id, g => g.Name); + return await McpPage.ReadAsync(limit, offset, async (take, skip, ct) => + { + var rows = await eggs.ListDailyEntriesAsync(flockId, from, to, take, skip, ct); + var names = await flocks.GetDisplayNamesAsync([.. rows.Select(e => e.FlockId).Distinct()], ct); + return (IReadOnlyList<(DailyEntryDetails Entry, string? FlockName)>) + [.. rows.Select(e => (e, names.GetValueOrDefault(e.FlockId)?.Name))]; + }, + r => new DailyEntryRow( + r.Entry.Id, r.Entry.FlockId, r.FlockName, r.Entry.Date, r.Entry.Status.ToString(), + r.Entry.TotalEggs, r.Entry.CrackedEggs, r.Entry.DirtyEggs, r.Entry.DiscardedEggs, r.Entry.MortalityCount, + [.. r.Entry.Grades.Select(g => new GradeCount(grades.GetValueOrDefault(g.EggGradeId), g.Quantity))]), + ct); + } +} + +public sealed record DailyEntryRow( + Guid DailyEntryId, Guid FlockId, string? FlockName, DateOnly Date, string Status, + int TotalEggs, int CrackedEggs, int DirtyEggs, int DiscardedEggs, int MortalityCount, + IReadOnlyList Grades); + +public sealed record GradeCount(string? Grade, int Quantity); diff --git a/src/Cluckwork.Api/Mcp/FlockTools.cs b/src/Cluckwork.Api/Mcp/FlockTools.cs new file mode 100644 index 000000000..9227a9c34 --- /dev/null +++ b/src/Cluckwork.Api/Mcp/FlockTools.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using Cluckwork.Application.Common; +using Cluckwork.Application.Modules.FlockManagement.Contracts; +using Microsoft.AspNetCore.Authorization; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace Cluckwork.Api.Mcp; + +// #807 — flock reads. Tenant and flock scope come from the request's query filters, so a +// Worker sees only assigned flocks. Breed is left out with every other farm-entered text +// field (#792). +[McpServerToolType] +public sealed class FlockTools +{ + private readonly IFlockModule flocks; + private readonly IFarmClock farmClock; + + // Constructing McpCallContext is the identity check (#805); nothing here reads it. + public FlockTools(McpCallContext _, IFlockModule flocks, IFarmClock farmClock) + { + this.flocks = flocks; + this.farmClock = farmClock; + } + + [McpServerTool(Name = "cluckwork_list_flocks", ReadOnly = true)] + [Authorize] + [Authorize(Policy = AuthPolicies.FarmReadScope)] + [MirrorsRoute("ListFlocks")] + [Description("The farm's flocks you can see, by name, with birds alive and whether each accepts production today. " + + "Paged: when complete is false, call again with offset set to nextOffset.")] + public async Task> ListFlocksAsync( + [Description("'active', 'active-and-depleted' (default) or 'all', which adds archived flocks.")] + string status = "active-and-depleted", + [Description("Only flocks whose name contains this text, ignoring case.")] + string? nameContains = null, + [Description("Flocks per page, 1 to 100.")] int limit = McpPage.DefaultLimit, + int offset = 0, + CancellationToken ct = default) + { + var eligibility = status switch + { + "active" => FlockEligibility.Active, + "active-and-depleted" => FlockEligibility.ActiveAndDepleted, + "all" => FlockEligibility.All, + _ => throw new McpException("status must be 'active', 'active-and-depleted' or 'all'."), + }; + var today = await farmClock.TodayAsync(ct); + return await McpPage.ReadAsync(limit, offset, async (take, skip, ct) => + { + var rows = await flocks.SearchAsync(nameContains, eligibility, take, skip, ct); + var removed = await flocks.GetBirdsRemovedAsync([.. rows.Select(f => f.Id)], ct); + return (IReadOnlyList<(FlockDetails Flock, long Removed)>)[.. rows.Select(f => (f, removed.GetValueOrDefault(f.Id)))]; + }, + r => new FlockRow( + r.Flock.Id, r.Flock.Name, r.Flock.Status.ToString(), r.Flock.PlacementDate, r.Flock.InitialCount, + r.Flock.InitialCount - r.Removed, r.Flock.DepletedOn, r.Flock.ArchivedOn, + r.Flock.CanRecordProductionOn(today)), + ct); + } + + // A model holds names, and duplicate flock names are legal, so a name resolves only + // to exactly one visible flock. Text that parses as an id is read as one. The refusal + // lists at most FlockNameResolution.MaxCandidates ids. + internal static async Task ResolveAsync(IFlockLookup lookup, string flock, CancellationToken ct) + { + if (Guid.TryParse(flock, out var id)) + return (await lookup.GetDisplayNamesAsync([id], ct)).GetValueOrDefault(id) ?? throw NotFound(flock); + + return await lookup.ResolveByNameAsync(flock.Trim(), ct) switch + { + FlockNameResolution.Found found => found.Flock, + FlockNameResolution.Ambiguous ambiguous => throw new McpException( + $"More than one flock is named '{flock.Trim()}'. Call again with flock set to one of these ids: " + + string.Join(", ", ambiguous.Candidates.Select(c => $"{c.Id} ({c.Status})")) + + (ambiguous.MoreExist + ? ". More flocks share this name than are listed; find the one you mean with cluckwork_list_flocks, status 'all' and nameContains." + : ".")), + _ => throw NotFound(flock), + }; + } + + private static McpException NotFound(string flock) => new($"No flock you can see is named or numbered '{flock.Trim()}'."); +} + +public sealed record FlockRow( + Guid FlockId, string Name, string Status, DateOnly PlacementDate, int InitialCount, long BirdsAlive, + DateOnly? DepletedOn, DateOnly? ArchivedOn, bool AcceptsProductionToday); diff --git a/src/Cluckwork.Api/Mcp/McpPage.cs b/src/Cluckwork.Api/Mcp/McpPage.cs new file mode 100644 index 000000000..eecfbe0e4 --- /dev/null +++ b/src/Cluckwork.Api/Mcp/McpPage.cs @@ -0,0 +1,43 @@ +using ModelContextProtocol; + +namespace Cluckwork.Api.Mcp; + +// #807 — every read tool's reply. A silently truncated first page reads to a model as a +// complete answer, so a reply always says whether it is complete and where to continue. +// ReadAsync is the only way to build one: it asks the query for one row past the page, so +// a shorter answer proves the page is the last, and no tool chooses that row count. +public sealed record McpPage +{ + public IReadOnlyList Items { get; } + public bool Complete { get; } + public int? NextOffset { get; } + + private McpPage(IReadOnlyList items, bool complete, int? nextOffset) => + (Items, Complete, NextOffset) = (items, complete, nextOffset); + + // fetch(take, skip, ct) returns at most take rows starting at skip. + public static async Task> ReadAsync( + int limit, int offset, Func>> fetch, + Func map, CancellationToken ct) + { + var take = Math.Clamp(limit, 1, McpPage.MaxLimit); + var skip = Math.Max(offset, 0); + var rows = await fetch(take + 1, skip, ct); + if (rows.Count <= take) + return new([.. rows.Select(map)], complete: true, nextOffset: null); + + // An offset is an int, so a continuation past int.MaxValue cannot be expressed. + var next = (long)skip + take; + if (next > int.MaxValue) + throw new McpException("This page has more rows after it than an offset can reach. Narrow the request with its filters."); + return new([.. rows.Take(take).Select(map)], complete: false, nextOffset: (int)next); + } +} + +public static class McpPage +{ + // The REST page caps live in the endpoints, not the module contracts, so a tool sets + // its own. Smaller than REST's: a page lands in a model's context window. + public const int DefaultLimit = 50; + public const int MaxLimit = 100; +} diff --git a/src/Cluckwork.Api/Mcp/MirrorsRouteAttribute.cs b/src/Cluckwork.Api/Mcp/MirrorsRouteAttribute.cs new file mode 100644 index 000000000..a21b80b01 --- /dev/null +++ b/src/Cluckwork.Api/Mcp/MirrorsRouteAttribute.cs @@ -0,0 +1,10 @@ +namespace Cluckwork.Api.Mcp; + +// #807, guard rows 13 to 15 — the REST route a tool answers the same question as, by its +// WithName. A tool may admit no role its route denies; McpRouteParityTests holds it there, +// so tightening the route alone turns the test red. +[AttributeUsage(AttributeTargets.Method)] +public sealed class MirrorsRouteAttribute(string routeName) : Attribute +{ + public string RouteName { get; } = routeName; +} diff --git a/src/Cluckwork.Api/Mcp/StockTools.cs b/src/Cluckwork.Api/Mcp/StockTools.cs new file mode 100644 index 000000000..7d8576109 --- /dev/null +++ b/src/Cluckwork.Api/Mcp/StockTools.cs @@ -0,0 +1,118 @@ +using System.ComponentModel; +using Cluckwork.Application.Common; +using Cluckwork.Application.Modules.EggOperations.Contracts; +using Microsoft.AspNetCore.Authorization; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace Cluckwork.Api.Mcp; + +// #807 — egg stock and the lot ledger behind it. A movement's reason is farm-entered text +// and is left out (#792). +[McpServerToolType] +public sealed class StockTools +{ + private readonly IEggOperationsModule eggs; + private readonly IFarmClock farmClock; + + // Constructing McpCallContext is the identity check (#805); nothing here reads it. + public StockTools(McpCallContext _, IEggOperationsModule eggs, IFarmClock farmClock) + { + this.eggs = eggs; + this.farmClock = farmClock; + } + + // ponytail: the contract sums lots in SQL and returns one row per grade, which this pages + // in memory. Rows are bounded by the grade catalogue, which Owners and Managers curate and + // which the lot and entry tools, and the SPA's capture form, already load whole. Page it in + // the query if farms ever hold hundreds of grades. + [McpServerTool(Name = "cluckwork_read_stock", ReadOnly = true)] + [Authorize] + [Authorize(Policy = AuthPolicies.FarmReadScope)] + [MirrorsRoute("GetStock")] + [Description("Eggs in stock today, per grade. Available excludes withdrawal-restricted eggs, which are counted " + + "separately; belowFloor is true when available is under the grade's low-stock floor. " + + "Paged: when complete is false, call again with offset set to nextOffset.")] + public async Task> ReadStockAsync( + [Description("Grades per page, 1 to 100.")] int limit = McpPage.DefaultLimit, + int offset = 0, + CancellationToken ct = default) + { + var today = await farmClock.TodayAsync(ct); + return await McpPage.ReadAsync(limit, offset, + async (take, skip, ct) => (IReadOnlyList)[.. (await eggs.GetStockByGradeAsync(today, ct)).Skip(skip).Take(take)], + r => new StockRow(r.GradeName, r.Available, r.Restricted, r.LowStockFloor, r.BelowFloor), + ct); + } + + [McpServerTool(Name = "cluckwork_list_egg_lots", ReadOnly = true)] + [Authorize] + [Authorize(Policy = AuthPolicies.FarmReadScope)] + [MirrorsRoute("ListEggLots")] + [Description("Egg lots, newest production first: how many eggs each produced and how many are still available. " + + "Paged: when complete is false, call again with offset set to nextOffset.")] + public async Task> ListEggLotsAsync( + [Description("Only lots of this grade, by name or id.")] string? grade = null, + [Description("Earliest production date, yyyy-mm-dd.")] DateOnly? from = null, + [Description("Latest production date, yyyy-mm-dd.")] DateOnly? to = null, + [Description("Lots per page, 1 to 100.")] int limit = McpPage.DefaultLimit, + int offset = 0, + CancellationToken ct = default) + { + var grades = await eggs.ListAllGradesAsync(ct); + var gradeId = grade is null ? (Guid?)null : ResolveGrade(grades, grade); + var names = grades.ToDictionary(g => g.Id, g => g.Name); + return await McpPage.ReadAsync(limit, offset, + (take, skip, ct) => eggs.ListLotsAsync(gradeId, from, to, take, skip, ct), + l => new EggLotRow( + l.Id, names.GetValueOrDefault(l.EggGradeId), l.ProductionDate, l.QuantityProduced, + l.QuantityAvailable, l.RestrictedUntil, l.DailyEntryId), + ct); + } + + [McpServerTool(Name = "cluckwork_list_egg_lot_movements", ReadOnly = true)] + [Authorize] + [Authorize(Policy = AuthPolicies.FarmReadScope)] + [MirrorsRoute("ListEggLotMovements")] + [Description("One egg lot's movements, newest first: every change to its available quantity and what caused it. " + + "Paged: when complete is false, call again with offset set to nextOffset.")] + public async Task> ListEggLotMovementsAsync( + [Description("The lot's id, from cluckwork_list_egg_lots.")] Guid lotId, + [Description("Movements per page, 1 to 100.")] int limit = McpPage.DefaultLimit, + int offset = 0, + CancellationToken ct = default) + { + return await McpPage.ReadAsync(limit, offset, + async (take, skip, ct) => await eggs.ListLotMovementsAsync(lotId, take, skip, ct) + ?? throw new McpException($"No egg lot {lotId} exists on this farm."), + m => new EggLotMovementRow( + m.MovementType.ToString(), m.QuantityDelta, m.ReferenceType, m.ReferenceId, m.CreatedAtUtc), + ct); + } + + // Grade names are unique per farm, ignoring case, but an account's catalogue spans its farms. + private static Guid ResolveGrade(IReadOnlyList grades, string grade) + { + var text = grade.Trim(); + var matches = Guid.TryParse(text, out var id) + ? grades.Where(g => g.Id == id).ToList() + : grades.Where(g => string.Equals(g.Name, text, StringComparison.OrdinalIgnoreCase)).ToList(); + return matches switch + { + [var only] => only.Id, + [] => throw new McpException($"No egg grade is named or numbered '{text}'."), + _ => throw new McpException( + $"More than one egg grade is named '{text}'. Call again with grade set to one of these ids: " + + string.Join(", ", matches.Select(g => g.Id)) + "."), + }; + } +} + +public sealed record StockRow(string Grade, int Available, int Restricted, int? LowStockFloor, bool BelowFloor); + +public sealed record EggLotRow( + Guid LotId, string? Grade, DateOnly ProductionDate, int QuantityProduced, int QuantityAvailable, + DateOnly? RestrictedUntil, Guid? DailyEntryId); + +public sealed record EggLotMovementRow( + string MovementType, int QuantityDelta, string ReferenceType, Guid ReferenceId, DateTimeOffset RecordedAtUtc); diff --git a/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggOperationsModule.cs b/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggOperationsModule.cs index 42330b04b..ae49bb36b 100644 --- a/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggOperationsModule.cs +++ b/src/Cluckwork.Application/Modules/EggOperations/Contracts/IEggOperationsModule.cs @@ -52,6 +52,10 @@ Task> ListLotsAsync( // Null when the lot does not exist or belongs to another farm. Task?> ListLotMovementsAsync(Guid lotId, CancellationToken ct); + // One page of the same ledger, paged in the query (#807). + Task?> ListLotMovementsAsync( + Guid lotId, int limit, int offset, CancellationToken ct); + Task> RecordLotMovementAsync( RecordEggLotMovementCommand command, Guid accountId, CancellationToken ct); } diff --git a/src/Cluckwork.Application/Modules/EggOperations/Eggs/EggOperationsModule.cs b/src/Cluckwork.Application/Modules/EggOperations/Eggs/EggOperationsModule.cs index b4a9eb687..cf4a64362 100644 --- a/src/Cluckwork.Application/Modules/EggOperations/Eggs/EggOperationsModule.cs +++ b/src/Cluckwork.Application/Modules/EggOperations/Eggs/EggOperationsModule.cs @@ -81,13 +81,16 @@ public async Task> ListLotsAsync( l.Id, l.EggGradeId, l.ProductionDate, l.QuantityProduced, l.QuantityAvailable, l.RestrictedUntil, l.DailyEntryId)).ToList(); - public async Task?> ListLotMovementsAsync(Guid lotId, CancellationToken ct) - { - if (await lots.GetByIdAsync(lotId, ct) is null) return null; - return (await movements.ListByLotAsync(lotId, ct)).Select(m => new EggLotMovementDetails( - m.Id, m.MovementType, m.QuantityDelta, m.ReferenceType, m.ReferenceId, - m.Reason, m.CreatedAtUtc)).ToList(); - } + public async Task?> ListLotMovementsAsync(Guid lotId, CancellationToken ct) => + await lots.GetByIdAsync(lotId, ct) is null ? null : ToDetails(await movements.ListByLotAsync(lotId, ct)); + + public async Task?> ListLotMovementsAsync( + Guid lotId, int limit, int offset, CancellationToken ct) => + await lots.GetByIdAsync(lotId, ct) is null ? null : ToDetails(await movements.ListByLotAsync(lotId, limit, offset, ct)); + + private static List ToDetails(IReadOnlyList ledger) => + [.. ledger.Select(m => new EggLotMovementDetails( + m.Id, m.MovementType, m.QuantityDelta, m.ReferenceType, m.ReferenceId, m.Reason, m.CreatedAtUtc))]; public Task> RecordLotMovementAsync( RecordEggLotMovementCommand command, Guid accountId, CancellationToken ct) => diff --git a/src/Cluckwork.Application/Modules/EggOperations/Eggs/IEggInventoryMovementRepository.cs b/src/Cluckwork.Application/Modules/EggOperations/Eggs/IEggInventoryMovementRepository.cs index 5621adfc3..7c45a332f 100644 --- a/src/Cluckwork.Application/Modules/EggOperations/Eggs/IEggInventoryMovementRepository.cs +++ b/src/Cluckwork.Application/Modules/EggOperations/Eggs/IEggInventoryMovementRepository.cs @@ -8,4 +8,6 @@ public interface IEggInventoryMovementRepository Task AddRangeAsync(IEnumerable movements, CancellationToken ct = default); /// A lot's movements, newest first (CreatedAtUtc, then Sequence). Task> ListByLotAsync(Guid eggLotId, CancellationToken ct = default); + Task> ListByLotAsync( + Guid eggLotId, int limit, int offset, CancellationToken ct = default); } diff --git a/src/Cluckwork.Application/Modules/FlockManagement/Contracts/IFlockLookup.cs b/src/Cluckwork.Application/Modules/FlockManagement/Contracts/IFlockLookup.cs index 8c3a438a1..f3ceb6f1b 100644 --- a/src/Cluckwork.Application/Modules/FlockManagement/Contracts/IFlockLookup.cs +++ b/src/Cluckwork.Application/Modules/FlockManagement/Contracts/IFlockLookup.cs @@ -20,7 +20,8 @@ Task> GetDisplayNamesAsync( IReadOnlyCollection ids, CancellationToken ct); // Exact name match across every status. Duplicate names are legal, so a - // caller must handle Ambiguous rather than take the first match. + // caller must handle Ambiguous rather than take the first match. Reads at + // most MaxCandidates + 1 rows, however many flocks share the name (#807). Task ResolveByNameAsync(string name, CancellationToken ct); } @@ -42,12 +43,14 @@ public sealed record FlockDetails( public abstract record FlockNameResolution { + public const int MaxCandidates = 10; + private FlockNameResolution() { } public sealed record Found(FlockReference Flock) : FlockNameResolution; public sealed record NotFound : FlockNameResolution; - // Ordered by id. - public sealed record Ambiguous(IReadOnlyList Candidates) : FlockNameResolution; + // The first MaxCandidates by id; MoreExist when further flocks share the name. + public sealed record Ambiguous(IReadOnlyList Candidates, bool MoreExist) : FlockNameResolution; } diff --git a/src/Cluckwork.Application/Modules/FlockManagement/Flocks/FlockLookup.cs b/src/Cluckwork.Application/Modules/FlockManagement/Flocks/FlockLookup.cs index dd9dff426..7b16164cc 100644 --- a/src/Cluckwork.Application/Modules/FlockManagement/Flocks/FlockLookup.cs +++ b/src/Cluckwork.Application/Modules/FlockManagement/Flocks/FlockLookup.cs @@ -16,11 +16,12 @@ public Task> GetDisplayNamesAsync( flocks.GetDisplayNamesAsync(ids, ct); public async Task ResolveByNameAsync(string name, CancellationToken ct) => - await flocks.ListByNameAsync(name, ct) switch + await flocks.ListByNameAsync(name, FlockNameResolution.MaxCandidates + 1, ct) switch { [] => new FlockNameResolution.NotFound(), [var only] => new FlockNameResolution.Found(only), - var many => new FlockNameResolution.Ambiguous(many), + var many => new FlockNameResolution.Ambiguous( + [.. many.Take(FlockNameResolution.MaxCandidates)], many.Count > FlockNameResolution.MaxCandidates), }; internal static FlockDetails ToDetails(Flock f) => diff --git a/src/Cluckwork.Application/Modules/FlockManagement/Flocks/IFlockRepository.cs b/src/Cluckwork.Application/Modules/FlockManagement/Flocks/IFlockRepository.cs index 48fc61b6a..59926d2c6 100644 --- a/src/Cluckwork.Application/Modules/FlockManagement/Flocks/IFlockRepository.cs +++ b/src/Cluckwork.Application/Modules/FlockManagement/Flocks/IFlockRepository.cs @@ -47,9 +47,9 @@ Task> SearchAsync( Task> GetDisplayNamesAsync( IReadOnlyCollection flockIds, CancellationToken ct = default); - // #852 — every visible flock with exactly this name, ordered by id, through + // #852 — up to limit visible flocks with exactly this name, ordered by id, through // the same filtered set as GetDisplayNamesAsync. Duplicate names are legal. - Task> ListByNameAsync(string name, CancellationToken ct = default); + Task> ListByNameAsync(string name, int limit, CancellationToken ct = default); // Write-side lifecycle lookup (#388). Bypasses the request-start flock // snapshot after the live FlockScopeGuard succeeds, but reinstates AccountId diff --git a/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggInventoryMovementRepository.cs b/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggInventoryMovementRepository.cs index 737c734ea..37c9425ef 100644 --- a/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggInventoryMovementRepository.cs +++ b/src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggInventoryMovementRepository.cs @@ -15,9 +15,15 @@ public async Task AddRangeAsync(IEnumerable movements, Can public async Task> ListByLotAsync( Guid eggLotId, CancellationToken ct = default) => - await db.EggInventoryMovements + await ByLot(eggLotId).ToListAsync(ct); + + public async Task> ListByLotAsync( + Guid eggLotId, int limit, int offset, CancellationToken ct = default) => + await ByLot(eggLotId).Skip(offset).Take(limit).ToListAsync(ct); + + private IQueryable ByLot(Guid eggLotId) => + db.EggInventoryMovements .AsNoTracking() .Where(m => m.EggLotId == eggLotId) - .OrderByCreationChronologyDescending() - .ToListAsync(ct); + .OrderByCreationChronologyDescending(); } diff --git a/src/Cluckwork.Infrastructure/Modules/FlockManagement/Repositories/FlockRepository.cs b/src/Cluckwork.Infrastructure/Modules/FlockManagement/Repositories/FlockRepository.cs index 877bccbac..e9d39cc55 100644 --- a/src/Cluckwork.Infrastructure/Modules/FlockManagement/Repositories/FlockRepository.cs +++ b/src/Cluckwork.Infrastructure/Modules/FlockManagement/Repositories/FlockRepository.cs @@ -124,11 +124,12 @@ public async Task> GetDisplayNamesAsyn } public async Task> ListByNameAsync( - string name, CancellationToken ct = default) => + string name, int limit, CancellationToken ct = default) => await db.Flocks .AsNoTracking() .Where(f => f.Name == name) .OrderBy(f => f.Id) + .Take(limit) .Select(f => new FlockReference(f.Id, f.Name, f.Status)) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs index d3c263c9b..b3a7dec36 100644 --- a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs @@ -757,7 +757,7 @@ private async Task EnsureFlockAsync( return found.Flock.Id; case FlockNameResolution.Ambiguous ambiguous: throw new InvalidOperationException( - $"Simulation seed: {ambiguous.Candidates.Count} flocks are named {name} " + + $"Simulation seed: {(ambiguous.MoreExist ? "more than " : "")}{ambiguous.Candidates.Count} flocks are named {name} " + $"({string.Join(", ", ambiguous.Candidates.Select(c => c.Id))}). " + "Rename the duplicates or reset the fixture."); } diff --git a/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs b/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs index 7138f2a28..9ab84fa97 100644 --- a/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/ConfirmSaleAtomicityTests.cs @@ -114,6 +114,10 @@ public Task AddRangeAsync(IEnumerable movements, Cancellat public Task> ListByLotAsync(Guid eggLotId, CancellationToken ct = default) => inner.ListByLotAsync(eggLotId, ct); + + public Task> ListByLotAsync( + Guid eggLotId, int limit, int offset, CancellationToken ct = default) => + inner.ListByLotAsync(eggLotId, limit, offset, ct); } private sealed class FaultingAllocations(ISalesOrderAllocationRepository inner, FaultPoint fault, AppDbContext db) diff --git a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpAuthorizationTests.cs b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpAuthorizationTests.cs index 17af52761..493aa8f9d 100644 --- a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpAuthorizationTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpAuthorizationTests.cs @@ -22,6 +22,11 @@ namespace Cluckwork.Api.IntegrationTests.Mcp; public sealed class McpAuthorizationTests(CluckworkWebApplicationFactory factory) { private static readonly string[] BothScopes = [OAuthScopes.ReadFarm, OAuthScopes.WriteDailyEntries]; + private static readonly string[] ReadTools = + [ + "cluckwork_list_daily_entries", "cluckwork_list_egg_lot_movements", "cluckwork_list_egg_lots", + "cluckwork_list_flocks", "cluckwork_read_stock", + ]; [Theory] [InlineData("2026-07-28")] @@ -55,7 +60,9 @@ public async Task ToolsList_ShowsOnlyToolsTheRoleAndScopesAllow(string role, str await using var client = await ConnectAsync(host, token); var tools = await client.ListToolsAsync(); - Assert.Equal(expected.Order(), tools.Select(t => t.Name).Order()); + // The real read tools (#807) admit every role and need farm:read. + string[] reads = scopes.Contains(OAuthScopes.ReadFarm) ? ReadTools : []; + Assert.Equal(expected.Concat(reads).Order(), tools.Select(t => t.Name).Order()); } [Fact] diff --git a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpPageTests.cs b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpPageTests.cs new file mode 100644 index 000000000..2939aefff --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpPageTests.cs @@ -0,0 +1,102 @@ +using Cluckwork.Api.Mcp; +using ModelContextProtocol; + +namespace Cluckwork.Api.IntegrationTests.Mcp; + +// #807 F6 — the page builder alone, with a fake query: the window it asks for, and the +// completeness and continuation it reports from what comes back. +public sealed class McpPageTests +{ + [Theory] + [InlineData(-5, 1)] + [InlineData(0, 1)] + [InlineData(1, 1)] + [InlineData(100, 100)] + [InlineData(500, McpPage.MaxLimit)] + public async Task AsksForOneRowPastTheClampedLimit(int limit, int take) + { + var asked = new List<(int Take, int Skip)>(); + + await McpPage.ReadAsync(limit, 0, Rows(asked, 0), r => r, default); + + Assert.Equal([(take + 1, 0)], asked); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, 0)] + [InlineData(1_000_000, 1_000_000)] + [InlineData(int.MaxValue, int.MaxValue)] + public async Task KeepsANonnegativeOffset(int offset, int skip) + { + var asked = new List<(int Take, int Skip)>(); + + await McpPage.ReadAsync(10, offset, Rows(asked, 0), r => r, default); + + Assert.Equal([(11, skip)], asked); + } + + [Theory] + [InlineData(0)] + [InlineData(9)] + [InlineData(10)] + public async Task UpToTheLimit_IsComplete(int available) + { + var page = await McpPage.ReadAsync(10, 30, Rows([], available), r => r, default); + + Assert.Equal(available, page.Items.Count); + Assert.True(page.Complete); + Assert.Null(page.NextOffset); + } + + [Theory] + [InlineData(0, 10)] + [InlineData(30, 40)] + public async Task MoreThanTheLimit_KeepsThePage_AndContinuesAfterIt(int offset, int next) + { + var page = await McpPage.ReadAsync(10, offset, Rows([], 11), r => r, default); + + Assert.Equal(Enumerable.Range(0, 10), page.Items); + Assert.False(page.Complete); + Assert.Equal(next, page.NextOffset); + } + + // Near int.MaxValue a continuation is followed, never moved backwards, until the next + // offset no longer fits in an int; then the call is refused rather than repeated. + [Fact] + public async Task NearTheIntegerLimit_ContinuesForward_ThenRefuses() + { + var asked = new List<(int Take, int Skip)>(); + Func>> endless = (take, skip, _) => + { + asked.Add((take, skip)); + return Task.FromResult>([.. Enumerable.Range(0, take).Select(i => (long)skip + i)]); + }; + + var first = await McpPage.ReadAsync(100, int.MaxValue - 150, endless, r => r, default); + var refused = await Assert.ThrowsAsync(() => + McpPage.ReadAsync(100, first.NextOffset!.Value, endless, r => r, default)); + + Assert.Equal(int.MaxValue - 50, first.NextOffset); + Assert.Equal((long)int.MaxValue - 150, first.Items[0]); + Assert.Equal([(101, int.MaxValue - 150), (101, int.MaxValue - 50)], asked); + Assert.Contains("more rows after it than an offset can reach", refused.Message); + } + + [Fact] + public async Task MapsEveryKeptRow() + { + var page = await McpPage.ReadAsync(2, 0, Rows([], 3), r => $"row {r}", default); + + Assert.Equal(["row 0", "row 1"], page.Items); + } + + // A query that answers with up to take rows, from a source holding `available`. + private static Func>> Rows( + List<(int Take, int Skip)> asked, int available) => + (take, skip, _) => + { + asked.Add((take, skip)); + return Task.FromResult>([.. Enumerable.Range(0, Math.Min(available, take))]); + }; +} diff --git a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpReadToolsTests.cs b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpReadToolsTests.cs new file mode 100644 index 000000000..e81afb9b2 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpReadToolsTests.cs @@ -0,0 +1,347 @@ +using System.Collections.Concurrent; +using System.Data.Common; +using System.Text.Json; +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Api.Mcp; +using Cluckwork.Application.Modules.Access.Contracts; +using Cluckwork.Domain.Modules.EggOperations.Eggs; +using Cluckwork.Domain.Modules.Farm.Accounts; +using Cluckwork.Domain.Modules.Farm.Contracts; +using Cluckwork.Domain.Modules.FlockManagement.Flocks; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using static Cluckwork.Api.IntegrationTests.Mcp.McpConnection; + +namespace Cluckwork.Api.IntegrationTests.Mcp; + +// #807 — the read tools through the real OAuth flow and the SDK's client: guard rows 26 +// (a cross-farm bearer reads nothing), 31 (every reply is bounded and says so) and the +// name-resolution contract, plus #388's flock scope for a Worker. +[Collection(IntegrationCollection.Name)] +public sealed class McpReadToolsTests(CluckworkWebApplicationFactory factory) +{ + private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + [Fact] + public async Task CrossFarmBearer_ReadsNothingOfTheOtherFarm() + { + using var host = Host(factory); + var mine = await SeedAsync(factory, Roles.Manager); + var theirs = await SeedAsync(factory, Roles.Manager); + var myFlock = await SeedFlockAsync(mine.AccountId, "Mine"); + var theirFlock = await SeedFlockAsync(theirs.AccountId, "Theirs"); + var theirGrade = (await factory.SeedEggGradesAsync(theirs.AccountId, Guid.NewGuid(), "Theirs Large"))["Theirs Large"]; + var theirLot = await factory.SeedEggLotAsync(theirs.AccountId, theirGrade, 30); + await SeedEntryAsync(theirs.AccountId, theirFlock, Today); + + await using var client = await ConnectAsync(host, await IssueAsync(host, mine, [OAuthScopes.ReadFarm])); + + var flocks = await PageAsync(client, "cluckwork_list_flocks", new() { ["status"] = "all" }); + Assert.Equal([myFlock], flocks.Items.Select(f => f.FlockId)); + Assert.Empty((await PageAsync(client, "cluckwork_read_stock")).Items); + Assert.Empty((await PageAsync(client, "cluckwork_list_egg_lots")).Items); + Assert.Empty((await PageAsync(client, "cluckwork_list_daily_entries")).Items); + Assert.Contains("No flock you can see", + await ErrorAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = theirFlock.ToString() })); + Assert.Contains("No flock you can see", + await ErrorAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = "Theirs" })); + Assert.Contains($"No egg lot {theirLot}", + await ErrorAsync(client, "cluckwork_list_egg_lot_movements", new() { ["lotId"] = theirLot })); + Assert.Contains("No egg grade", + await ErrorAsync(client, "cluckwork_list_egg_lots", new() { ["grade"] = "Theirs Large" })); + } + + [Fact] + public async Task AssignedWorker_ReadsOnlyTheirFlocks() + { + using var host = Host(factory); + var worker = await SeedAsync(factory, role: null); + var assigned = await SeedFlockAsync(worker.AccountId, "Assigned"); + var other = await SeedFlockAsync(worker.AccountId, "Other"); + await SeedEntryAsync(worker.AccountId, assigned, Today); + await SeedEntryAsync(worker.AccountId, other, Today); + await factory.WithTenantScopeAsync(worker.AccountId, async db => + { + db.UserRoleAssignments.Add(UserRoleAssignment.Create(Guid.NewGuid(), worker.AccountId, worker.Id, null, null, assigned)); + await db.SaveChangesAsync(); + }); + + await using var client = await ConnectAsync(host, await IssueAsync(host, worker, [OAuthScopes.ReadFarm])); + + var flocks = await PageAsync(client, "cluckwork_list_flocks", new() { ["status"] = "all" }); + var entries = await PageAsync(client, "cluckwork_list_daily_entries"); + Assert.Equal([assigned], flocks.Items.Select(f => f.FlockId)); + Assert.Equal([assigned], entries.Items.Select(e => e.FlockId)); + Assert.Contains("No flock you can see", + await ErrorAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = "Other" })); + } + + // Duplicate flock names are legal, so a shared name is refused with every candidate's + // id rather than resolved to whichever row comes first. + [Fact] + public async Task SharedFlockName_IsRefusedWithTheCandidates_AndAnIdResolvesIt() + { + using var host = Host(factory); + var user = await SeedAsync(factory, Roles.Manager); + var first = await SeedFlockAsync(user.AccountId, "House 2"); + var second = await SeedFlockAsync(user.AccountId, "House 2"); + var unique = await SeedFlockAsync(user.AccountId, "House 3"); + await SeedEntryAsync(user.AccountId, first, Today); + await SeedEntryAsync(user.AccountId, second, Today); + await SeedEntryAsync(user.AccountId, unique, Today); + + await using var client = await ConnectAsync(host, await IssueAsync(host, user, [OAuthScopes.ReadFarm])); + + var refusal = await ErrorAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = "House 2" }); + Assert.Contains($"{first} (Active)", refusal); + Assert.Contains($"{second} (Active)", refusal); + var byId = await PageAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = second.ToString() }); + var byName = await PageAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = "House 3" }); + Assert.Equal([second], byId.Items.Select(e => e.FlockId)); + Assert.Equal([unique], byName.Items.Select(e => e.FlockId)); + Assert.Equal("House 3", Assert.Single(byName.Items).FlockName); + } + + // Row 31: each tool's first page says it is incomplete and where to continue, and the + // continuation is the rest. + [Theory] + [InlineData("cluckwork_list_flocks")] + [InlineData("cluckwork_read_stock")] + [InlineData("cluckwork_list_egg_lots")] + [InlineData("cluckwork_list_egg_lot_movements")] + [InlineData("cluckwork_list_daily_entries")] + public async Task EveryReadTool_PagesWithCompletenessAndContinuation(string tool) + { + using var host = Host(factory); + var user = await SeedAsync(factory, Roles.Manager); + var grades = await factory.SeedEggGradesAsync(user.AccountId, Guid.NewGuid(), "Large", "Medium"); + var lot = await factory.SeedEggLotAsync(user.AccountId, grades["Large"], 40); + await factory.SeedEggLotAsync(user.AccountId, grades["Medium"], 20); + await factory.WithTenantScopeAsync(user.AccountId, async db => + { + db.EggInventoryMovements.Add(EggInventoryMovement.Create( + Guid.NewGuid(), user.AccountId, lot, Domain.Modules.EggOperations.Contracts.EggMovementType.Sale, + -5, "SalesOrder", Guid.NewGuid())); + await db.SaveChangesAsync(); + }); + foreach (var name in new[] { "Paged A", "Paged B" }) + await SeedEntryAsync(user.AccountId, await SeedFlockAsync(user.AccountId, name), Today); + + await using var client = await ConnectAsync(host, await IssueAsync(host, user, [OAuthScopes.ReadFarm])); + var arguments = tool == "cluckwork_list_egg_lot_movements" ? new Dictionary { ["lotId"] = lot } : []; + + var first = await PageAsync(client, tool, new(arguments) { ["limit"] = 1 }); + Assert.Single(first.Items); + Assert.False(first.Complete); + Assert.Equal(1, first.NextOffset); + + var rest = await PageAsync(client, tool, new(arguments) { ["limit"] = 1, ["offset"] = first.NextOffset }); + Assert.Single(rest.Items); + Assert.True(rest.Complete); + Assert.Null(rest.NextOffset); + Assert.NotEqual(first.Items[0].GetRawText(), rest.Items[0].GetRawText()); + } + + // The tool's own cap holds when a caller asks for more. + [Fact] + public async Task OversizedLimit_IsCappedAndSaysSo() + { + using var host = Host(factory); + var user = await SeedAsync(factory, Roles.Manager); + await factory.WithTenantScopeAsync(user.AccountId, async db => + { + for (var i = 0; i <= McpPage.MaxLimit; i++) + db.Flocks.Add(NewFlock(user.AccountId, $"Bulk {i:D3}")); + await db.SaveChangesAsync(); + }); + + await using var client = await ConnectAsync(host, await IssueAsync(host, user, [OAuthScopes.ReadFarm])); + var page = await PageAsync(client, "cluckwork_list_flocks", new() { ["limit"] = 10_000 }); + + Assert.Equal(McpPage.MaxLimit, page.Items.Count); + Assert.False(page.Complete); + Assert.Equal(McpPage.MaxLimit, page.NextOffset); + } + + // #388 for the egg reads: lots, their movements and the stock they sum to belong to a + // flock, so a Worker sees only the assigned flock's, and an unassigned lot reads as missing. + [Fact] + public async Task AssignedWorker_ReadsOnlyTheirFlocksEggs() + { + using var host = Host(factory); + var worker = await SeedAsync(factory, role: null); + var assigned = await SeedFlockAsync(worker.AccountId, "Assigned"); + var other = await SeedFlockAsync(worker.AccountId, "Other"); + var grade = (await factory.SeedEggGradesAsync(worker.AccountId, Guid.NewGuid(), "Large"))["Large"]; + var mine = await SeedLotAsync(worker.AccountId, assigned, grade, 30); + var theirs = await SeedLotAsync(worker.AccountId, other, grade, 70); + await factory.WithTenantScopeAsync(worker.AccountId, async db => + { + db.UserRoleAssignments.Add(UserRoleAssignment.Create(Guid.NewGuid(), worker.AccountId, worker.Id, null, null, assigned)); + await db.SaveChangesAsync(); + }); + + await using var client = await ConnectAsync(host, await IssueAsync(host, worker, [OAuthScopes.ReadFarm])); + + var stock = Assert.Single((await PageAsync(client, "cluckwork_read_stock")).Items); + var lots = await PageAsync(client, "cluckwork_list_egg_lots"); + var movements = await PageAsync(client, "cluckwork_list_egg_lot_movements", new() { ["lotId"] = mine }); + Assert.Equal(30, stock.Available); + Assert.Equal([mine], lots.Items.Select(l => l.LotId)); + Assert.Equal([30], movements.Items.Select(m => m.QuantityDelta)); + var missing = Guid.NewGuid(); + var asMissing = (await ErrorAsync(client, "cluckwork_list_egg_lot_movements", new() { ["lotId"] = missing })) + .Replace(missing.ToString(), ""); + var asTheirs = (await ErrorAsync(client, "cluckwork_list_egg_lot_movements", new() { ["lotId"] = theirs })) + .Replace(theirs.ToString(), ""); + Assert.Contains("No egg lot exists on this farm.", asMissing); + Assert.Equal(asMissing, asTheirs); + } + + // Row 31 for the query, not only the reply: a page of a long ledger reads that page + // from the database, counted as rows each reader actually returned. + [Fact] + public async Task LotMovementPage_ReadsOnlyThePageFromTheDatabase() + { + var reads = new ReaderRowCounts(); + using var host = WithReaderRowCounts(Host(factory), reads); + var user = await SeedAsync(factory, Roles.Manager); + var grade = (await factory.SeedEggGradesAsync(user.AccountId, Guid.NewGuid(), "Large"))["Large"]; + var lot = await factory.SeedEggLotAsync(user.AccountId, grade, 500); + await factory.WithTenantScopeAsync(user.AccountId, async db => + { + for (var i = 0; i < 40; i++) + db.EggInventoryMovements.Add(EggInventoryMovement.Create( + Guid.NewGuid(), user.AccountId, lot, Domain.Modules.EggOperations.Contracts.EggMovementType.Sale, + -1, "SalesOrder", Guid.NewGuid())); + await db.SaveChangesAsync(); + }); + + await using var client = await ConnectAsync(host, await IssueAsync(host, user, [OAuthScopes.ReadFarm])); + reads.Clear(); + var page = await PageAsync(client, "cluckwork_list_egg_lot_movements", + new() { ["lotId"] = lot, ["limit"] = 2, ["offset"] = 10 }); + + Assert.Equal(2, page.Items.Count); + Assert.Equal(12, page.NextOffset); + // ReadCount includes the read that finds the end: the page, its lookahead row, and that. + Assert.Equal([2 + 1 + 1], reads.From("\"EggInventoryMovements\"")); + } + + // However many flocks share a name, resolving it reads and lists a bounded number. + [Fact] + public async Task SharedFlockName_ListsBoundedCandidates_AndReadsBoundedRows() + { + var reads = new ReaderRowCounts(); + using var host = WithReaderRowCounts(Host(factory), reads); + var user = await SeedAsync(factory, Roles.Manager); + var shared = $"Reused {Guid.NewGuid():N}"; + await factory.WithTenantScopeAsync(user.AccountId, async db => + { + for (var i = 0; i < 3 * Application.Modules.FlockManagement.Contracts.FlockNameResolution.MaxCandidates; i++) + db.Flocks.Add(NewFlock(user.AccountId, shared)); + await db.SaveChangesAsync(); + }); + + await using var client = await ConnectAsync(host, await IssueAsync(host, user, [OAuthScopes.ReadFarm])); + reads.Clear(); + var refusal = await ErrorAsync(client, "cluckwork_list_daily_entries", new() { ["flock"] = shared }); + + var max = Application.Modules.FlockManagement.Contracts.FlockNameResolution.MaxCandidates; + Assert.Equal(max, refusal.Split("(Active)").Length - 1); + Assert.Contains("More flocks share this name than are listed", refusal); + // The candidates, the row proving more exist, and the read that finds the end. + Assert.Equal([max + 1 + 1], reads.From("\"Flocks\"")); + } + + private static WebApplicationFactory WithReaderRowCounts(WebApplicationFactory host, ReaderRowCounts reads) => + host.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + services.AddDbContext((_, options) => options.AddInterceptors(reads)))); + + private async Task SeedLotAsync(Guid accountId, Guid flockId, Guid gradeId, int quantity) + { + var lot = EggLot.Create(Guid.NewGuid(), accountId, flockId, Today, gradeId, quantity); + await factory.WithTenantScopeAsync(accountId, async db => + { + db.EggLots.Add(lot); + db.EggInventoryMovements.Add(EggInventoryMovement.Create( + Guid.NewGuid(), accountId, lot.Id, Domain.Modules.EggOperations.Contracts.EggMovementType.Production, + quantity, "DailyEntry", Guid.NewGuid())); + await db.SaveChangesAsync(); + }); + return lot.Id; + } + + private async Task SeedFlockAsync(Guid accountId, string name) + { + var flock = NewFlock(accountId, name); + await factory.WithTenantScopeAsync(accountId, async db => + { + db.Flocks.Add(flock); + await db.SaveChangesAsync(); + }); + return flock.Id; + } + + private static Flock NewFlock(Guid accountId, string name) => + Flock.Create(Guid.NewGuid(), accountId, Guid.NewGuid(), Guid.NewGuid(), name, "Test Breed", Today.AddDays(-30), 100); + + private Task SeedEntryAsync(Guid accountId, Guid flockId, DateOnly date) => + factory.WithTenantScopeAsync(accountId, async db => + { + var flock = await db.Flocks.FindAsync(flockId); + db.DailyEntries.Add(DailyEntry.Create(Guid.NewGuid(), accountId, flock!.FarmId, flock.HouseId, flockId, date)); + await db.SaveChangesAsync(); + }); + + private static async Task> PageAsync(McpClient client, string tool, Dictionary? arguments = null) + { + var result = await client.CallToolAsync(tool, arguments); + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.True(result.IsError != true, text); + return JsonSerializer.Deserialize>(text, Json)!; + } + + private static async Task ErrorAsync(McpClient client, string tool, Dictionary arguments) + { + var result = await client.CallToolAsync(tool, arguments); + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.True(result.IsError == true, text); + return text; + } + + // McpPage as a client reads it. + private sealed record Page(List Items, bool Complete, int? NextOffset); + + // Rows each data reader returned, by the table its query selects from. + private sealed class ReaderRowCounts : DbCommandInterceptor + { + private readonly ConcurrentQueue<(string Sql, int Rows)> seen = new(); + + public void Clear() => seen.Clear(); + + public IReadOnlyList From(string table) => + [.. seen.Where(r => r.Sql.Contains($"FROM {table}", StringComparison.Ordinal)).Select(r => r.Rows)]; + + public override InterceptionResult DataReaderClosing( + DbCommand command, DataReaderClosingEventData eventData, InterceptionResult result) + { + seen.Enqueue((command.CommandText, eventData.ReadCount)); + return result; + } + + public override ValueTask DataReaderClosingAsync( + DbCommand command, DataReaderClosingEventData eventData, InterceptionResult result) + { + seen.Enqueue((command.CommandText, eventData.ReadCount)); + return ValueTask.FromResult(result); + } + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpRouteParityTests.cs b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpRouteParityTests.cs new file mode 100644 index 000000000..ca27cb3a6 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpRouteParityTests.cs @@ -0,0 +1,127 @@ +using System.Reflection; +using System.Security.Claims; +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Api.Mcp; +using Cluckwork.Domain.Modules.Farm.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cluckwork.Api.IntegrationTests.Mcp; + +// #807, guard rows 13 to 15. Every tool names the REST route it mirrors, the name resolves, +// and the tool admits no role the route denies. Both sides are evaluated through the live +// authorization service, so tightening a route without touching Mcp/ turns this red. +[Collection(IntegrationCollection.Name)] +public sealed class McpRouteParityTests(CluckworkWebApplicationFactory factory) +{ + // The effective roles AuthPolicies distinguishes. A Worker carries no role claim. + private static readonly (string Name, ClaimsPrincipal Principal)[] Callers = + [.. new[] { Roles.Owner, Roles.Manager, Roles.Sales, Roles.ReadOnly, null } + .Select(role => (role ?? "Worker", new ClaimsPrincipal(new ClaimsIdentity( + role is null ? [new Claim("sub", "parity")] : [new Claim("sub", "parity"), new Claim("role", role)], + "parity", "sub", "role"))))]; + + [Fact] + public async Task RealTree_EveryToolMirrorsARoute_AndAdmitsNoRoleItDenies() + { + var tools = McpToolSurface.ToolTypes(typeof(McpCallContext).Assembly.GetTypes()).ToList(); + + Assert.True(tools.SelectMany(McpToolSurface.ToolMethods).Count() >= 5, "the walk found fewer tools than #807 shipped"); + AssertNone(await FindingsAsync(tools)); + } + + [Theory] + [InlineData(typeof(NoMirrorTool), "declares no [MirrorsRoute]")] + [InlineData(typeof(UnknownRouteTool), "mirrors 'NoSuchRoute', which no endpoint is named")] + [InlineData(typeof(WiderThanRouteTool), "admits Worker, which RecordEggLotMovement denies")] + public async Task ToolBreakingParity_IsAFinding(Type tool, string finding) => + Assert.Contains(await FindingsAsync([tool]), f => f.Contains(finding, StringComparison.Ordinal)); + + [Fact] + public async Task ToolNarrowerThanItsRoute_IsNoFinding() => + Assert.Empty(await FindingsAsync([typeof(NarrowerThanRouteTool)])); + + private async Task> FindingsAsync(IEnumerable tools) + { + var provider = factory.Services.GetRequiredService(); + var authorization = factory.Services.GetRequiredService(); + var routes = factory.Services.GetRequiredService().Endpoints + .Where(e => e.Metadata.GetMetadata() is not null) + .ToDictionary(e => e.Metadata.GetMetadata()!.EndpointName); + + var findings = new List(); + foreach (var method in McpToolSurface.ToolTypes(tools).SelectMany(McpToolSurface.ToolMethods)) + { + var site = $"{method.DeclaringType!.FullName}.{method.Name}"; + var mirrored = method.GetCustomAttribute()?.RouteName; + if (mirrored is null) + { + findings.Add($"{site} declares no [MirrorsRoute]"); + continue; + } + if (!routes.TryGetValue(mirrored, out var route)) + { + findings.Add($"{site} mirrors '{mirrored}', which no endpoint is named"); + continue; + } + + // The tool's role gates, as the SDK reads them; its scope gate has no route twin. + var toolGates = method.GetCustomAttributes(inherit: true).Concat(method.DeclaringType.GetCustomAttributes(inherit: true)) + .OfType().Where(a => !McpToolSurface.ScopePolicies.Contains(a.Policy)); + var toolPolicy = await AuthorizationPolicy.CombineAsync(provider, toolGates); + var routePolicy = await AuthorizationPolicy.CombineAsync( + provider, route.Metadata.GetOrderedMetadata(), route.Metadata.GetOrderedMetadata()); + if (toolPolicy is null || routePolicy is null) + { + findings.Add($"{site} or its route '{mirrored}' carries no role policy"); + continue; + } + + foreach (var (name, principal) in Callers) + if ((await authorization.AuthorizeAsync(principal, toolPolicy)).Succeeded + && !(await authorization.AuthorizeAsync(principal, routePolicy)).Succeeded) + findings.Add($"{site} admits {name}, which {mirrored} denies"); + } + + return findings; + } + + private static void AssertNone(IReadOnlyList findings) => + Assert.True(findings.Count == 0, string.Join("\n", findings)); + + [AttributeUsage(AttributeTargets.Class)] + private sealed class McpServerToolTypeAttribute : Attribute; + + [AttributeUsage(AttributeTargets.Method)] + private sealed class McpServerToolAttribute : Attribute; + + [McpServerToolType] + private sealed class NoMirrorTool + { + [McpServerTool, Authorize, Authorize(Policy = AuthPolicies.FarmReadScope)] + public string Read() => ""; + } + + [McpServerToolType] + private sealed class UnknownRouteTool + { + [McpServerTool, Authorize, Authorize(Policy = AuthPolicies.FarmReadScope), MirrorsRoute("NoSuchRoute")] + public string Read() => ""; + } + + // RecordEggLotMovement is AdminOnly; the default policy also admits a Worker. + [McpServerToolType] + private sealed class WiderThanRouteTool + { + [McpServerTool, Authorize, Authorize(Policy = AuthPolicies.FarmReadScope), MirrorsRoute("RecordEggLotMovement")] + public string Write() => ""; + } + + [McpServerToolType] + private sealed class NarrowerThanRouteTool + { + [McpServerTool, Authorize(Policy = AuthPolicies.AdminOnly), Authorize(Policy = AuthPolicies.FarmReadScope), MirrorsRoute("ListFlocks")] + public string Read() => ""; + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolSurfaceTests.cs b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolSurfaceTests.cs index 835802b4e..5166ff20b 100644 --- a/tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolSurfaceTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolSurfaceTests.cs @@ -1,6 +1,10 @@ using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Cluckwork.Api.Mcp; using Cluckwork.Application.Common; +using Cluckwork.Application.Modules.EggOperations.Contracts; using Cluckwork.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -32,6 +36,109 @@ public void RealTree_NoTypeUnderTheMcpNamespace_DeclaresAScopeOpener() AssertNone(McpToolSurface.ScopeOpenerDeclarations(mcpTypes)); } + // #807, row 31: a read tool's reply says whether it is complete and where to continue. + [Fact] + public void RealTree_EveryReadToolReturnsAPage() + { + var reads = ReadTools().ToList(); + + Assert.True(reads.Count >= 5, $"the walk found {reads.Count} read tools"); + AssertNone([.. reads.Where(m => !(m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() == typeof(Task<>) + && m.ReturnType.GetGenericArguments()[0].IsGenericType + && m.ReturnType.GetGenericArguments()[0].GetGenericTypeDefinition() == typeof(McpPage<>))) + .Select(m => $"{m.DeclaringType!.FullName}.{m.Name} returns {m.ReturnType.Name}; return Task>")]); + } + + // #792: phase 1 tools return no farm-entered free text. Every text field a read tool + // returns is listed here with why it is not one, so a new note or reason field fails. + private static readonly string[] ReplyText = + [ + "DailyEntryRow.FlockName", // a flock's name: the label tools address flocks by, set by Owner or Manager + "DailyEntryRow.Status", // enum + "EggLotMovementRow.MovementType", // enum + "EggLotMovementRow.ReferenceType", // the source record's type name, set by code + "EggLotRow.Grade", // a grade's name, set by Owner or Manager + "FlockRow.Name", + "FlockRow.Status", // enum + "GradeCount.Grade", + "StockRow.Grade", + ]; + + [Fact] + public void RealTree_ReadToolRepliesReturnOnlyReviewedText() => + Assert.Equal(ReplyText.Order(), ReadTools().SelectMany(m => TextFields(m.ReturnType, m.Name, [])).Distinct().Order()); + + // The walk reaches text a serializer would emit from any namespace, inside a collection + // or array, and refuses a reply shape it cannot see into. + [Theory] + [InlineData(typeof(NestedContractRow), "DailyEntryDetails.AdjustReason")] + [InlineData(typeof(NestedContractRow), "DailyEntryDetails.VoidReason")] + [InlineData(typeof(TextCollectionRow), "TextCollectionRow.Notes")] + [InlineData(typeof(TextCollectionRow), "TextCollectionRow.Tags")] + [InlineData(typeof(OpaqueRow), "OpaqueRow.Extra is opaque")] + [InlineData(typeof(OpaqueRow), "OpaqueRow.Raw is opaque")] + public void TextWalk_ReachesNestedCollectionAndOpaqueText(Type row, string field) => + Assert.Contains(field, TextFields(typeof(Task<>).MakeGenericType(typeof(McpPage<>).MakeGenericType(row)), "Fixture", [])); + + // A conditionally ignored property still serializes when it holds a value; only an + // always-ignored one never does. The walk agrees with the serializer on both. + [Fact] + public void TextWalk_FollowsTheSerializer_OnIgnoredProperties() + { + var json = JsonSerializer.Serialize(new IgnoredRow("farm-entered note", "never sent"), new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var fields = TextFields(typeof(IgnoredRow), "Fixture", []); + + Assert.Equal("""{"reason":"farm-entered note"}""", json); + Assert.Equal(["IgnoredRow.Reason"], fields); + } + + private static IEnumerable ReadTools() => + McpToolSurface.ToolTypes(ApiTypes).SelectMany(McpToolSurface.ToolMethods) + .Where(m => m.GetCustomAttribute()!.ReadOnly); + + // Follows a reply the way System.Text.Json serializes it: Task to T, a nullable to its + // value, a collection or array to its element, any other type to its public properties. + // A text field is keyed by the property holding it. + private static IReadOnlyList TextFields(Type type, string holder, HashSet seen) + { + if (type == typeof(string) || type == typeof(char)) + return [holder]; + if (Nullable.GetUnderlyingType(type) is { } value) + return TextFields(value, holder, seen); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Task<>)) + return TextFields(type.GetGenericArguments()[0], holder, seen); + if (ElementType(type) is { } element) + return TextFields(element, holder, seen); + if (type == typeof(object) || type == typeof(JsonElement) || typeof(JsonNode).IsAssignableFrom(type) || type.IsInterface) + return [$"{holder} is opaque"]; + if (type.IsPrimitive || type.IsEnum || type == typeof(decimal) || type == typeof(Guid) || type == typeof(DateOnly) + || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeOnly) || type == typeof(TimeSpan)) + return []; + if (!seen.Add(type)) + return []; + return [.. type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetIndexParameters().Length == 0 + && p.GetCustomAttribute()?.Condition != JsonIgnoreCondition.Always) + .SelectMany(p => TextFields(p.PropertyType, $"{type.Name}.{p.Name}", seen))]; + } + + private static Type? ElementType(Type type) => + type.IsArray ? type.GetElementType() + : type == typeof(string) ? null + : new[] { type }.Concat(type.GetInterfaces()) + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + ?.GetGenericArguments()[0]; + + private sealed record NestedContractRow(Guid Id, DailyEntryDetails Entry); + + private sealed record TextCollectionRow(Guid Id, IReadOnlyList Notes, string[] Tags); + + private sealed record OpaqueRow(Guid Id, object Extra, JsonElement Raw); + + private sealed record IgnoredRow( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Reason, + [property: JsonIgnore] string Hidden); + [Fact] public void ToolsTakingTheContext_HaveNoFindings() => Assert.Empty(McpToolSurface.Findings([typeof(ConstructorTool), typeof(StaticTool)])); diff --git a/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md b/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md index 4a7ebe070..b995560ba 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md +++ b/tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md @@ -12,7 +12,7 @@ | GeneralInventory | — | R (6) | R (6) fk:3 | R (4) fk:2 | — | — | — | — | P | | Finance | — | R (4) | R (3) fk:1 | — | — | — | — | — | P | | Insights | — | R (1) | R (2) | R (2) | R (2) | R (1) | R (2) | — | P | -| Platform | A (35) | A (27) | A (23) | A (25) | A (31) | A (18) | A (9) | A (25) | — | +| Platform | A (35) | A (27) | A (26) | A (28) | A (31) | A (18) | A (9) | A (25) | — | ## Cross-owner foreign keys @@ -65,8 +65,8 @@ After-commit events and read-model queries are not syntactically observable, and | Insights | Platform | Q/P | P | | Platform/adapters | Access | W | A (35) | | Platform/adapters | Farm | W | A (27) | -| Platform/adapters | FlockManagement | W | A (23) | -| Platform/adapters | EggOperations | W | A (25) | +| Platform/adapters | FlockManagement | W | A (26) | +| Platform/adapters | EggOperations | W | A (28) | | Platform/adapters | Commerce | W | A (31) | | Platform/adapters | GeneralInventory | W | A (18) | | Platform/adapters | Finance | W | A (9) | diff --git a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Adapters.cs b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Adapters.cs index e8a686b46..4969a3586 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Adapters.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/RealModuleLedger.Adapters.cs @@ -35,6 +35,14 @@ internal static partial class RealModuleLedger "Access", "Farm", ]), + new("Cluckwork.Api.Mcp.DailyEntryTools.ctor", [ + "EggOperations", + "FlockManagement", + ]), + new("Cluckwork.Api.Mcp.FlockTools.ResolveAsync", ["FlockManagement"]), + new("Cluckwork.Api.Mcp.FlockTools.ctor", ["FlockManagement"]), + new("Cluckwork.Api.Mcp.StockTools.ResolveGrade", ["EggOperations"]), + new("Cluckwork.Api.Mcp.StockTools.ctor", ["EggOperations"]), new("Cluckwork.Api.Modules.Farm.Accounts.AccountEndpoints.GetAccount", ["Farm"]), new("Cluckwork.Api.Modules.Farm.Accounts.AccountEndpoints.GetSettings", ["Farm"]), new("Cluckwork.Api.Modules.Farm.Accounts.AccountEndpoints.SetConnectedApps", ["Farm"]), diff --git a/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs b/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs index 1cead7b97..20dcef800 100644 --- a/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs +++ b/tests/Cluckwork.Application.Tests/Eggs/EggOperationsModuleTests.cs @@ -298,6 +298,9 @@ public Task> ListByLotAsync(Guid eggLotId, C return Task.FromResult>(Rows.Where(m => m.EggLotId == eggLotId).ToList()); } + public Task> ListByLotAsync( + Guid eggLotId, int limit, int offset, CancellationToken ct = default) => throw new NotSupportedException(); + public Task AddAsync(EggInventoryMovement movement, CancellationToken ct = default) => throw new NotSupportedException(); public Task AddRangeAsync(IEnumerable movements, CancellationToken ct = default) => throw new NotSupportedException(); diff --git a/tests/Cluckwork.Application.Tests/Flocks/FlockLookupTests.cs b/tests/Cluckwork.Application.Tests/Flocks/FlockLookupTests.cs index 3f659058f..1d3d82081 100644 --- a/tests/Cluckwork.Application.Tests/Flocks/FlockLookupTests.cs +++ b/tests/Cluckwork.Application.Tests/Flocks/FlockLookupTests.cs @@ -75,7 +75,24 @@ public async Task ResolveByName_TwoMatchesAreAmbiguousWithBothCandidates() new(Guid.Parse("00000000-0000-0000-0000-0000000000f2"), "House 1", FlockStatus.Depleted), ]; var resolution = await new FlockLookup(new StubFlocks(null, both)).ResolveByNameAsync("House 1", default); - Assert.Equal(both, Assert.IsType(resolution).Candidates); + var ambiguous = Assert.IsType(resolution); + Assert.Equal(both, ambiguous.Candidates); + Assert.False(ambiguous.MoreExist); + } + + // #807: however many flocks share a name, the refusal carries a bounded list and says so. + [Theory] + [InlineData(FlockNameResolution.MaxCandidates, false)] + [InlineData(FlockNameResolution.MaxCandidates + 1, true)] + [InlineData(FlockNameResolution.MaxCandidates * 5, true)] + public async Task ResolveByName_CapsTheCandidates(int matches, bool moreExist) + { + var all = Enumerable.Range(1, matches) + .Select(i => new FlockReference(Guid.Parse($"00000000-0000-0000-0000-{i:D12}"), "House 1", FlockStatus.Archived)).ToList(); + var resolution = await new FlockLookup(new StubFlocks(null, all)).ResolveByNameAsync("House 1", default); + var ambiguous = Assert.IsType(resolution); + Assert.Equal(all.Take(FlockNameResolution.MaxCandidates), ambiguous.Candidates); + Assert.Equal(moreExist, ambiguous.MoreExist); } private sealed class StubFlocks(Flock? flock, IReadOnlyList? byName = null) : IFlockRepository @@ -85,8 +102,8 @@ private sealed class StubFlocks(Flock? flock, IReadOnlyList? byN public Task GetReadOnlyForFlockScopedWriteAsync(Guid id, Guid accountId, CancellationToken ct = default) => Task.FromResult(flock); - public Task> ListByNameAsync(string name, CancellationToken ct = default) => - Task.FromResult(byName ?? throw new NotSupportedException()); + public Task> ListByNameAsync(string name, int limit, CancellationToken ct = default) => + Task.FromResult>([.. (byName ?? throw new NotSupportedException()).Take(limit)]); public Task GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); public Task GetByIdForFlockScopedWriteAsync(Guid id, Guid accountId, CancellationToken ct = default) => diff --git a/tools/oauth/mutation-check.sh b/tools/oauth/mutation-check.sh index 64d8949c3..1a71ee5c3 100755 --- a/tools/oauth/mutation-check.sh +++ b/tools/oauth/mutation-check.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# tools/oauth/mutation-check.sh — proves each #795 to #799, #806 and #1148 OAuth claim has a +# tools/oauth/mutation-check.sh — proves each #795 to #799, #806, #807 and #1148 OAuth and MCP claim has a # test that fails when the claim breaks. # # Baseline green, then per mutant: apply one exact-string edit (it must match once), @@ -53,6 +53,17 @@ MCP=src/Cluckwork.Api/Hosting/CluckworkMcp.cs FORBIDDEN=src/Cluckwork.Api/Middleware/ForbiddenProblemResultHandler.cs POLICIES=src/Cluckwork.Api/AuthPolicies.cs TOOL_SURFACE=tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolSurface.cs +FLOCK_TOOLS=src/Cluckwork.Api/Mcp/FlockTools.cs +STOCK_TOOLS=src/Cluckwork.Api/Mcp/StockTools.cs +PAGE=src/Cluckwork.Api/Mcp/McpPage.cs +FLOCK_ENDPOINTS=src/Cluckwork.Api/Modules/FlockManagement/Flocks/FlockEndpoints.cs +STOCK_ENDPOINTS=src/Cluckwork.Api/Modules/EggOperations/Stock/StockEndpoints.cs +FLOCK_SCOPE=src/Cluckwork.Infrastructure/Persistence/FlockScope.cs +MOVEMENT_REPO=src/Cluckwork.Infrastructure/Modules/EggOperations/Repositories/EggInventoryMovementRepository.cs +FLOCK_REPO=src/Cluckwork.Infrastructure/Modules/FlockManagement/Repositories/FlockRepository.cs +FLOCK_LOOKUP=src/Cluckwork.Application/Modules/FlockManagement/Flocks/FlockLookup.cs +DB_CONTEXT=src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs +TEXT_WALK=tests/Cluckwork.Api.IntegrationTests/Mcp/McpToolSurfaceTests.cs TESTS=tests/Cluckwork.Api.IntegrationTests TEST_NS=Cluckwork.Api.IntegrationTests SUITE='FullyQualifiedName~OAuth|FullyQualifiedName~ClientMetadata|FullyQualifiedName~Cluckwork.Api.IntegrationTests.Mcp.' @@ -251,6 +262,26 @@ challenge-without-metadata#kill#IDENTITY#? McpAuthenticationDefaults.Authenticat metadata-from-request-host#kill#MCP#\n options.ResourceMetadataUri = new Uri(resource, "/.well-known/oauth-protected-resource" + resource.AbsolutePath);##Mcp.McpAuthorizationTests.Challenge_NamesTheIssuer_NotTheRequestHost#Sub-string not found insufficient-scope-unannounced#kill#FORBIDDEN#var onlyScopesFailed = failed.Count > 0 && failed.All(r => r is OAuthScopeRequirement);#var onlyScopesFailed = false;#Mcp.McpAuthorizationTests.TokenWithoutAnMcpScope_Gets403InsufficientScope#Strings differ rpc-429-as-problem#kill#LIMITS#if (policyName == RateLimitingOptions.OAuthApiPolicyName)\n {\n context.HttpContext.Response.StatusCode#if (policyName == "none")\n {\n context.HttpContext.Response.StatusCode#Mcp.McpAuthorizationTests.OverTheBudget_Gets429_AsAJsonRpcError#not a JSON-RPC error +route-tightened#kill#FLOCK_ENDPOINTS#.WithName("ListFlocks")#.WithName("ListFlocks").RequireAuthorization(AuthPolicies.AdminOnly)#Mcp.McpRouteParityTests.RealTree_EveryToolMirrorsARoute_AndAdmitsNoRoleItDenies#which ListFlocks denies +route-renamed#kill#STOCK_ENDPOINTS#.WithName("ListEggLots")#.WithName("ListEggLotsRenamed")#Mcp.McpRouteParityTests.RealTree_EveryToolMirrorsARoute_AndAdmitsNoRoleItDenies#mirrors 'ListEggLots', which no endpoint is named +tool-mirrors-nothing#kill#STOCK_TOOLS#[MirrorsRoute("GetStock")]##Mcp.McpRouteParityTests.RealTree_EveryToolMirrorsARoute_AndAdmitsNoRoleItDenies#declares no [MirrorsRoute] +read-tool-unpaged#kill#STOCK_TOOLS#public async Task> ReadStockAsync#public async Task ReadStockAsync#Mcp.McpToolSurfaceTests.RealTree_EveryReadToolReturnsAPage#return Task> +page-without-lookahead#kill#PAGE#var rows = await fetch(take + 1, skip, ct);#var rows = await fetch(take, skip, ct);#Mcp.McpPageTests.AsksForOneRowPastTheClampedLimit#Collections differ +page-lookahead-ignored#kill#PAGE#if (rows.Count <= take)#if (rows.Count <= take + 1)#Mcp.McpPageTests.MoreThanTheLimit_KeepsThePage_AndContinuesAfterIt#Collections differ +page-offset-clamped-back#kill#PAGE#var skip = Math.Max(offset, 0);#var skip = Math.Clamp(offset, 0, int.MaxValue - McpPage.MaxLimit);#Mcp.McpPageTests.NearTheIntegerLimit_ContinuesForward_ThenRefuses#No exception was thrown +page-next-overflows#kill#PAGE#if (next > int.MaxValue)#if (next > int.MaxValue + 1000L)#Mcp.McpPageTests.NearTheIntegerLimit_ContinuesForward_ThenRefuses#No exception was thrown +page-uncapped#kill#PAGE#Math.Clamp(limit, 1, McpPage.MaxLimit)#Math.Max(limit, 1)#Mcp.McpPageTests.AsksForOneRowPastTheClampedLimit#Collections differ +flock-id-unchecked#kill#FLOCK_TOOLS#return (await lookup.GetDisplayNamesAsync([id], ct)).GetValueOrDefault(id) ?? throw NotFound(flock);#return new FlockReference(id, flock, Cluckwork.Domain.Modules.FlockManagement.Contracts.FlockStatus.Active);#Mcp.McpReadToolsTests.CrossFarmBearer_ReadsNothingOfTheOtherFarm#"items":[] +flock-scope-ignored#kill#FLOCK_SCOPE#IsUnrestricted = unrestricted;#IsUnrestricted = true;#Mcp.McpReadToolsTests.AssignedWorker_ReadsOnlyTheirFlocks#Collections differ +shared-name-first-match#kill#FLOCK_TOOLS#FlockNameResolution.Ambiguous ambiguous => throw new McpException(#FlockNameResolution.Ambiguous ambiguous when ambiguous.Candidates.Count > 0 => ambiguous.Candidates[0], FlockNameResolution.Ambiguous ambiguous => throw new McpException(#Mcp.McpReadToolsTests.SharedFlockName_IsRefusedWithTheCandidates_AndAnIdResolvesIt#"items":[ +reply-free-text#kill#STOCK_TOOLS#string ReferenceType, Guid ReferenceId, DateTimeOffset RecordedAtUtc);#string ReferenceType, Guid ReferenceId, DateTimeOffset RecordedAtUtc, string? Reason = null);#Mcp.McpToolSurfaceTests.RealTree_ReadToolRepliesReturnOnlyReviewedText#Collections differ +movements-paged-in-memory#kill#MOVEMENT_REPO#await ByLot(eggLotId).Skip(offset).Take(limit).ToListAsync(ct);#(await ByLot(eggLotId).ToListAsync(ct)).Skip(offset).Take(limit).ToList();#Mcp.McpReadToolsTests.LotMovementPage_ReadsOnlyThePageFromTheDatabase#Collections differ +name-resolution-unbounded#kill#FLOCK_REPO#.OrderBy(f => f.Id)\n .Take(limit)#.OrderBy(f => f.Id)#Mcp.McpReadToolsTests.SharedFlockName_ListsBoundedCandidates_AndReadsBoundedRows#Collections differ +name-candidates-uncapped#kill#FLOCK_LOOKUP#[.. many.Take(FlockNameResolution.MaxCandidates)]#many#Mcp.McpReadToolsTests.SharedFlockName_ListsBoundedCandidates_AndReadsBoundedRows#Values differ +text-walk-namespace-bound#kill#TEXT_WALK# if (!seen.Add(type))# if (type.Namespace?.StartsWith("Cluckwork.Api") != true || !seen.Add(type))#Mcp.McpToolSurfaceTests.TextWalk_ReachesNestedCollectionAndOpaqueText#Assert.Contains() Failure +text-walk-skips-elements#kill#TEXT_WALK#if (ElementType(type) is { } element)#if (ElementType(type) is { } element && type.IsArray)#Mcp.McpToolSurfaceTests.TextWalk_ReachesNestedCollectionAndOpaqueText#Assert.Contains() Failure +text-walk-ignores-conditional#kill#TEXT_WALK#&& p.GetCustomAttribute()?.Condition != JsonIgnoreCondition.Always)#&& p.GetCustomAttribute() is null)#Mcp.McpToolSurfaceTests.TextWalk_FollowsTheSerializer_OnIgnoredProperties#Collections differ +egg-lot-scope-ignored#kill#DB_CONTEXT#builder.Entity().HasQueryFilter(e =>\n e.AccountId == tenant.AccountId\n && (flockScope.IsUnrestricted || flockScope.AssignedFlockIds.Contains(e.FlockId)));#builder.Entity().HasQueryFilter(e =>\n e.AccountId == tenant.AccountId);#Mcp.McpReadToolsTests.AssignedWorker_ReadsOnlyTheirFlocksEggs#Values differ EOF ) @@ -345,7 +376,7 @@ sys.exit(1 if problems else 0) PY } -FILES=("$MCP" "$FORBIDDEN" "$POLICIES" "$TOOL_SURFACE" "$IDENTITY" "$TELEMETRY" "$SERVER" "$ENDPOINT" "$REGISTRATION" "$FETCHER" "$DOCUMENTS" "$IDEMPOTENCY" "$LIMITS" "$PURGE" "$SWEEP" "$WORKER" "$STAMP_MIGRATION" "$STAMP_CONFIG" "$VERIFIER" "$EPOCH" "$MUST_CHANGE" "$CONNECTED" "$LAST_USED" "$ME" "$PROGRAM" "$ACCOUNT" "$ACCOUNT_ENDPOINTS" "$SWITCH_HANDLER" "$SWITCH_MIGRATION") +FILES=("$MCP" "$FORBIDDEN" "$POLICIES" "$TOOL_SURFACE" "$FLOCK_TOOLS" "$STOCK_TOOLS" "$PAGE" "$FLOCK_ENDPOINTS" "$STOCK_ENDPOINTS" "$FLOCK_SCOPE" "$MOVEMENT_REPO" "$FLOCK_REPO" "$FLOCK_LOOKUP" "$DB_CONTEXT" "$TEXT_WALK" "$IDENTITY" "$TELEMETRY" "$SERVER" "$ENDPOINT" "$REGISTRATION" "$FETCHER" "$DOCUMENTS" "$IDEMPOTENCY" "$LIMITS" "$PURGE" "$SWEEP" "$WORKER" "$STAMP_MIGRATION" "$STAMP_CONFIG" "$VERIFIER" "$EPOCH" "$MUST_CHANGE" "$CONNECTED" "$LAST_USED" "$ME" "$PROGRAM" "$ACCOUNT" "$ACCOUNT_ENDPOINTS" "$SWITCH_HANDLER" "$SWITCH_MIGRATION") restore() { git checkout -- "${FILES[@]}"; } if ! git diff --quiet -- "${FILES[@]}"; then