Initial import of FsMcp project, ignore mcp.json, add mcp.example.json

This commit is contained in:
2026-03-03 22:00:49 +01:00
parent 76251869af
commit c0020b5e9d
12 changed files with 18738 additions and 0 deletions

24
.gitignore vendored
View File

@@ -12,3 +12,27 @@
# Built Visual Studio Code Extensions
*.vsix
# Build outputs
**/bin/
**/obj/
# User-specific files
*.user
*.suo
*.userosscache
*.sln.docstates
# MCP local credentials/config
mcp.json
.mcp.json
.vscode/mcp.json
# Rider
.idea/
# Logs
*.log
# OS files
.DS_Store
Thumbs.db

3
FsMcp.slnx Normal file
View File

@@ -0,0 +1,3 @@
<Solution>
<Project Path="FsMcp/FsMcp.csproj" />
</Solution>

22
FsMcp/.mcp/server.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
"description": "<your description here>",
"name": "io.github.<your GitHub username here>/<your repo name>",
"version": "0.1.0-beta",
"packages": [
{
"registryType": "nuget",
"identifier": "<your package ID here>",
"version": "0.1.0-beta",
"transport": {
"type": "stdio"
},
"packageArguments": [],
"environmentVariables": []
}
],
"repository": {
"url": "https://github.com/<your GitHub username here>/<your repo name>",
"source": "github"
}
}

9
FsMcp/Endpoints.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace FsMcp;
public static class Endpoints
{
public static string UserLogin => $"{ApiBase}/api/login";
public static string UserCurrentDetails => $"{ApiBase}/api/users/current/details";
private static string ApiBase => "https://beta.foodsharing.de";
}

View File

@@ -0,0 +1,103 @@
using System.Net.Http.Json;
namespace FsMcp;
internal sealed class FoodsharingApiClient
{
private readonly SemaphoreSlim _loginLock = new(1, 1);
public HttpClient HttpClient { get; } = new(new RequestThrottleHandler(TimeSpan.FromMilliseconds(200)));
public async Task EnsureLoginAsync()
{
if (HttpClient.DefaultRequestHeaders.Contains("X-CSRF-Token"))
{
return;
}
await _loginLock.WaitAsync();
try
{
if (HttpClient.DefaultRequestHeaders.Contains("X-CSRF-Token"))
{
return;
}
string username = Environment.GetEnvironmentVariable("USERNAME") ?? throw new InvalidOperationException("USERNAME environment variable is not set.");
string password = Environment.GetEnvironmentVariable("PASSWORD") ?? throw new InvalidOperationException("PASSWORD environment variable is not set.");
var payload = new
{
email = username,
password,
remember_me = true
};
using var response = await HttpClient.PostAsJsonAsync(Endpoints.UserLogin, payload);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Login failed ({(int)response.StatusCode} {response.ReasonPhrase}): {responseBody}");
}
if (!response.Headers.TryGetValues("Set-Cookie", out var setCookieValues))
{
throw new InvalidOperationException("Set-Cookie headers are missing from login response.");
}
string setCookieHeader = string.Join(";", setCookieValues);
string? csrfToken = setCookieHeader
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(cookie => cookie.Trim())
.FirstOrDefault(cookie => cookie.StartsWith("FS_CSRF_TOKEN="))
?.Split('=', 2)[1];
if (string.IsNullOrWhiteSpace(csrfToken))
{
throw new InvalidOperationException("CSRF token not found in Set-Cookie header.");
}
HttpClient.DefaultRequestHeaders.Remove("X-CSRF-Token");
HttpClient.DefaultRequestHeaders.Add("X-CSRF-Token", csrfToken);
}
finally
{
_loginLock.Release();
}
}
}
internal sealed class RequestThrottleHandler : DelegatingHandler
{
private readonly TimeSpan _minimumInterval;
private readonly SemaphoreSlim _requestGate = new(1, 1);
private DateTimeOffset _lastRequestStartedAt = DateTimeOffset.MinValue;
public RequestThrottleHandler(TimeSpan minimumInterval)
: base(new HttpClientHandler())
{
_minimumInterval = minimumInterval;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await _requestGate.WaitAsync(cancellationToken);
try
{
var waitFor = _lastRequestStartedAt + _minimumInterval - DateTimeOffset.UtcNow;
if (waitFor > TimeSpan.Zero)
{
await Task.Delay(waitFor, cancellationToken);
}
_lastRequestStartedAt = DateTimeOffset.UtcNow;
}
finally
{
_requestGate.Release();
}
return await base.SendAsync(request, cancellationToken);
}
}

