Skip to content

Commit 9ec84b7

Browse files
feat(api): implement NuGet package tools and integration tests
Signed-off-by: SebastienDegodez <sebastien.degodez@gmail.com>
1 parent 760882b commit 9ec84b7

23 files changed

Lines changed: 968 additions & 23 deletions

README.md

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,68 @@
1-
# McpPack MCP Server (.NET)
1+
# MCP.Pack 🚀
22

3-
This project is a Model Context Protocol (MCP) server built with .NET, following Clean Architecture and Domain-Driven Design (DDD) principles.
3+
A modern .NET server for Model Context Protocol (MCP) and NuGet package management 📦.
4+
Built with Clean Architecture 🏗️ and Domain-Driven Design (DDD) 🧠, supporting AOT compilation ⚡ and comprehensive automated testing 🧪.
45

5-
## Structure
6-
- **McpPack.Domain**: Domain logic, aggregates, entities, value objects, repositories, domain events
7-
- **McpPack.Application**: Application services, use cases, interfaces
8-
- **McpPack.Infrastructure**: Infrastructure implementations (e.g., data access, external services)
9-
- **McpPack.Api**: ASP.NET Core Web API (entry point)
6+
---
107

11-
## Getting Started
8+
## ✨ Features
9+
- 🔍 Discover and search NuGet packages via the Model Context Protocol (MCP)
10+
- 📑 Retrieve rich package metadata for any NuGet feed
11+
- 🧩 Designed for extensibility and integration in modern .NET ecosystems
1212

