Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/decisions/806-mcp-endpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 26 additions & 2 deletions docs/plans/770-mcp-server/02-guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<WithName>")]`, 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.

Expand Down Expand Up @@ -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<T>.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<T>` 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`
Expand Down
61 changes: 61 additions & 0 deletions src/Cluckwork.Api/Mcp/DailyEntryTools.cs
Original file line number Diff line number Diff line change
@@ -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<McpPage<DailyEntryRow>> 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<DailyEntryRow>.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<GradeCount> Grades);

public sealed record GradeCount(string? Grade, int Quantity);
88 changes: 88 additions & 0 deletions src/Cluckwork.Api/Mcp/FlockTools.cs
Original file line number Diff line number Diff line change
@@ -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<McpPage<FlockRow>> 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<FlockRow>.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<FlockReference> 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);
43 changes: 43 additions & 0 deletions src/Cluckwork.Api/Mcp/McpPage.cs
Original file line number Diff line number Diff line change
@@ -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<T>
{
public IReadOnlyList<T> Items { get; }
public bool Complete { get; }
public int? NextOffset { get; }

private McpPage(IReadOnlyList<T> 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<McpPage<T>> ReadAsync<TRow>(
int limit, int offset, Func<int, int, CancellationToken, Task<IReadOnlyList<TRow>>> fetch,
Func<TRow, T> 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;
}
10 changes: 10 additions & 0 deletions src/Cluckwork.Api/Mcp/MirrorsRouteAttribute.cs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading