Add tools for region forum threads and forum thread retrieval; update endpoints and enhance README documentation
This commit is contained in:
@@ -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}";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
153
FsMcp/Tools/ForumThreadTools.cs
Normal file
153
FsMcp/Tools/ForumThreadTools.cs
Normal 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; }
|
||||||
|
}
|
||||||
108
FsMcp/Tools/RegionForumThreadsTools.cs
Normal file
108
FsMcp/Tools/RegionForumThreadsTools.cs
Normal 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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user