Compare commits
3 Commits
e4f259a1d1
...
cebe292e2e
| Author | SHA1 | Date | |
|---|---|---|---|
| cebe292e2e | |||
| 2aae8a4e58 | |||
| 2418966b82 |
28
.github/plans/plan-managed-stores-pickups.prompt.md
vendored
Normal file
28
.github/plans/plan-managed-stores-pickups.prompt.md
vendored
Normal 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.
|
||||||
12
.github/prompts/list-open-slot-requests.prompt.md
vendored
Normal file
12
.github/prompts/list-open-slot-requests.prompt.md
vendored
Normal 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
57
FsMcp/Converters.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,5 +21,43 @@ public static class Endpoints
|
|||||||
public static string StoreLogActions(int storeId, string fromDate, string toDate, string storeLogActionIds) =>
|
public static string StoreLogActions(int storeId, string fromDate, string toDate, string storeLogActionIds) =>
|
||||||
$"{ApiBase}/api/stores/{storeId}/log/{fromDate}/{toDate}/actions/{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";
|
private static string ApiBase => "https://beta.foodsharing.de";
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,10 @@ builder.Services
|
|||||||
.WithTools<FsMcp.Tools.RegionUsersTools>()
|
.WithTools<FsMcp.Tools.RegionUsersTools>()
|
||||||
.WithTools<FsMcp.Tools.SearchAllTools>()
|
.WithTools<FsMcp.Tools.SearchAllTools>()
|
||||||
.WithTools<FsMcp.Tools.RegionForumThreadsTools>()
|
.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();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
@@ -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.
|
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
|
### Store members tool
|
||||||
|
|
||||||
This server also exposes `get_store_members`.
|
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/)
|
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
|
||||||
- [GitHub Organization](https://github.com/modelcontextprotocol)
|
- [GitHub Organization](https://github.com/modelcontextprotocol)
|
||||||
- [MCP C# SDK](https://modelcontextprotocol.github.io/csharp-sdk)
|
- [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.
|
||||||
|
|
||||||
|
|||||||
49
FsMcp/Tools/ConfirmPickupTools.cs
Normal file
49
FsMcp/Tools/ConfirmPickupTools.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
181
FsMcp/Tools/ConversationsTools.cs
Normal file
181
FsMcp/Tools/ConversationsTools.cs
Normal 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; }
|
||||||
|
}
|
||||||
104
FsMcp/Tools/StorePickupsTools.cs
Normal file
104
FsMcp/Tools/StorePickupsTools.cs
Normal 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; }
|
||||||
|
}
|
||||||
58
FsMcp/Tools/UserStoresTools.cs
Normal file
58
FsMcp/Tools/UserStoresTools.cs
Normal 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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user