13-
### Prerequisites
14-
- [.NET 8 SDK](https://dotnet.microsoft.com/download)
13+
## 🛠️ Technical Highlights
14+
- 🏗️ **Clean Architecture**: strict separation of Domain, Application, Infrastructure, and API layers
15+
-**AOT (Ahead-Of-Time) compilation**: fast startup, low memory usage
16+
- 🧪 **Comprehensive automated tests**: unit, integration, and architecture validation
17+
- 💎 **Modern .NET 9**: minimal APIs, dependency injection, async/await everywhere
1518

16-
### Build
19+
## 🗂️ Project Structure
20+
21+
```
22+
McpPack.sln
23+
src/
24+
McpPack.Domain/ # Domain logic, aggregates, value objects, repositories
25+
McpPack.Application/ # Use cases, service interfaces
26+
McpPack.Infrastructure/ # Implementations for repositories, external services
27+
McpPack.Api/ # API endpoints, orchestration
28+
29+
tests/
30+
McpPack.UnitTests/ # Unit tests for Domain & Application
31+
McpPack.IntegrationTests/ # Integration & architecture tests
32+
```
33+
34+
## 🚀 Getting Started
35+
36+
### 🧰 Prerequisites
37+
- 🟣 [.NET 9 SDK](https://dotnet.microsoft.com/download)
38+
- 📊 (Optional) [dotnet-reportgenerator-globaltool](https://github.com/danielpalme/ReportGenerator) for coverage reports
39+
40+
### 🏃‍♂️ Build & Run
1741
```sh
1842
dotnet build
43+
dotnet run --project src/McpPack.Api
1944
```
2045

21-
### Run
46+
### 🧪 Test
2247
```sh
23-
dotnet run --project McpPack.Api
48+
dotnet test
2449
```
2550

26-
### Debug
27-
- Open the solution in VS Code or Visual Studio
28-
- Set breakpoints in any project
29-
- Start debugging (F5)
51+
## 🏛️ Architecture Principles
52+
- 🧠 **Domain**: no dependencies, pure business logic
53+
- 🎯 **Application**: orchestrates use cases, depends only on Domain
54+
- 🏗️ **Infrastructure**: implements interfaces, depends on Application & Domain
55+
- 🌐 **API**: exposes endpoints, depends only on Infrastructure
56+
- 🧪 **Tests**: separated by type (unit/integration/architecture)
57+
58+
## 🤝 Contributing
59+
- 📝 Follow [Conventional Commits](https://www.conventionalcommits.org/)
60+
- 🧪 Write tests for all new features and bugfixes
61+
- 🧹 Keep code clean and robust
62+
63+
## 📄 License
64+
MIT
65+
66+
---
3067

31-
## Notes
32-
- Follow DDD and Clean Architecture best practices for all new code.
33-
- See `.github/copilot-instructions.md` for Copilot guidance.
68+
> Because good enough is never enough. 😎

src/McpPack.Api/McpPack.Api.csproj

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<ItemGroup>
44
<ProjectReference Include="..\McpPack.Infrastructure\McpPack.Infrastructure.csproj" />
55
</ItemGroup>
66

77
<ItemGroup>
88
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
9-
<PackageReference Include="ModelContextProtocol" Version="0.2.0-preview.3" />
9+
<PackageReference Include="ModelContextProtocol" Version="0.3.0-preview.1" />
10+
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.1" />
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<Folder Include="Properties/" />
1015
</ItemGroup>
1116

1217
<PropertyGroup>
@@ -15,5 +20,11 @@
1520
<ImplicitUsings>enable</ImplicitUsings>
1621
<Nullable>enable</Nullable>
1722
</PropertyGroup>
18-
23+
24+
<PropertyGroup>
25+
<!-- Enables Native AOT compilation during publish-->
26+
<PublishAot>true</PublishAot>
27+
<!--Favor the size of the executable instead of other performance -->
28+
<OptimizationPreference>Size</OptimizationPreference>
29+
</PropertyGroup>
1930
</Project>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using McpPack.Domain.PackageAggregate;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Text.Json.Serialization;
4+
5+
namespace McpPack.Api;
6+
7+
[ExcludeFromCodeCoverage(
8+
Justification = "This is a generated file for JSON serialization context, no manual changes are expected.")]
9+
[JsonSerializable(typeof(IEnumerable<PackageMetadata>))]
10+
[JsonSerializable(typeof(PackageMetadata))]
11+
public partial class McpPackJsonContext : JsonSerializerContext
12+
{
13+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System.ComponentModel;
2+
using McpPack.Application.RetrievePackage;
3+
using McpPack.Domain.PackageAggregate;
4+
using ModelContextProtocol.Server;
5+
6+
namespace McpPack.Api;
7+
8+
/// <summary>
9+
/// Provides tools for managing NuGet packages.
10+
/// This class is registered as a server tool in the ModelContextProtocol.
11+
/// It allows clients to retrieve package metadata, such as the latest version of a NuGet package.
12+
/// </summary>
13+
/// <remarks>
14+
/// This class is part of the McpPack.Api assembly, which serves as the API layer
15+
/// in the Clean Architecture structure of the McpPack application.
16+
/// It interacts with the Application layer to perform operations related to NuGet packages.
17+
/// The RetrieveNugetPackageVersionAsync method is exposed as a server tool that can be called
18+
/// by clients to retrieve the latest version of a specified NuGet package.
19+
/// </remarks>
20+
/// <example>
21+
/// Example usage:
22+
/// ```csharp
23+
/// var packageTools = new PackageTools(RetrievePackageMetadataUseCase);
24+
/// var latestVersion = await packageTools.RetrieveNugetPackageVersionAsync("Newtonsoft.Json");
25+
/// foreach (var version in latestVersion)
26+
/// {
27+
/// Console.WriteLine($"Package: {version.Id}, Version: {version.Version}");
28+
/// }
29+
/// ```
30+
/// </example>
31+
/// <seealso cref="RetrievePackageMetadataUseCase"/>
32+
[McpServerToolType]
33+
public sealed class NugetPackageTools
34+
{
35+
public const string RetrieveNugetPackageVersion = "RetrieveNugetPackageVersion";
36+
37+
private readonly RetrievePackageMetadataUseCase _retrievePackageMetadataUseCase;
38+
39+
/// <summary>
40+
/// Initializes a new instance of the <see cref="NugetPackageTools"/> class.
41+
/// </summary>
42+
/// <param name="retrievePackageMetadataUseCase">The use case for retrieving package metadata.</param>
43+
public NugetPackageTools(RetrievePackageMetadataUseCase retrievePackageMetadataUseCase)
44+
{
45+
_retrievePackageMetadataUseCase = retrievePackageMetadataUseCase;
46+
}
47+
48+
/// <summary>
49+
/// Gets the latest version of a NuGet package from nuget server.
50+
/// </summary>
51+
[McpServerTool(Name = RetrieveNugetPackageVersion), Description("Retrieve the latest version of a NuGet package from nuget server.")]
52+
public async Task<IEnumerable<PackageMetadata>> RetrieveNugetPackageVersionAsync(
53+
[Description("The NuGet package name")] string packageName)
54+
{
55+
var metadata = await _retrievePackageMetadataUseCase.ExecuteAsync(packageName);
56+
return metadata;
57+
}
58+
}

src/McpPack.Api/Program.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using McpPack.Api;
2+
using McpPack.Application.RetrievePackage;
3+
using McpPack.Infrastructure.DependencyInjection;
4+
5+
var builder = WebApplication.CreateSlimBuilder(args);
6+
builder.Logging.AddConsole(consoleLogOptions =>
7+
{
8+
// Configure all logs to go to stderr
9+
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
10+
});
11+
12+
// Register application use cases
13+
builder.Services.AddTransient<RetrievePackageMetadataUseCase>();
14+
15+
// Register all infrastructure services through Infrastructure layer
16+
// Configure NugetServiceOptions from configuration (appsettings.json or environment variables)
17+
builder.Services.AddInfrastructureServices(builder.Configuration);
18+
19+
builder.Services
20+
.AddMcpServer()
21+
.WithHttpTransport()
22+
.WithTools<NugetPackageTools>(serializerOptions: McpPackJsonContext.Default.Options);
23+
24+
var application = builder.Build();
25+
26+
application.MapMcp();
27+
28+
await application.RunAsync();
29+
30+
public partial class Program
31+
{
32+
// This partial class is used to allow the HostBuilder to be extended in other files if needed.
33+
// It can be useful for adding additional configuration or services in a modular way.
34+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "https://json.schemastore.org/launchsettings.json",
3+
"profiles": {
4+
"McpPack.Api (http)": {
5+
"commandName": "Project",
6+
"environmentVariables": {
7+
"ASPNETCORE_ENVIRONMENT": "Development"
8+
},
9+
"applicationUrl": "http://localhost:5000",
10+
"dotnetRunMessages": true
11+
},
12+
"McpPack.Api (https)": {
13+
"commandName": "Project",
14+
"launchBrowser": true,
15+
"environmentVariables": {
16+
"ASPNETCORE_ENVIRONMENT": "Development"
17+
},
18+
"applicationUrl": "https://localhost:5001;http://localhost:5000",
19+
"dotnetRunMessages": true
20+
},
21+
}
22+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using McpPack.Domain.PackageAggregate;
2+
3+
namespace McpPack.Application.RetrievePackage;
4+
5+
public sealed class RetrievePackageMetadataUseCase
6+
{
7+
private readonly INugetService _nugetService;
8+
9+
public RetrievePackageMetadataUseCase(INugetService nugetService)
10+
{
11+
_nugetService = nugetService ?? throw new ArgumentNullException(nameof(nugetService));
12+
}
13+
14+
public async Task<IReadOnlyList<PackageMetadata>> ExecuteAsync(
15+
string query,
16+
CancellationToken cancellationToken = default)
17+
{
18+
if (string.IsNullOrWhiteSpace(query))
19+
throw new ArgumentException("Query cannot be null or empty", nameof(query));
20+
21+
return await _nugetService.SearchPackagesAsync(query, cancellationToken);
22+
}
23+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Collections;
2+
3+
namespace McpPack.Domain.PackageAggregate;
4+
5+
public sealed class AvailablePackageVersions
6+
{
7+
private readonly IReadOnlyList<PackageVersion> _versions;
8+
9+
private AvailablePackageVersions(IEnumerable<PackageVersion> versions)
10+
{
11+
ArgumentNullException.ThrowIfNull(versions);
12+
var versionList = versions.ToList();
13+
if (versionList.Count == 0)
14+
{
15+
throw new ArgumentException("At least one version must be provided", nameof(versions));
16+
}
17+
18+
_versions = versionList;
19+
}
20+
21+
public static AvailablePackageVersions Create(IEnumerable<PackageVersion> versions) => new(versions);
22+
23+
public bool Any() => _versions.Count > 0;
24+
25+
public PackageVersion GetLatest()
26+
{
27+
return _versions.First();
28+
}
29+
30+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace McpPack.Domain.PackageAggregate;
2+
3+
public interface INugetService
4+
{
5+
Task<IReadOnlyList<PackageMetadata>> SearchPackagesAsync(string query, CancellationToken cancellationToken = default);
6+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace McpPack.Domain.PackageAggregate;
2+
3+
public sealed class PackageMetadata
4+
{
5+
public string Title { get; }
6+
public Uri? ReadmeUri { get; }
7+
public AvailablePackageVersions Versions { get; }
8+
9+
private PackageMetadata(string title, Uri? readmeUri, AvailablePackageVersions versions)
10+
{
11+
if (string.IsNullOrWhiteSpace(title))
12+
{
13+
throw new ArgumentException("Package title cannot be null or empty", nameof(title));
14+
}
15+
16+
Title = title;
17+
ReadmeUri = readmeUri;
18+
Versions = versions ?? throw new ArgumentNullException(nameof(versions));
19+
}
20+
21+
public static PackageMetadata Create(string title, Uri? readmeUri, AvailablePackageVersions versions)
22+
{
23+
return new PackageMetadata(title, readmeUri, versions);
24+
}
25+
26+
public bool HasVersions()
27+
{
28+
return Versions.Any();
29+
}
30+
31+
public PackageVersion? GetLatestVersion()
32+
{
33+
return Versions.GetLatest();
34+
}
35+
}

0 commit comments

Comments
 (0)