diff --git a/FsMcp/Endpoints.cs b/FsMcp/Endpoints.cs index 242f6e0..966ce55 100644 --- a/FsMcp/Endpoints.cs +++ b/FsMcp/Endpoints.cs @@ -7,6 +7,16 @@ public static class Endpoints public static string RegionStores(int regionId) => $"{ApiBase}/api/regions/{regionId}/stores"; public static string RegionUsers(int regionId) => $"{ApiBase}/api/regions/{regionId}/users"; public static string StoreMembers(int storeId) => $"{ApiBase}/api/stores/{storeId}/members"; + public static string RegionForumThreads(int regionId, int? subforumId = null, int? limit = null, int? offset = null) + { + var query = new List(); + if (subforumId.HasValue) query.Add($"subforumId={subforumId.Value}"); + 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/regions/{regionId}/forum/threads{qs}"; + } + public static string ForumThread(int threadId) => $"{ApiBase}/api/forum/threads/{threadId}"; public static string SearchAll(string q, bool? global = null) => $"{ApiBase}/api/search/all?q={Uri.EscapeDataString(q)}" + (global.HasValue ? $"&global={global.Value.ToString().ToLower()}" : ""); public static string StoreLogActions(int storeId, string fromDate, string toDate, string storeLogActionIds) => $"{ApiBase}/api/stores/{storeId}/log/{fromDate}/{toDate}/actions/{storeLogActionIds}"; diff --git a/FsMcp/Program.cs b/FsMcp/Program.cs index 0869ba9..0b07f41 100644 --- a/FsMcp/Program.cs +++ b/FsMcp/Program.cs @@ -17,6 +17,8 @@ builder.Services .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools() + .WithTools(); await builder.Build().RunAsync(); diff --git a/FsMcp/README.md b/FsMcp/README.md index 16cb9af..356b27d 100644 --- a/FsMcp/README.md +++ b/FsMcp/README.md @@ -204,6 +204,30 @@ Notes for consumers: - To reduce verbosity, all sub-entity arrays map to a shared base search result object (`id`, `name`, `searchString`). Detailed payloads for each entity type are ignored for simplicity. The tool is strongly typed in `Tools/SearchAllTools.cs`, with per-field `Description` attributes. + +### Region forum threads tool + +This server exposes `get_region_forum_threads`. + +- Purpose: Returns paginated threads of a given region's forum from `GET /api/regions/{regionId}/forum/threads`. +- Input: + - `regionId` (required, positive integer) + - `subforumId` (optional, integer; 1 for ambassador, 0 for normal) + - `limit` (optional, integer) + - `offset` (optional, integer) +- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header. +- Output: Returns a `PaginatedForumThreadsForListView` containing `totalCount`, `offset`, and an `entries` array of forum threads. + +### Forum thread tool + +This server exposes `get_forum_thread`. + +- Purpose: Returns a forum thread including all posts from `GET /api/forum/threads/{threadId}`. +- Input: + - `threadId` (required, positive integer) +- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header. +- Output: Returns a `ForumThreadResponse` containing thread info, metadata (`permissions`, `subscriptionsStatus`), and the `posts` array. + ## Publishing to NuGet.org 1. Run `dotnet pack -c Release` to create the NuGet package diff --git a/FsMcp/Tools/ForumThreadTools.cs b/FsMcp/Tools/ForumThreadTools.cs new file mode 100644 index 0000000..72fb3ce --- /dev/null +++ b/FsMcp/Tools/ForumThreadTools.cs @@ -0,0 +1,153 @@ +using System.ComponentModel; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using FsMcp; +using ModelContextProtocol.Server; + +namespace FsMcp.Tools; + +internal sealed class ForumThreadTools +{ + private readonly FoodsharingApiClient _apiClient; + + public ForumThreadTools(FoodsharingApiClient apiClient) + { + _apiClient = apiClient; + } + + [McpServerTool] + [Description("Returns a forum thread including all posts from GET /api/forum/threads/{threadId}.")] + public async Task GetForumThreadAsync( + [Description("Forum thread ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")] int threadId) + { + if (threadId <= 0) throw new ArgumentOutOfRangeException(nameof(threadId), "threadId must be a positive integer."); + + await _apiClient.EnsureLoginAsync(); + + var response = await _apiClient.HttpClient.GetAsync(Endpoints.ForumThread(threadId)); + 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 ForumThreadResponse(); + } +} + +public sealed record ForumThreadResponse +{ + [Description("Thread ID.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("Region ID.")] + [JsonPropertyName("regionId")] + public int? RegionId { get; init; } + + [Description("Subforum identifier. 1 is the ambassador forum, 0 the normal forum.")] + [JsonPropertyName("subforumId")] + public int? SubforumId { get; init; } + + [Description("Title of the thread.")] + [JsonPropertyName("title")] + public string Title { get; init; } = string.Empty; + + [Description("Pinned level from -1 (end of list) to 10 (top of list).")] + [JsonPropertyName("pinnedLevel")] + public int? PinnedLevel { get; init; } + + [Description("Whether the thread is locked.")] + [JsonPropertyName("isLocked")] + public bool? IsLocked { get; init; } + + [Description("Whether the thread is active, or needs to be checked by a moderator first.")] + [JsonPropertyName("isActive")] + public bool? IsActive { get; init; } + + [Description("Id of the user who initially created the thread.")] + [JsonPropertyName("creatorId")] + public int? CreatorId { get; init; } + + [Description("Id of the last post in this thread.")] + [JsonPropertyName("lastPostId")] + public int? LastPostId { get; init; } + + [Description("Permissions associated with this thread.")] + [JsonPropertyName("permissions")] + public ForumThreadPermissions? Permissions { get; init; } + + [Description("User's subscription status for this thread.")] + [JsonPropertyName("subscriptionsStatus")] + public SubscriptionsStatus? SubscriptionsStatus { get; init; } + + [Description("Posts in the thread.")] + [JsonPropertyName("posts")] + public IReadOnlyList Posts { get; init; } = Array.Empty(); +} + +public sealed record ForumThreadPermissions +{ + [Description("Whether the user may moderate the thread.")] + [JsonPropertyName("mayModerate")] + public bool? MayModerate { get; init; } + + [Description("Whether the user may hide posts.")] + [JsonPropertyName("mayHidePosts")] + public bool? MayHidePosts { get; init; } + + [Description("Whether the user is allowed to delete posts of other users (own posts are always deletable).")] + [JsonPropertyName("mayDelete")] + public bool? MayDelete { get; init; } +} + +public sealed record SubscriptionsStatus +{ + [Description("Whether the user is subscribed to bell notifications.")] + [JsonPropertyName("isBellSubscribed")] + public bool? IsBellSubscribed { get; init; } + + [Description("Whether the user is subscribed to mail notifications.")] + [JsonPropertyName("isMailSubscribed")] + public bool? IsMailSubscribed { get; init; } +} + +public sealed record ForumPost +{ + [Description("ID of the post.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("Body of the post.")] + [JsonPropertyName("body")] + public string? Body { get; init; } + + [Description("Creation timestamp of the post.")] + [JsonPropertyName("createdAt")] + public DateTimeOffset? CreatedAt { get; init; } + + [Description("Author profile of the post.")] + [JsonPropertyName("author")] + public ForumProfile? Author { get; init; } + + [Description("Hidden post info, if the post is hidden.")] + [JsonPropertyName("hidden")] + public HiddenPostInfo? Hidden { get; init; } +} + +public sealed record HiddenPostInfo +{ + [Description("Reason why the post was hidden.")] + [JsonPropertyName("reason")] + public string? Reason { get; init; } + + [Description("Profile of the moderator who hidden the post.")] + [JsonPropertyName("moderator")] + public ForumProfile? Moderator { get; init; } + + [Description("Timestamp when the post was hidden.")] + [JsonPropertyName("time")] + public DateTimeOffset? Time { get; init; } +} \ No newline at end of file diff --git a/FsMcp/Tools/RegionForumThreadsTools.cs b/FsMcp/Tools/RegionForumThreadsTools.cs new file mode 100644 index 0000000..0e9ee5b --- /dev/null +++ b/FsMcp/Tools/RegionForumThreadsTools.cs @@ -0,0 +1,108 @@ +using System.ComponentModel; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using FsMcp; +using ModelContextProtocol.Server; + +namespace FsMcp.Tools; + +internal sealed class RegionForumThreadsTools +{ + private readonly FoodsharingApiClient _apiClient; + + public RegionForumThreadsTools(FoodsharingApiClient apiClient) + { + _apiClient = apiClient; + } + + [McpServerTool] + [Description("List threads of a region forum from GET /api/regions/{regionId}/forum/threads.")] + public async Task GetRegionForumThreadsAsync( + [Description("Region ID as positive integer (OpenAPI path pattern: [1-9][0-9]*).")] int regionId, + [Description("Subforum identifier. 1 is the ambassador forum, 0 the normal forum.")] int? subforumId = null, + [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) + { + if (regionId <= 0) throw new ArgumentOutOfRangeException(nameof(regionId), "regionId must be a positive integer."); + + await _apiClient.EnsureLoginAsync(); + + var response = await _apiClient.HttpClient.GetAsync(Endpoints.RegionForumThreads(regionId, subforumId, 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 PaginatedForumThreadsForListView(); + } +} + +public sealed record PaginatedForumThreadsForListView +{ + [Description("The total number of entries that are available.")] + [JsonPropertyName("totalCount")] + public int? TotalCount { get; init; } + + [Description("The 0-based index of the first entry that is included.")] + [JsonPropertyName("offset")] + public int? Offset { get; init; } + + [Description("List of forum thread items.")] + [JsonPropertyName("entries")] + public IReadOnlyList Entries { get; init; } = Array.Empty(); +} + +public sealed record ForumThreadForListView +{ + [Description("Unique identifier of the forum thread.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("Title of the forum thread.")] + [JsonPropertyName("title")] + public string Title { get; init; } = string.Empty; + + [Description("Pinned level from -1 (end of list) to 10 (top of list).")] + [JsonPropertyName("pinnedLevel")] + public int? PinnedLevel { get; init; } + + [Description("Whether the thread is locked.")] + [JsonPropertyName("isLocked")] + public bool? IsLocked { get; init; } + + [Description("Summary of the latest post in this thread.")] + [JsonPropertyName("latestPost")] + public ForumPostSummary? LatestPost { get; init; } +} + +public sealed record ForumPostSummary +{ + [Description("Creation date of the post.")] + [JsonPropertyName("createdAt")] + public DateTimeOffset? CreatedAt { get; init; } + + [Description("Author profile of the post.")] + [JsonPropertyName("author")] + public ForumProfile? Author { get; init; } +} + +public sealed record ForumProfile +{ + [Description("ID of the user.")] + [JsonPropertyName("id")] + public int Id { get; init; } + + [Description("Name of the user.")] + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [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; } +} \ No newline at end of file