Compare commits

...

3 Commits

10 changed files with 599 additions and 1 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'

57
FsMcp/Converters.cs Normal file
View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FsMcp;
public class ObjectOrArrayConverter<T> : JsonConverter<IReadOnlyList<T>>
{
public override IReadOnlyList<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartArray)
{
return JsonSerializer.Deserialize<List<T>>(ref reader, options) ?? [];
}
else if (reader.TokenType == JsonTokenType.StartObject)
{
var dict = JsonSerializer.Deserialize<Dictionary<string, T>>(ref reader, options);
if (dict != null)
{
var list = new List<T>(dict.Count);
foreach (var kvp in dict) list.Add(kvp.Value);
return list;
}
return [];
}
using (JsonDocument.ParseValue(ref reader)) { }
return [];
}
public override void Write(Utf8JsonWriter writer, IReadOnlyList<T> value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options);
}
}
public class NumberOrBoolConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.True) return true;
if (reader.TokenType == JsonTokenType.False) return false;
if (reader.TokenType == JsonTokenType.Number) return reader.GetInt32() != 0;
if (reader.TokenType == JsonTokenType.String)
{
var str = reader.GetString();
return str == "1" || str?.ToLower() == "true";
}
return false;
}
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
{
writer.WriteBooleanValue(value);
}
}

View File

@@ -21,5 +21,43 @@ public static class Endpoints
public static string StoreLogActions(int storeId, string fromDate, string toDate, string storeLogActionIds) =>
$"{ApiBase}/api/stores/{storeId}/log/{fromDate}/{toDate}/actions/{storeLogActionIds}";
public static string UserStores(string userId, bool? excludeInactive = null)
{
var query = new List<string>();
if (excludeInactive.HasValue) query.Add($"excludeInactive={excludeInactive.Value.ToString().ToLower()}");
var qs = query.Count > 0 ? "?" + string.Join("&", query) : "";
return $"{ApiBase}/api/users/{userId}/stores{qs}";
}
public static string StorePickups(int storeId) => $"{ApiBase}/api/stores/{storeId}/pickups";
public static string ConfirmPickup(int storeId, string pickupDate, int userId) => $"{ApiBase}/api/stores/{storeId}/pickups/{pickupDate}/users/{userId}";
public static string Conversations(int? limit = null, int? offset = null)
{
var query = new List<string>();
if (limit.HasValue) query.Add($"limit={limit.Value}");
if (offset.HasValue) query.Add($"offset={offset.Value}");
var qs = query.Count > 0 ? "?" + string.Join("&", query) : "";
return $"{ApiBase}/api/conversations{qs}";
}
public static string Conversation(int conversationId, int? limit = null, bool? markAsRead = null)
{
var query = new List<string>();
if (limit.HasValue) query.Add($"limit={limit.Value}");
if (markAsRead.HasValue) query.Add($"markAsRead={markAsRead.Value.ToString().ToLower()}");
var qs = query.Count > 0 ? "?" + string.Join("&", query) : "";
return $"{ApiBase}/api/conversations/{conversationId}{qs}";
}
public static string ConversationMessages(int conversationId, int? olderThanId = null, int? limit = null)
{
var query = new List<string>();
if (olderThanId.HasValue) query.Add($"olderThanId={olderThanId.Value}");
if (limit.HasValue) query.Add($"limit={limit.Value}");
var qs = query.Count > 0 ? "?" + string.Join("&", query) : "";
return $"{ApiBase}/api/conversations/{conversationId}/messages{qs}";
}
private static string ApiBase => "https://beta.foodsharing.de";
}

View File

@@ -19,6 +19,10 @@ builder.Services
.WithTools<FsMcp.Tools.RegionUsersTools>()
.WithTools<FsMcp.Tools.SearchAllTools>()
.WithTools<FsMcp.Tools.RegionForumThreadsTools>()
.WithTools<FsMcp.Tools.ForumThreadTools>();
.WithTools<FsMcp.Tools.ForumThreadTools>()
.WithTools<FsMcp.Tools.ConversationsTools>()
.WithTools<FsMcp.Tools.UserStoresTools>()
.WithTools<FsMcp.Tools.StorePickupsTools>()
.WithTools<FsMcp.Tools.ConfirmPickupTools>();
await builder.Build().RunAsync();

View File

