diff --git a/.github/plans/plan-managed-stores-pickups.prompt.md b/.github/plans/plan-managed-stores-pickups.prompt.md new file mode 100644 index 0000000..b8b83cc --- /dev/null +++ b/.github/plans/plan-managed-stores-pickups.prompt.md @@ -0,0 +1,28 @@ +## Plan: Implement Managed Stores Pickups MCP Tools + +To minimize API calls, we will implement the two necessary endpoints to first get the user's managed stores, and then fetch the upcoming pickups for each managed store. + +**Steps** +1. **Analyze API Models**: Extract the response schemas for both endpoints from `fsopenapi.json` to create fully typed C# `record` responses (e.g., `StoreTeamMembershipWithPickupStatus` and the `occupiedSlots` structure). +2. **Update Endpoints list**: Add `GetStoresOfUser` and `ListPickupsForStore` relative URLs to `FsMcp/Endpoints.cs`. +3. **Implement User Stores Tool** (*parallel with step 4*): Create `FsMcp/Tools/UserStoresTools.cs` exposing a tool to call `GET /api/users/{userId}/stores`. Ensure `EnsureLoginAsync()` is called before making requests, and fully apply `[Description]` attributes to fields. +4. **Implement Store Pickups Tool** (*parallel with step 3*): Create `FsMcp/Tools/StorePickupsTools.cs` exposing a tool to call `GET /api/stores/{storeId}/pickups`. Include the `occupiedSlots` with their `isConfirmed` property. +5. **Register MCP Tools**: Add `.WithTools()` and `.WithTools()` inside `FsMcp/Program.cs`. +6. **Update Documents**: Add usage instructions, auth behaviors, and endpoint mappings to `FsMcp/README.md`. + +**Relevant files** +- `fsopenapi.json` — Source of truth for OpenAPI schema properties +- `FsMcp/Endpoints.cs` — Add static endpoint URLs +- `FsMcp/Tools/UserStoresTools.cs` — Implements `GET /api/users/{userId}/stores` +- `FsMcp/Tools/StorePickupsTools.cs` — Implements `GET /api/stores/{storeId}/pickups` +- `FsMcp/Program.cs` — Tool registration +- `FsMcp/README.md` — Documentation + +**Verification** +1. Execute `dotnet build FsMcp/FsMcp.csproj -c Debug` to verify compilation. +2. Launch the MCP server using the provided workspace task: `Run FsMcp Debug`. +3. Once running, interactively test the tools by asking: "List stores I manage and show their open unconfirmed pickups" via this chat, ensuring both tools are successfully invoked by the MCP client. + +**Decisions** +- `userId` in `GET /api/users/{userId}/stores` can receive `"current"` to simplify queries. +- We implement two distinct tool classes rather than merging them to adhere to the existing `Tools/*Tools.cs` domain-driven naming pattern in the project. diff --git a/.github/prompts/list-open-slot-requests.prompt.md b/.github/prompts/list-open-slot-requests.prompt.md new file mode 100644 index 0000000..168c055 --- /dev/null +++ b/.github/prompts/list-open-slot-requests.prompt.md @@ -0,0 +1,12 @@ +--- +agent: 'agent' +model: Gemini 3 Flash (Preview) (copilot) +tools: [foodsharing-mcp/get_user_stores, foodsharing-mcp/get_store_pickups] +description: 'List open slot requests for my managed foodsharing stores' +--- + +Your goal is to look at all my stores that i manage (isManaging = true), retrieve the pickups and find where the slot's isConfirmed property is false + +tools to use: +'Foodsharing Mcp/get_user_stores' +'Foodsharing Mcp/get_store_pickups' \ No newline at end of file diff --git a/FsMcp/Tools/StorePickupsTools.cs b/FsMcp/Tools/StorePickupsTools.cs new file mode 100644 index 0000000..5904639 --- /dev/null +++ b/FsMcp/Tools/StorePickupsTools.cs @@ -0,0 +1,104 @@ +using System.ComponentModel; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using FsMcp; +using ModelContextProtocol.Server; + +namespace FsMcp.Tools; + +internal sealed class StorePickupsTools +{ + private readonly FoodsharingApiClient _apiClient; + + public StorePickupsTools(FoodsharingApiClient apiClient) + { + _apiClient = apiClient; + } + + [McpServerTool] + [Description("List upcoming pickups for a store from GET /api/stores/{storeId}/pickups.")] + public async Task> GetStorePickupsAsync( + [Description("Store ID as positive integer.")] + int storeId) + { + if (storeId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(storeId), "storeId must be a positive integer."); + } + + await _apiClient.EnsureLoginAsync(); + + var pickups = await _apiClient.HttpClient.GetFromJsonAsync>(Endpoints.StorePickups(storeId)); + return pickups ?? []; + } +} + +public sealed record StorePickup +{ + [Description("Date and time of the pickup.")] + [JsonPropertyName("date")] + public DateTime Date { get; init; } + + [Description("Total number of available slots for this pickup.")] + [JsonPropertyName("totalSlots")] + public int TotalSlots { get; init; } + + [Description("List of slots already occupied by users.")] + [JsonPropertyName("occupiedSlots")] + [JsonConverter(typeof(ObjectOrArrayConverter))] + public IReadOnlyList OccupiedSlots { get; init; } = []; + + [Description("Whether slots are still available for this pickup.")] + [JsonPropertyName("isAvailable")] + [JsonConverter(typeof(NumberOrBoolConverter))] + public bool IsAvailable { get; init; } + + [Description("Description or instructions for the pickup.")] + [JsonPropertyName("description")] + public string Description { get; init; } = string.Empty; +} + +public sealed record OccupiedSlot +{ + [Description("Whether the user's participation is confirmed.")] + [JsonPropertyName("isConfirmed")] + [JsonConverter(typeof(NumberOrBoolConverter))] + public bool IsConfirmed { get; init; } + + [Description("Profile of the user who occupied the slot.")] + [JsonPropertyName("profile")] + public PickupUserProfile Profile { get; init; } = new(); +} + +public sealed record PickupUserProfile +{ + [Description("User identifier.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("User display name.")] + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [Description("URL to the user's avatar image.")] + [JsonPropertyName("avatar")] + public string? Avatar { get; init; } + + [Description("Whether the user is currently in sleep mode.")] + [JsonPropertyName("isSleeping")] + [JsonConverter(typeof(NumberOrBoolConverter))] + public bool IsSleeping { get; init; } + + [Description("Mobile phone number.")] + [JsonPropertyName("mobile")] + public string? Mobile { get; init; } + + [Description("Landline phone number.")] + [JsonPropertyName("landline")] + public string? Landline { get; init; } + + [Description("Whether the user is a store manager.")] + [JsonPropertyName("isManager")] + [JsonConverter(typeof(NumberOrBoolConverter))] + public bool IsManager { get; init; } +} diff --git a/FsMcp/Tools/UserStoresTools.cs b/FsMcp/Tools/UserStoresTools.cs new file mode 100644 index 0000000..ee402fc --- /dev/null +++ b/FsMcp/Tools/UserStoresTools.cs @@ -0,0 +1,58 @@ +using System.ComponentModel; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using FsMcp; +using ModelContextProtocol.Server; + +namespace FsMcp.Tools; + +internal sealed class UserStoresTools +{ + private readonly FoodsharingApiClient _apiClient; + + public UserStoresTools(FoodsharingApiClient apiClient) + { + _apiClient = apiClient; + } + + [McpServerTool] + [Description("Returns the stores where a user is a member of from GET /api/users/{userId}/stores. Typically used with userId='current' to get the current user's stores.")] + public async Task> GetUserStoresAsync( + [Description("User ID as positive integer or 'current'. Defaults to 'current'.")] + string userId = "current", + [Description("Whether to exclude inactive stores.")] + bool? excludeInactive = null) + { + await _apiClient.EnsureLoginAsync(); + + var stores = await _apiClient.HttpClient.GetFromJsonAsync>(Endpoints.UserStores(userId, excludeInactive)); + return stores ?? []; + } +} + +public sealed record StoreTeamMembershipWithPickupStatus +{ + [Description("Unique identifier of the store.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("Name of the store.")] + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [Description("Whether the user is a manager of this store.")] + [JsonPropertyName("isManaging")] + public bool IsManaging { get; init; } + + [Description("Membership status of the user in the store team.")] + [JsonPropertyName("membershipStatus")] + public int MembershipStatus { get; init; } + + [Description("Category type of the store.")] + [JsonPropertyName("categoryType")] + public int CategoryType { get; init; } + + [Description("Pickup status for the user in this store. Can be null.")] + [JsonPropertyName("pickupStatus")] + public int? PickupStatus { get; init; } +}