40
FsMcp/FsMcp.csproj Normal file
View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifiers>win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64</RuntimeIdentifiers>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Set up the NuGet package to be an MCP server -->
<PackAsTool>true</PackAsTool>
<PackageType>McpServer</PackageType>
<!-- Set up the MCP server to be a self-contained application that does not rely on a shared framework -->
<SelfContained>true</SelfContained>
<PublishSelfContained>true</PublishSelfContained>
<!-- Set up the MCP server to be a single file executable -->
<PublishSingleFile>true</PublishSingleFile>
<!-- Set recommended package metadata -->
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageId>SampleMcpServer</PackageId>
<PackageVersion>0.1.0-beta</PackageVersion>
<PackageTags>AI; MCP; server; stdio</PackageTags>
<Description>An MCP server using the MCP C# SDK.</Description>
</PropertyGroup>
<!-- Include additional files for browsing the MCP server. -->
<ItemGroup>
<None Include=".mcp\server.json" Pack="true" PackagePath="/.mcp/" />
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="ModelContextProtocol" Version="0.7.0-preview.1" />
</ItemGroup>
</Project>

19
FsMcp/Program.cs Normal file
View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using FsMcp;
var builder = Host.CreateApplicationBuilder(args);
// Configure all logs to go to stderr (stdout is used for the MCP protocol messages).
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
builder.Services.AddSingleton<FoodsharingApiClient>();
// Add the MCP services: the transport to use (stdio) and the tools to register.
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<RandomNumberTools>()
.WithTools<CurrentUserTools>();
await builder.Build().RunAsync();

115
FsMcp/README.md Normal file
View File