@@ -98,6 +98,50 @@ Notes for consumers:
The tool is strongly typed in `Tools/RegionStoresTools.cs`, with per-field `Description` attributes so MCP clients can surface schema-aware guidance directly in tool UIs.
### User stores tool
This server also exposes `get_user_stores`.
- Purpose: Returns the stores where a user is a member of from `GET /api/users/{userId}/stores`.
- Input:
- `userId` (optional, defaults to `"current"`)
- `excludeInactive` (optional, boolean)
- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header.
- Output: Array of typed store team membership entries including:
- identity (`id`, `name`)
- membership status (`isManaging`, `membershipStatus`)
- category and pickup status (`categoryType`, `pickupStatus`)
The tool is strongly typed in `Tools/UserStoresTools.cs`.
### Store pickups tool
This server also exposes `get_store_pickups`.
- Purpose: List upcoming pickups for a store from `GET /api/stores/{storeId}/pickups`.
- Input:
- `storeId` (required, positive integer)
- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header.
- Output: Array of typed pickup entries including:
- `date`, `totalSlots`, `isAvailable`, `description`
- `occupiedSlots`: List of users who have signed up, including their `isConfirmed` status.
The tool is strongly typed in `Tools/StorePickupsTools.cs`.
### Confirm pickup tool
This server exposes `confirm_pickup`.
- Purpose: Confirm a pickup slot for a user at a store from `PATCH /api/stores/{storeId}/pickups/{pickupDate}/users/{userId}`.
- Input:
- `storeId` (required, positive integer)
- `pickupDate` (required, string, e.g. `2024-05-18T12:00:00.000Z`)
- `userId` (required, positive integer)
- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header.
- Output: Simple "Success" string payload when the slot is confirmed.
The tool is strongly typed in `Tools/ConfirmPickupTools.cs`.
### Store members tool
This server also exposes `get_store_members`.
@@ -267,3 +311,26 @@ For both VS Code and Visual Studio, the configuration file uses the following se
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
- [MCP C# SDK](https://modelcontextprotocol.github.io/csharp-sdk)
## get_conversations
- **Endpoint:** GET /api/conversations
- **Purpose:** Get the list of conversations for the current user.
- **Input Parameters:** limit (int, optional), offset (int, optional).
- **Auth:** Requires login credentials environment variables (USERNAME, PASSWORD). Uses CSRF token handling.
- **Output:** Returns an array of Conversation objects along with ConversationProfile metadata.
## get_conversation
- **Endpoint:** GET /api/conversations/{conversationId}
- **Purpose:** Get a specific conversation including a peek at its messages.
- **Input Parameters:** conversationId (int, required), limit (int, optional), markAsRead (boolean, optional).
- **Auth:** Requires login credentials environment variables (USERNAME, PASSWORD). Uses CSRF token handling.
- **Output:** Returns a Conversation object and an array of ConversationProfile data.
## get_conversation_messages
- **Endpoint:** GET /api/conversations/{conversationId}/messages
- **Purpose:** Get messages from a specific conversation.
- **Input Parameters:** conversationId (int, required), olderThanId (int, optional - used to paginate older messages), limit (int, optional).
- **Auth:** Requires login credentials environment variables (USERNAME, PASSWORD). Uses CSRF token handling.
- **Output:** Returns a MessageCollection object with array of Message and ConversationProfile objects.

View File

@@ -0,0 +1,49 @@
using System.ComponentModel;
using FsMcp;
using ModelContextProtocol.Server;
namespace FsMcp.Tools;
internal sealed class ConfirmPickupTools
{
private readonly FoodsharingApiClient _apiClient;
public ConfirmPickupTools(FoodsharingApiClient apiClient)
{
_apiClient = apiClient;
}
[McpServerTool]
[Description("Confirm a pickup slot for a user at a store from PATCH /api/stores/{storeId}/pickups/{pickupDate}/users/{userId}.")]
public async Task<string> ConfirmPickupAsync(
[Description("Store ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")]
int storeId,
[Description("Pickup date as string (OpenAPI path pattern: [0-9]{4}-...T...Z). Example: 2024-05-18T12:00:00.000Z.")]
string pickupDate,
[Description("User ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")]
int userId)
{
if (storeId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(storeId), "storeId must be a positive integer.");
}
if (userId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(userId), "userId must be a positive integer.");
}
if (string.IsNullOrWhiteSpace(pickupDate))
{
throw new ArgumentException("pickupDate must not be empty.", nameof(pickupDate));
}
await _apiClient.EnsureLoginAsync();
string url = Endpoints.ConfirmPickup(storeId, pickupDate, userId);
using var request = new HttpRequestMessage(HttpMethod.Patch, url);
using var response = await _apiClient.HttpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
return "Success";
}
}

View File

