Add tools for region forum threads and forum thread retrieval; update endpoints and enhance README documentation

This commit is contained in:
2026-05-16 14:05:36 +02:00
parent 776af859f0
commit e4f259a1d1
5 changed files with 298 additions and 1 deletions

View File

@@ -7,6 +7,16 @@ public static class Endpoints
public static string RegionStores(int regionId) => $"{ApiBase}/api/regions/{regionId}/stores"; 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 RegionUsers(int regionId) => $"{ApiBase}/api/regions/{regionId}/users";
public static string StoreMembers(int storeId) => $"{ApiBase}/api/stores/{storeId}/members"; 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<string>();
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 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) => 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}";

View File

@@ -17,6 +17,8 @@ builder.Services
.WithTools<RegionStoresTools>() .WithTools<RegionStoresTools>()
.WithTools<StoreMembersTools>() .WithTools<StoreMembersTools>()
.WithTools<FsMcp.Tools.RegionUsersTools>() .WithTools<FsMcp.Tools.RegionUsersTools>()
.WithTools<FsMcp.Tools.SearchAllTools>(); .WithTools<FsMcp.Tools.SearchAllTools>()
.WithTools<FsMcp.Tools.RegionForumThreadsTools>()
.WithTools<FsMcp.Tools.ForumThreadTools>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();

View File

@@ -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. - 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. 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 ## Publishing to NuGet.org
1. Run `dotnet pack -c Release` to create the NuGet package 1. Run `dotnet pack -c Release` to create the NuGet package

View File

@@ -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<ForumThreadResponse> 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<ForumThreadResponse>();
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<ForumPost> Posts { get; init; } = Array.Empty<ForumPost>();
}
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; }
}

View File

@@ -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<PaginatedForumThreadsForListView> 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<PaginatedForumThreadsForListView>();
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<ForumThreadForListView> Entries { get; init; } = Array.Empty<ForumThreadForListView>();
}
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; }
}