diff --git a/.github/prompts/get-aldi-breakers.prompt.md b/.github/prompts/get-aldi-breakers.prompt.md new file mode 100644 index 0000000..1852500 --- /dev/null +++ b/.github/prompts/get-aldi-breakers.prompt.md @@ -0,0 +1,19 @@ +--- +agent: 'agent' +model: Grok Code Fast 1 (copilot) +tools: ['foodsharing-mcp/*'] +description: 'Get duplicate ALDI memberships in the siegen region' +--- + +Region ID: 139 (Siegen) +Search for all stores in that region that name contains "ALDI" +For all found stores, use the "storeId" to get the store members that are assigned to that store and have a membership status of "active" + +Collect all the members in one big list, group them by name. +The goal is to see which members is in how many of those stores. +It is only of interest if a member is in more than two stores, so only show those members that are in three or more stores. + +return a list of members with the following information: +- Name +- Number of stores they are assigned to +- List of store names they are assigned to \ No newline at end of file diff --git a/FsMcp/Endpoints.cs b/FsMcp/Endpoints.cs index 223610d..c770104 100644 --- a/FsMcp/Endpoints.cs +++ b/FsMcp/Endpoints.cs @@ -6,6 +6,8 @@ public static class Endpoints public static string UserCurrentDetails => $"{ApiBase}/api/users/current/details"; public static string RegionStores(int regionId) => $"{ApiBase}/api/regions/{regionId}/stores"; public static string StoreMembers(int storeId) => $"{ApiBase}/api/stores/{storeId}/members"; + public static string StoreLogActions(int storeId, string fromDate, string toDate, string storeLogActionIds) => + $"{ApiBase}/api/stores/{storeId}/log/{fromDate}/{toDate}/actions/{storeLogActionIds}"; private static string ApiBase => "https://beta.foodsharing.de"; } \ No newline at end of file diff --git a/FsMcp/Program.cs b/FsMcp/Program.cs index 9f25bbc..141dd48 100644 --- a/FsMcp/Program.cs +++ b/FsMcp/Program.cs @@ -15,6 +15,7 @@ builder.Services .WithStdioServerTransport() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); await builder.Build().RunAsync(); diff --git a/FsMcp/README.md b/FsMcp/README.md index cebd8d8..28a33ee 100644 --- a/FsMcp/README.md +++ b/FsMcp/README.md @@ -122,6 +122,53 @@ Notes for consumers: The tool is strongly typed in `Tools/StoreMembersTools.cs`, with per-field `Description` attributes so MCP clients can surface schema-aware guidance directly in tool UIs. +### Store log entries tool + +This server also exposes `get_store_log_entries`. + +- Purpose: Returns store log entries from `GET /api/stores/{storeId}/log/{fromDate}/{toDate}/actions/{storeLogActionIds}` for auditing team and slot activity. +- Input: + - `storeId` (required, positive integer; OpenAPI path pattern: `[1-9][0-9]*`) + - `fromDate` (required, `DateTimeOffset`; serialized to UTC path format `yyyy-MM-ddTHH:mm:ss.fffZ`) + - `toDate` (required, `DateTimeOffset`; serialized to UTC path format `yyyy-MM-ddTHH:mm:ss.fffZ`) + - `storeLogActionIds` (required, non-empty list of positive integers; serialized as comma-separated path segment matching `\d+(,\d+)*`) + - `limit` (optional, query parameter, must be `> 0`) + - `offset` (optional, query parameter, must be `>= 0`) +- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header. +- Output: Array of typed store log entries including: + - timing fields (`performedAt`, `dateReference`) + - type field (`actionType`) + - actor/target profiles (`actor`, `target` with `id`, `name`, `avatar`, `isSleeping`) + - optional text fields (`content`, `reason`) + +Notes for consumers: + +- The backend may reject requests beyond the allowed history window (OpenAPI documents `400` for more than 6 months back). +- `fromDate` must be less than or equal to `toDate`. +- `actionType` values currently observed/mapped: + - `1` membership requests made / `Beitrittsanfragen stellen` + - `2` membership requests rejected / `Beitrittsanfragen ablehnen` + - `3` membership requests accepted / `Beitrittsanfragen annehmen` + - `4` members added manually / `Foodsaver:innen ohne Anfrage ins Team aufnehmen` + - `5` team members made standby / `Foodsaver:innen auf die Springerliste setzen` + - `6` team members moved to active team / `Foodsaver:innen ins aktive Team aufnehmen` + - `7` team members removed from team / `Foodsaver:innen aus dem Team entfernen` + - `8` team members left team / `Team verlassen` + - `9` store coordinators appointed / `Betriebsverantwortliche eintragen` + - `10` store coordinators removed / `Betriebsverantwortliche austragen` + - `11` signed up for a slot / `Eintragen für Slot` + - `12` signed out from a slot / `Austragen für Slot` + - `13` removed from a slot / `Für Slot ausgetragen werden` + - `14` confirmed a slot / `Slot bestätigen` + - `15` wall posts deleted / `Pinnwandeintrag löschen` + - `16` team applications withdrawn / `Beitrittsanfrage zurückziehen` + - `17` invitations to the team / `Foodsaver:innen ins Team einladen` + - `18` withdrawn team invitations / `Team-Einladungen zurückziehen` + - `19` accepted team invitations / `Team-Einladungen annehmen` + - `20` declined team invitations / `Team-Einladungen ablehnen` + +The tool is strongly typed in `Tools/StoreLogTools.cs`, with per-field `Description` attributes so MCP clients can surface schema-aware guidance directly in tool UIs. + ## Publishing to NuGet.org 1. Run `dotnet pack -c Release` to create the NuGet package diff --git a/FsMcp/Tools/StoreLogTools.cs b/FsMcp/Tools/StoreLogTools.cs new file mode 100644 index 0000000..996b0a9 --- /dev/null +++ b/FsMcp/Tools/StoreLogTools.cs @@ -0,0 +1,143 @@ +using System.ComponentModel; +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using FsMcp; +using ModelContextProtocol.Server; + +internal sealed class StoreLogTools +{ + private readonly FoodsharingApiClient _apiClient; + + public StoreLogTools(FoodsharingApiClient apiClient) + { + _apiClient = apiClient; + } + + [McpServerTool] + [Description("Returns store log entries from GET /api/stores/{storeId}/log/{fromDate}/{toDate}/actions/{storeLogActionIds}. Useful for auditing team membership changes, slot signups/signouts, and invitation activity over a time range with selected action types.")] + public async Task> GetStoreLogEntriesAsync( + [Description("Store ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")] + int storeId, + [Description("Inclusive start timestamp in UTC. Will be serialized to API path format yyyy-MM-ddTHH:mm:ss.fffZ.")] + DateTimeOffset fromDate, + [Description("Inclusive end timestamp in UTC. Will be serialized to API path format yyyy-MM-ddTHH:mm:ss.fffZ.")] + DateTimeOffset toDate, + [Description("Store log action type IDs to include. Values are joined as comma-separated path segment (OpenAPI pattern: \\d+(,\\d+)*).")] + IReadOnlyList storeLogActionIds, + [Description("Optional page size limit (query parameter).")] + int? limit = null, + [Description("Optional pagination offset (query parameter).")] + int? offset = null) + { + if (storeId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(storeId), "storeId must be a positive integer."); + } + + if (storeLogActionIds.Count == 0) + { + throw new ArgumentException("At least one action type ID is required.", nameof(storeLogActionIds)); + } + + if (storeLogActionIds.Any(id => id <= 0)) + { + throw new ArgumentOutOfRangeException(nameof(storeLogActionIds), "All action type IDs must be positive integers."); + } + + if (fromDate > toDate) + { + throw new ArgumentException("fromDate must be less than or equal to toDate."); + } + + if (limit is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(limit), "limit must be greater than 0 when provided."); + } + + if (offset is < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset), "offset must be greater than or equal to 0 when provided."); + } + + await _apiClient.EnsureLoginAsync(); + + string fromDatePath = FormatDatePath(fromDate); + string toDatePath = FormatDatePath(toDate); + string actionTypesPath = string.Join(',', storeLogActionIds); + + string endpoint = Endpoints.StoreLogActions(storeId, fromDatePath, toDatePath, actionTypesPath); + List queryParts = []; + + if (limit is not null) + { + queryParts.Add($"limit={limit.Value}"); + } + + if (offset is not null) + { + queryParts.Add($"offset={offset.Value}"); + } + + if (queryParts.Count > 0) + { + endpoint = $"{endpoint}?{string.Join("&", queryParts)}"; + } + + var entries = await _apiClient.HttpClient.GetFromJsonAsync>(endpoint); + return entries ?? []; + } + + private static string FormatDatePath(DateTimeOffset value) => + value.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture); +} + +public sealed record StoreLogEntryItem +{ + [Description("Timestamp when the action was performed (ISO-8601).")] + [JsonPropertyName("performedAt")] + public DateTimeOffset? PerformedAt { get; init; } + + [Description("Numeric store log action type ID. Known mappings: 1 membership requests made / Beitrittsanfragen stellen; 2 membership requests rejected / Beitrittsanfragen ablehnen; 3 membership requests accepted / Beitrittsanfragen annehmen; 4 members added manually / Foodsaver:innen ohne Anfrage ins Team aufnehmen; 5 team members made standby / Foodsaver:innen auf die Springerliste setzen; 6 team members moved to active team / Foodsaver:innen ins aktive Team aufnehmen; 7 team members removed from team / Foodsaver:innen aus dem Team entfernen; 8 team members left team / Team verlassen; 9 store coordinators appointed / Betriebsverantwortliche eintragen; 10 store coordinators removed / Betriebsverantwortliche austragen; 11 signed up for a slot / Eintragen für Slot; 12 signed out from a slot / Austragen für Slot; 13 removed from a slot / Für Slot ausgetragen werden; 14 confirmed a slot / Slot bestätigen; 15 wall posts deleted / Pinnwandeintrag löschen; 16 team applications withdrawn / Beitrittsanfrage zurückziehen; 17 invitations to the team / Foodsaver:innen ins Team einladen; 18 withdrawn team invitations / Team-Einladungen zurückziehen; 19 accepted team invitations / Team-Einladungen annehmen; 20 declined team invitations / Team-Einladungen ablehnen.")] + [JsonPropertyName("actionType")] + public int? ActionType { get; init; } + + [Description("Profile of the user who performed the action.")] + [JsonPropertyName("actor")] + public StoreLogProfile? Actor { get; init; } + + [Description("Profile of the action target user, if applicable.")] + [JsonPropertyName("target")] + public StoreLogProfile? Target { get; init; } + + [Description("Additional timestamp reference associated with the log entry (for example affected slot time).")] + [JsonPropertyName("dateReference")] + public DateTimeOffset? DateReference { get; init; } + + [Description("Text content for the log entry, if provided by the API.")] + [JsonPropertyName("content")] + public string? Content { get; init; } + + [Description("Optional reason text attached to the action.")] + [JsonPropertyName("reason")] + public string? Reason { get; init; } +} + +public sealed record StoreLogProfile +{ + [Description("Unique user ID.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("Display name of the user.")] + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [Description("Relative API path to avatar image.")] + [JsonPropertyName("avatar")] + public string Avatar { get; init; } = string.Empty; + + [Description("Whether the user currently has sleeping-hat mode enabled.")] + [JsonPropertyName("isSleeping")] + public bool? IsSleeping { get; init; } +} \ No newline at end of file