@@ -0,0 +1,181 @@
using System.ComponentModel;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using FsMcp;
using ModelContextProtocol.Server;
namespace FsMcp.Tools;
internal sealed class ConversationsTools
{
private readonly FoodsharingApiClient _apiClient;
public ConversationsTools(FoodsharingApiClient apiClient)
{
_apiClient = apiClient;
}
[McpServerTool]
[Description(@"Get the list of conversations for the current user from GET /api/conversations.
Conversations with titles beginning with 'Team' are very likely store-chats. Conversations titled
with a first name are very likely private conversations with other users. The conversation title is
not necessarily the same as the store name or user name, though.")]
public async Task<ConversationsResponse> GetConversationsAsync(
[Description("Maximum number of results to return.")] int? limit = null,
[Description("The 0-based index of the first entry that is included.")] int? offset = null)
{
await _apiClient.EnsureLoginAsync();
var response = await _apiClient.HttpClient.GetAsync(Endpoints.Conversations(limit, offset));
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"API request failed with status {response.StatusCode}: {content}");
}
var result = await response.Content.ReadFromJsonAsync<ConversationsResponse>();
return result ?? new ConversationsResponse();
}
[McpServerTool]
[Description("Get a conversation including some messages from GET /api/conversations/{conversationId}.")]
public async Task<ConversationResponse> GetConversationAsync(
[Description("Conversation ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")] int conversationId,
[Description("Maximum number of results to return.")] int? limit = null,
[Description("Set to true to mark the conversation as read.")] bool? markAsRead = null)
{
if (conversationId <= 0) throw new ArgumentOutOfRangeException(nameof(conversationId), "conversationId must be positive.");
await _apiClient.EnsureLoginAsync();
var response = await _apiClient.HttpClient.GetAsync(Endpoints.Conversation(conversationId, limit, markAsRead));
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"API request failed with status {response.StatusCode}: {content}");
}
var result = await response.Content.ReadFromJsonAsync<ConversationResponse>();
return result ?? new ConversationResponse();
}
[McpServerTool]
[Description("Get messages from a conversation from GET /api/conversations/{conversationId}/messages.")]
public async Task<MessageCollection> GetConversationMessagesAsync(
[Description("Conversation ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")] int conversationId,
[Description("ID of a message to get older messages than this one.")] int? olderThanId = null,
[Description("Maximum number of results to return.")] int? limit = null)
{
if (conversationId <= 0) throw new ArgumentOutOfRangeException(nameof(conversationId), "conversationId must be positive.");
await _apiClient.EnsureLoginAsync();
var response = await _apiClient.HttpClient.GetAsync(Endpoints.ConversationMessages(conversationId, olderThanId, limit));
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"API request failed with status {response.StatusCode}: {content}");
}
var result = await response.Content.ReadFromJsonAsync<MessageCollection>();
return result ?? new MessageCollection();
}
}
public sealed record ConversationsResponse
{
[Description("List of conversations.")]
[JsonPropertyName("conversations")]
public IReadOnlyList<Conversation> Conversations { get; init; } = Array.Empty<Conversation>();
[Description("List of user profiles referenced in the conversations.")]
[JsonPropertyName("profiles")]
public IReadOnlyList<ConversationProfile> Profiles { get; init; } = Array.Empty<ConversationProfile>();
}
public sealed record ConversationResponse
{
[Description("The conversation object.")]
[JsonPropertyName("conversation")]
public Conversation? Conversation { get; init; }
[Description("List of user profiles referenced in this conversation.")]
[JsonPropertyName("profiles")]
public IReadOnlyList<ConversationProfile> Profiles { get; init; } = Array.Empty<ConversationProfile>();
}
public sealed record MessageCollection
{
[Description("List of messages.")]
[JsonPropertyName("messages")]
public IReadOnlyList<Message> Messages { get; init; } = Array.Empty<Message>();
[Description("List of user profiles referenced in the messages.")]
[JsonPropertyName("profiles")]
public IReadOnlyList<ConversationProfile> Profiles { get; init; } = Array.Empty<ConversationProfile>();
}
public sealed record Conversation
{
[Description("Unique identifier of the conversation.")]
[JsonPropertyName("id")]
public int Id { get; init; }
[Description("Title of the conversation.")]
[JsonPropertyName("title")]
public string? Title { get; init; }
[Description("Store ID associated with the conversation, if any.")]
[JsonPropertyName("storeId")]
public int? StoreId { get; init; }
[Description("Number of unread messages.")]
[JsonPropertyName("unreadMessages")]
public int? UnreadMessages { get; init; }
[Description("The latest message in the conversation.")]
[JsonPropertyName("lastMessage")]
public Message? LastMessage { get; init; }
[Description("Messages in the conversation.")]
[JsonPropertyName("messages")]
public IReadOnlyList<Message> Messages { get; init; } = Array.Empty<Message>();
}
public sealed record Message
{
[Description("Unique identifier of the message.")]
[JsonPropertyName("id")]
public int Id { get; init; }
[Description("Body of the message.")]
[JsonPropertyName("body")]
public string? Body { get; init; }
[Description("Timestamp when the message was sent.")]
[JsonPropertyName("sentAt")]
public DateTimeOffset? SentAt { get; init; }
[Description("ID of the author of the message.")]
[JsonPropertyName("authorId")]
public int? AuthorId { get; init; }
}
public sealed record ConversationProfile
{
[Description("ID of the user.")]
[JsonPropertyName("id")]
public int Id { get; init; }
[Description("Name of the user.")]
[JsonPropertyName("name")]
public string? Name { get; init; }
[Description("Avatar path of the user.")]
[JsonPropertyName("avatar")]
public string? Avatar { get; init; }
[Description("Whether the user is currently using the sleeping hat function.")]
[JsonPropertyName("isSleeping")]
public bool? IsSleeping { get; init; }
}

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; }
}