@@ -0,0 +1,115 @@
# MCP Server
This README was created using the C# MCP server project template.
It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package.
The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine.
However, since it is self-contained, it must be built for each target platform separately.
By default, the template is configured to build for:
* `win-x64`
* `win-arm64`
* `osx-arm64`
* `linux-x64`
* `linux-arm64`
* `linux-musl-x64`
If your users require more platforms to be supported, update the list of runtime identifiers in the project's `<RuntimeIdentifiers />` element.
See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide.
Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey).
## Checklist before publishing to NuGet.org
- Test the MCP server locally using the steps below.
- Update the package metadata in the .csproj file, in particular the `<PackageId>`.
- Update `.mcp/server.json` to declare your MCP server's inputs.
- See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details.
- Pack the project using `dotnet pack`.
The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package).
## Developing locally
To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`.
```json
{
"servers": {
"FsMcp": {
"type": "stdio",
"command": "dotnet",
"args": [
"run",
"--project",
"<PATH TO PROJECT DIRECTORY>"
]
}
}
}
```
Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers:
- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers)
## Testing the MCP Server
Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `FsMcp` MCP server and show you the results.
### Current user profile tool
This server also exposes `get_current_user_details`.
- Purpose: Returns full details for the currently authenticated user from `GET /api/users/current/details`.
- Auth: Uses `USERNAME` and `PASSWORD` environment variables to log in and then sends the `X-CSRF-Token` header.
- Output: Structured profile object including:
- identity and account status (`id`, `foodsaver`, `isVerified`, `role`, `isSleeping`)
- primary region data (`regionId`, `regionName`) and all memberships (`regions`, `groups`)
- profile/contact fields (`firstname`, `lastname`, `email`, `address`, `postcode`, `city`, `landline`, `mobile`, `birthday`, `photo`)
- activity/pass data (`stats.weight`, `stats.count`, `lastPassDate`, `lastPassUntilValid`, `lastPassUntilValidInDays`)
- permission flags (`permissions.*`)
- coordinates (`coordinates.lat`, `coordinates.lon`)
The tool is strongly typed in `Tools/CurrentUserTools.cs`, where each field is documented with `Description` attributes so MCP clients can surface schema-aware help.
## Publishing to NuGet.org
1. Run `dotnet pack -c Release` to create the NuGet package
2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key <your-api-key> --source https://api.nuget.org/v3/index.json`
## Using the MCP Server from NuGet.org
Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org.
- **VS Code**: Create a `<WORKSPACE DIRECTORY>/.vscode/mcp.json` file
- **Visual Studio**: Create a `<SOLUTION DIRECTORY>\.mcp.json` file
For both VS Code and Visual Studio, the configuration file uses the following server definition:
```json
{
"servers": {
"FsMcp": {
"type": "stdio",
"command": "dnx",
"args": [
"<your package ID here>",
"--version",
"<your package version here>",
"--yes"
]
}
}
}
```
## More information
.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP:
- [Official Documentation](https://modelcontextprotocol.io/)
- [Protocol Specification](https://spec.modelcontextprotocol.io/)
- [GitHub Organization](https://github.com/modelcontextprotocol)
- [MCP C# SDK](https://modelcontextprotocol.github.io/csharp-sdk)

View File

@@ -0,0 +1,263 @@
using System.ComponentModel;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using FsMcp;
using ModelContextProtocol.Server;
internal class CurrentUserTools
{
private readonly FoodsharingApiClient _apiClient;
public CurrentUserTools(FoodsharingApiClient apiClient)
{
_apiClient = apiClient;
}
[McpServerTool]
[Description("Returns full profile details for the currently authenticated foodsharing user from /api/users/current/details, including identity, verification, membership regions/groups, permissions, contact data, coordinates, pass status, and activity stats.")]
public async Task<CurrentUserDetails> GetCurrentUserDetailsAsync()
{
await _apiClient.EnsureLoginAsync();
var userDetails = await _apiClient.HttpClient.GetFromJsonAsync<CurrentUserDetails>(Endpoints.UserCurrentDetails);
return userDetails ?? throw new InvalidOperationException("Current user details response was empty.");
}
}
public sealed record CurrentUserDetails
{
[Description("Unique user ID.")]
[JsonPropertyName("id")]
public int Id { get; init; }
[Description("True if the account is a foodsaver account.")]
[JsonPropertyName("foodsaver")]
public bool Foodsaver { get; init; }
[Description("True if the account is verified.")]
[JsonPropertyName("isVerified")]
public bool IsVerified { get; init; }
[Description("Primary region ID of the user.")]
[JsonPropertyName("regionId")]
public int RegionId { get; init; }
[Description("True if sleep mode is currently enabled for the user.")]
[JsonPropertyName("isSleeping")]
public bool IsSleeping { get; init; }
[Description("Display name of the user's primary region.")]
[JsonPropertyName("regionName")]
public string RegionName { get; init; } = string.Empty;
[Description("Public about-me text visible to other users.")]
[JsonPropertyName("aboutMePublic")]
public string AboutMePublic { get; init; } = string.Empty;
[Description("Mailbox ID for internal messaging.")]
[JsonPropertyName("mailboxId")]
public int MailboxId { get; init; }
[Description("True if an iCal calendar token exists for the user.")]
[JsonPropertyName("hasCalendarToken")]
public bool HasCalendarToken { get; init; }
[Description("User first name.")]
[JsonPropertyName("firstname")]
public string Firstname { get; init; } = string.Empty;
[Description("User last name.")]
[JsonPropertyName("lastname")]
public string Lastname { get; init; } = string.Empty;
[Description("Gender value as provided by API (numeric enum).")]
[JsonPropertyName("gender")]
public int Gender { get; init; }
[Description("Path to profile photo resource.")]
[JsonPropertyName("photo")]
public string Photo { get; init; } = string.Empty;
[Description("Date/time of last successful pass completion (ISO-8601 timestamp).")]
[JsonPropertyName("lastPassDate")]
public DateTimeOffset? LastPassDate { get; init; }
[Description("Date/time until pass validity remains active (ISO-8601 timestamp).")]
[JsonPropertyName("lastPassUntilValid")]
public DateTimeOffset? LastPassUntilValid { get; init; }
[Description("Remaining validity duration of the pass in whole days.")]
[JsonPropertyName("lastPassUntilValidInDays")]
public int? LastPassUntilValidInDays { get; init; }
[Description("User contribution statistics (weight and count).")]
[JsonPropertyName("stats")]
public UserStats? Stats { get; init; }
[Description("Permission flags for profile editing and administrative capabilities.")]
[JsonPropertyName("permissions")]
public UserPermissions? Permissions { get; init; }
[Description("True if an active email address is currently configured.")]
[JsonPropertyName("hasActiveEmail")]
public bool HasActiveEmail { get; init; }
[Description("Home coordinates of the user profile.")]
[JsonPropertyName("coordinates")]
public GeoCoordinates? Coordinates { get; init; }
[Description("Street address.")]
[JsonPropertyName("address")]
public string Address { get; init; } = string.Empty;
[Description("City name.")]
[JsonPropertyName("city")]
public string City { get; init; } = string.Empty;
[Description("Postal code.")]
[JsonPropertyName("postcode")]
public string Postcode { get; init; } = string.Empty;
[Description("Email address.")]
[JsonPropertyName("email")]
public string Email { get; init; } = string.Empty;
[Description("Landline phone number.")]
[JsonPropertyName("landline")]
public string Landline { get; init; } = string.Empty;
[Description("Mobile phone number.")]
[JsonPropertyName("mobile")]
public string Mobile { get; init; } = string.Empty;
[Description("Birthday date/time as provided by API (ISO-8601 timestamp).")]
[JsonPropertyName("birthday")]
public DateTimeOffset? Birthday { get; init; }
[Description("Internal about-me text visible to authorized roles.")]
[JsonPropertyName("aboutMeIntern")]
public string AboutMeIntern { get; init; } = string.Empty;
[Description("Role identifier as provided by API (numeric enum).")]
[JsonPropertyName("role")]
public int Role { get; init; }
[Description("All regions the user is a member of, with classification and responsibility status.")]
[JsonPropertyName("regions")]
public IReadOnlyList<UserRegionMembership> Regions { get; init; } = [];
[Description("All groups the user belongs to with responsibility status.")]
[JsonPropertyName("groups")]
public IReadOnlyList<UserGroupMembership> Groups { get; init; } = [];
[Description("Optional position or role title text.")]
[JsonPropertyName("position")]
public string Position { get; init; } = string.Empty;
}
public sealed record UserStats
{
[Description("Total saved food weight in kilograms.")]
[JsonPropertyName("weight")]
public double Weight { get; init; }
[Description("Total number of counted activities/pickups.")]
[JsonPropertyName("count")]
public int Count { get; init; }
}
public sealed record UserPermissions
{
[Description("May edit own user profile.")]
[JsonPropertyName("mayEditUserProfile")]
public bool MayEditUserProfile { get; init; }
[Description("May administrate other user profiles.")]
[JsonPropertyName("mayAdministrateUserProfile")]
public bool MayAdministrateUserProfile { get; init; }
[Description("May administrate blog posts.")]
[JsonPropertyName("administrateBlog")]
public bool AdministrateBlog { get; init; }
[Description("May edit quizzes.")]
[JsonPropertyName("editQuiz")]
public bool EditQuiz { get; init; }
[Description("May handle reports.")]
[JsonPropertyName("handleReports")]
public bool HandleReports { get; init; }
[Description("May add stores.")]
[JsonPropertyName("addStore")]
public bool AddStore { get; init; }
[Description("May edit static content.")]
[JsonPropertyName("editContent")]
public bool EditContent { get; init; }
[Description("May administrate newsletter email settings.")]
[JsonPropertyName("administrateNewsletterEmail")]
public bool AdministrateNewsletterEmail { get; init; }
[Description("May administrate regions.")]
[JsonPropertyName("administrateRegions")]
public bool AdministrateRegions { get; init; }
[Description("May perform global search.")]
[JsonPropertyName("maySearchGlobal")]
public bool MaySearchGlobal { get; init; }
[Description("May edit store categories.")]
[JsonPropertyName("editStoreCategories")]
public bool EditStoreCategories { get; init; }
[Description("May edit resource categories.")]
[JsonPropertyName("editResourceCategories")]
public bool EditResourceCategories { get; init; }
}
public sealed record GeoCoordinates
{
[Description("Latitude in decimal degrees.")]
[JsonPropertyName("lat")]
public double Lat { get; init; }
[Description("Longitude in decimal degrees.")]
[JsonPropertyName("lon")]
public double Lon { get; init; }
}
public sealed record UserRegionMembership
{
[Description("Region ID.")]
[JsonPropertyName("id")]
public int Id { get; init; }
[Description("Region display name.")]
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[Description("Region classification numeric value from API.")]
[JsonPropertyName("classification")]
public int Classification { get; init; }
[Description("True if the user has responsible role in this region.")]
[JsonPropertyName("isResponsible")]
public bool IsResponsible { get; init; }
}
public sealed record UserGroupMembership
{
[Description("Group ID.")]
[JsonPropertyName("id")]
public int Id { get; init; }
[Description("Group display name.")]
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[Description("True if the user has responsible role in this group.")]
[JsonPropertyName("isResponsible")]
public bool IsResponsible { get; init; }
}

View File

@@ -0,0 +1,51 @@
using System.ComponentModel;
using FsMcp;
using ModelContextProtocol.Server;
using System.Linq;
/// <summary>
/// Sample MCP tools for demonstration purposes.
/// These tools can be invoked by MCP clients to perform various operations.
/// </summary>
internal class RandomNumberTools
{
private readonly FoodsharingApiClient _apiClient;
public RandomNumberTools(FoodsharingApiClient apiClient)
{
_apiClient = apiClient;
}
// Mcp Server tool to get the crsf token for the current session (for demonstration purposes).
[McpServerTool]
[Description("Gets the CSRF token for the current session.")]
public async Task<string> GetCsrfTokenAsync()
{
await _apiClient.EnsureLoginAsync();
return _apiClient.HttpClient.DefaultRequestHeaders.GetValues("X-CSRF-Token").FirstOrDefault() ?? throw new InvalidOperationException("CSRF token not found.");
}
[McpServerTool]
[Description("Generates a random number between the specified minimum and maximum values.")]
public int GetRandomNumber(
[Description("Minimum value (inclusive)")] int min = 0,
[Description("Maximum value (exclusive)")] int max = 100)
{
return Random.Shared.Next(min, max);
}
[McpServerTool]
[Description("Generates a random Lorem Ipsum text with the specified number of words.")]
public string GetLoremIpsum(int wordCount = 10)
{
string[] loremIpsumWords = new[]
{
"Lorem", "ipsum", "dolor", "sit", "amet", "consectetur",
"adipiscing", "elit", "sed", "do", "eiusmod", "tempor",
"incididunt", "ut", "labore", "et", "dolore", "magna",
"aliqua"
};
return string.Join(" ", Enumerable.Range(0, wordCount).Select(_ => loremIpsumWords[Random.Shared.Next(loremIpsumWords.Length)]));
}
}

18070
fsopenapi.json Normal file

File diff suppressed because it is too large Load Diff

19
mcp.example.json Normal file
View File

@@ -0,0 +1,19 @@
{
"servers": {
"FsMcp": {
"type": "stdio",
"command": "dotnet",
"args": [
"run",
"--project",
"FsMcp/FsMcp.csproj",
"-c",
"Debug"
],
"env": {
"USERNAME": "your-foodsharing-username",
"PASSWORD": "your-foodsharing-password"
}
}
}
}