Add UserStores and StorePickups tools; implement endpoints and update documentation

This commit is contained in:
2026-05-16 17:09:32 +02:00
parent 2418966b82
commit 2aae8a4e58
4 changed files with 202 additions and 0 deletions

View File

@@ -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<UserStoresTools>()` and `.WithTools<StorePickupsTools>()` 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.

View File

@@ -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'

View File

@@ -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<IReadOnlyList<StorePickup>> 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<IReadOnlyList<StorePickup>>(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<OccupiedSlot>))]
public IReadOnlyList<OccupiedSlot> 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; }
}

View File

@@ -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<IReadOnlyList<StoreTeamMembershipWithPickupStatus>> 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<IReadOnlyList<StoreTeamMembershipWithPickupStatus>>(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; }
}