diff --git a/FsMcp/Endpoints.cs b/FsMcp/Endpoints.cs index 966ce55..ca25d80 100644 --- a/FsMcp/Endpoints.cs +++ b/FsMcp/Endpoints.cs @@ -21,5 +21,32 @@ 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 Conversations(int? limit = null, int? offset = null) + { + var query = new List(); + 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(); + 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(); + 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"; } \ No newline at end of file diff --git a/FsMcp/Program.cs b/FsMcp/Program.cs index 0b07f41..2c77ee9 100644 --- a/FsMcp/Program.cs +++ b/FsMcp/Program.cs @@ -19,6 +19,7 @@ builder.Services .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); await builder.Build().RunAsync(); diff --git a/FsMcp/README.md b/FsMcp/README.md index 356b27d..b60eff8 100644 --- a/FsMcp/README.md +++ b/FsMcp/README.md @@ -267,3 +267,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. + diff --git a/FsMcp/Tools/ConversationsTools.cs b/FsMcp/Tools/ConversationsTools.cs new file mode 100644 index 0000000..ad25841 --- /dev/null +++ b/FsMcp/Tools/ConversationsTools.cs @@ -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 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(); + return result ?? new ConversationsResponse(); + } + + [McpServerTool] + [Description("Get a conversation including some messages from GET /api/conversations/{conversationId}.")] + public async Task 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(); + return result ?? new ConversationResponse(); + } + + [McpServerTool] + [Description("Get messages from a conversation from GET /api/conversations/{conversationId}/messages.")] + public async Task 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(); + return result ?? new MessageCollection(); + } +} + +public sealed record ConversationsResponse +{ + [Description("List of conversations.")] + [JsonPropertyName("conversations")] + public IReadOnlyList Conversations { get; init; } = Array.Empty(); + + [Description("List of user profiles referenced in the conversations.")] + [JsonPropertyName("profiles")] + public IReadOnlyList Profiles { get; init; } = Array.Empty(); +} + +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 Profiles { get; init; } = Array.Empty(); +} + +public sealed record MessageCollection +{ + [Description("List of messages.")] + [JsonPropertyName("messages")] + public IReadOnlyList Messages { get; init; } = Array.Empty(); + + [Description("List of user profiles referenced in the messages.")] + [JsonPropertyName("profiles")] + public IReadOnlyList Profiles { get; init; } = Array.Empty(); +} + +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 Messages { get; init; } = Array.Empty(); +} + +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; } +}