Skip to content

Commit a153367

Browse files
Fix Codegen and Add MCPCode Generation (#140)
# TLDR; New code generator that creates MCP server tools from OpenAPI specs, plus comprehensive NucliaDB sample implementation demonstrating the full workflow from OpenAPI → RestClient.Net extensions → MCP tools. # Summary This PR adds **RestClient.Net.McpGenerator**, a code generator that transforms OpenAPI specifications into Model Context Protocol (MCP) server tools. The generator builds on top of the existing OpenAPI → RestClient.Net extension methods, creating type-safe MCP tools that expose REST APIs to Claude and other LLM tools. Includes a complete reference implementation using the NucliaDB API with tests, demo, and working MCP server. # Details **New Components (36 new files, 64 total changed)** **MCP Generator** (`RestClient.Net.McpGenerator`) - `McpToolGenerator.cs` - Generates MCP tool classes wrapping RestClient.Net extensions - `McpServerGenerator.cs` - Orchestrates OpenAPI → MCP tool code generation - CLI tool for command-line generation **NucliaDB Sample Suite** (`Samples/NucliaDbClient*`) - Generated client code from NucliaDB OpenAPI spec (~5,800 LOC) - Comprehensive integration tests with Docker-based NucliaDB instance - Demo console application showing usage patterns - Working MCP server implementation with setup documentation **Core Library Enhancements** - Added OPTIONS HTTP method support (`CreateOptions` delegates) - Enhanced test coverage for all HTTP methods including cancellation scenarios **Infrastructure** - CI/CD: Added Docker verification and container cleanup to PR builds - Test ordering: Priority-based test execution for integration tests - Code quality: Relaxed StyleCop ordering rules, promoted EXHAUSTION001 to error **Key Features** - Generates type-safe MCP tools with proper parameter descriptions - Integrates with `IHttpClientFactory` pattern (no socket exhaustion) - Functional composition using Result types for error handling - Full end-to-end example: OpenAPI → Extensions → MCP Tools → Working Server
1 parent 1eadf33 commit a153367

File tree

65 files changed

+11491
-871
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+11491
-871
lines changed

.config/dotnet-tools.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
"version": 1,
33
"isRoot": true,
44
"tools": {
5+
"csharpier": {
6+
"version": "0.30.2",
7+
"commands": [
8+
"dotnet-csharpier"
9+
],
10+
"rollForward": false
11+
},
512
"fantomas": {
613
"version": "6.3.15",
714
"commands": [

.editorconfig

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ dotnet_analyzer_diagnostic.category-Globalization.severity = error
1212
dotnet_analyzer_diagnostic.category-Documentation.severity = error
1313
dotnet_analyzer_diagnostic.category-Readability.severity = error
1414
dotnet_analyzer_diagnostic.category-Ordering.severity = error
15-
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = none
1615

1716
# Nullability
1817
# CS8602: Dereference of a possibly null reference.
@@ -41,7 +40,6 @@ dotnet_diagnostic.CA2000.severity = error
4140
dotnet_diagnostic.CA2201.severity = error
4241
dotnet_diagnostic.CS1591.severity = error
4342
dotnet_diagnostic.IDE0022.severity = error
44-
dotnet_diagnostic.CA1054.severity = error
4543
dotnet_diagnostic.CS8600.severity = error
4644
dotnet_diagnostic.CS8601.severity = error
4745
dotnet_diagnostic.CS8603.severity = error
@@ -373,9 +371,15 @@ dotnet_diagnostic.SA1400.severity = none
373371
dotnet_diagnostic.SA1114.severity = none
374372
dotnet_diagnostic.SA1118.severity = none
375373
dotnet_diagnostic.SA1649.severity = none
374+
dotnet_diagnostic.CA1054.severity = none
376375

376+
dotnet_diagnostic.SA1200.severity = none
377377

378378

379+
# TODO: these are nice. Put back
380+
dotnet_diagnostic.SA1201.severity = none
381+
dotnet_diagnostic.SA1202.severity = none
382+
dotnet_diagnostic.SA1204.severity = none
379383

380384

381385

.github/workflows/pr-build.yml

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,36 @@ jobs:
2020
8.0.x
2121
9.0.x
2222
23+
- name: Restore .NET tools
24+
run: dotnet tool restore
25+
2326
- name: Restore dependencies
2427
run: dotnet restore RestClient.sln
2528

2629
- name: Check code formatting with CSharpier
27-
run: |
28-
dotnet tool install --global csharpier
29-
dotnet csharpier --check .
30+
run: dotnet csharpier --check .
3031

3132
- name: Build solution
3233
run: dotnet build RestClient.sln --configuration Release --no-restore /warnaserror
3334

35+
- name: Run code analysis
36+
run: dotnet build RestClient.sln --configuration Release --no-restore /p:RunAnalyzers=true /p:TreatWarningsAsErrors=true
37+
38+
- name: Run Stryker Mutation Testing
39+
working-directory: RestClient.Net.CsTest
40+
run: dotnet stryker --break-at 100
41+
42+
- name: Verify Docker is available
43+
run: |
44+
docker --version
45+
docker compose version
46+
3447
- name: Run all tests with code coverage
3548
run: dotnet test RestClient.sln --configuration Release --no-build --verbosity normal --logger "console;verbosity=detailed" --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Threshold=100 DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ThresholdType=line,branch,method DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ThresholdStat=total
3649

37-
- name: Install Stryker Mutator
38-
run: dotnet tool install --global dotnet-stryker
39-
40-
- name: Run Stryker Mutation Testing
41-
run: dotnet stryker --break-at 100 --reporter "console" --reporter "html"
50+
- name: Cleanup Docker containers
51+
if: always()
52+
run: |
53+
cd Samples/NucliaDbClient
54+
docker compose down -v --remove-orphans || true
4255
43-
- name: Run code analysis
44-
run: dotnet build RestClient.sln --configuration Release --no-restore /p:RunAnalyzers=true /p:TreatWarningsAsErrors=true

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"-u",
3434
"${workspaceFolder}/Samples/NucliaDbClient/api.yaml",
3535
"-o",
36-
"${workspaceFolder}/Samples/RestClient.OpenApiGenerator.Sample.NucliaDB/Generated",
36+
"${workspaceFolder}/Samples/NucliaDbClient/Generated",
3737
"-n",
3838
"NucliaDB.Generated",
3939
"-c",

CLAUDE.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Code Rules
66

7-
- NO DUPLICATION - EVER!!!!
8-
- Reduce the AMOUNT of code wherever possible
7+
- NO DUPLICATION - EVER!!!! REMOVING DUPLICATION is the absolute HIGHEST PRIORITY!!!
8+
- YOU ARE NOT ALLOWED TO SKIP TESTS
99
- No throwing exceptions, except for in tests
1010
- FP style code. Pure functions with immutable types.
11-
- 100% test coverage everywhere
12-
- Don't use Git unless I explicitly ask you to
1311
- Keep functions under 20 LOC
1412
- Keep files under 300 LOC
15-
- Use StyleCop.Analyzers and Microsoft.CodeAnalysis.NetAnalyzers for code quality
16-
- Ccode analysis warnings are always errors
17-
- Nullable reference types are enabled
1813
- NEVER copy files. Only MOVE files
14+
- Don't use Git unless I explicitly ask you to
15+
- Promote code analysis warnings to errors
16+
- EXHAUSTION001 is a critical error and must be turned on everywhere
17+
- Nullable reference types are enabled and MUST be obeyed
18+
- Do not back files up
19+
- Aggressively pursue these aims, even when it means taking more time on a task
1920

2021
## Build, Test, and Development Commands
2122

RestClient.Net.CsTest/HttpClientFactoryExtensionsTests.cs

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,4 +1686,216 @@ public async Task CreatePatch_CancellationToken_CancelsRequest()
16861686

16871687
Assert.IsInstanceOfType<TaskCanceledException>(exception);
16881688
}
1689+
1690+
[TestMethod]
1691+
public async Task CreateHead_ReturnsSuccessResult()
1692+
{
1693+
// Arrange
1694+
var expectedContent = "Head Success";
1695+
using var response = new HttpResponseMessage(HttpStatusCode.OK)
1696+
{
1697+
Content = new StringContent(expectedContent),
1698+
};
1699+
using var httpClient = CreateMockHttpClientFactory(response: response).CreateClient();
1700+
1701+
var head = CreateHead<string, MyErrorModel, int>(
1702+
url: "http://test.com".ToAbsoluteUrl(),
1703+
buildRequest: id => new HttpRequestParts(
1704+
RelativeUrl: new RelativeUrl($"/items/{id}"),
1705+
Body: null,
1706+
Headers: null
1707+
),
1708+
deserializeSuccess: TestDeserializer.Deserialize<string>,
1709+
deserializeError: TestDeserializer.Deserialize<MyErrorModel>
1710+
);
1711+
1712+
// Act
1713+
var result = await head(httpClient, 123).ConfigureAwait(false);
1714+
1715+
// Assert
1716+
var successValue = +result;
1717+
Assert.AreEqual(expectedContent, successValue);
1718+
}
1719+
1720+
[TestMethod]
1721+
public async Task CreateHead_ErrorResponse_ReturnsFailureResult()
1722+
{
1723+
// Arrange
1724+
var expectedErrorContent = "Head Failed";
1725+
using var errorResponse = new HttpResponseMessage(HttpStatusCode.BadRequest)
1726+
{
1727+
Content = new StringContent( /*lang=json,strict*/
1728+
"{\"message\":\"Head Failed\"}"
1729+
),
1730+
};
1731+
using var httpClient = CreateMockHttpClientFactory(response: errorResponse).CreateClient();
1732+
1733+
var head = CreateHead<string, MyErrorModel, int>(
1734+
url: "http://test.com".ToAbsoluteUrl(),
1735+
buildRequest: id => new HttpRequestParts(
1736+
RelativeUrl: new RelativeUrl($"/items/{id}"),
1737+
Body: null,
1738+
Headers: null
1739+
),
1740+
deserializeSuccess: TestDeserializer.Deserialize<string>,
1741+
deserializeError: TestDeserializer.Deserialize<MyErrorModel>
1742+
);
1743+
1744+
// Act
1745+
var result = await head(httpClient, 123).ConfigureAwait(false);
1746+
1747+
// Assert
1748+
var httpError = !result;
1749+
if (httpError is not ResponseError(var body, var statusCode, var headers))
1750+
{
1751+
throw new InvalidOperationException("Expected error response");
1752+
}
1753+
1754+
Assert.AreEqual(HttpStatusCode.BadRequest, statusCode);
1755+
Assert.AreEqual(expectedErrorContent, body.Message);
1756+
}
1757+
1758+
[TestMethod]
1759+
public async Task CreateHead_CancellationToken_CancelsRequest()
1760+
{
1761+
// Arrange
1762+
using var cts = new CancellationTokenSource();
1763+
using var httpClient = CreateMockHttpClientFactory(
1764+
exceptionToThrow: new TaskCanceledException()
1765+
)
1766+
.CreateClient();
1767+
1768+
var head = CreateHead<string, MyErrorModel, int>(
1769+
url: "http://test.com".ToAbsoluteUrl(),
1770+
buildRequest: id => new HttpRequestParts(
1771+
RelativeUrl: new RelativeUrl($"/items/{id}"),
1772+
Body: null,
1773+
Headers: null
1774+
),
1775+
deserializeSuccess: TestDeserializer.Deserialize<string>,
1776+
deserializeError: TestDeserializer.Deserialize<MyErrorModel>
1777+
);
1778+
1779+
await cts.CancelAsync().ConfigureAwait(false);
1780+
1781+
// Act
1782+
var result = await head(httpClient, 123, cts.Token).ConfigureAwait(false);
1783+
1784+
// Assert
1785+
var exception = !result switch
1786+
{
1787+
ExceptionError(var ex) => ex,
1788+
ResponseError(var b, var sc, var h) => throw new InvalidOperationException(
1789+
"Expected exception error"
1790+
),
1791+
};
1792+
1793+
Assert.IsInstanceOfType<TaskCanceledException>(exception);
1794+
}
1795+
1796+
[TestMethod]
1797+
public async Task CreateOptions_ReturnsSuccessResult()
1798+
{
1799+
// Arrange
1800+
var expectedContent = "Options Success";
1801+
using var response = new HttpResponseMessage(HttpStatusCode.OK)
1802+
{
1803+
Content = new StringContent(expectedContent),
1804+
};
1805+
using var httpClient = CreateMockHttpClientFactory(response: response).CreateClient();
1806+
1807+
var options = CreateOptions<string, MyErrorModel, int>(
1808+
url: "http://test.com".ToAbsoluteUrl(),
1809+
buildRequest: id => new HttpRequestParts(
1810+
RelativeUrl: new RelativeUrl($"/items/{id}"),
1811+
Body: null,
1812+
Headers: null
1813+
),
1814+
deserializeSuccess: TestDeserializer.Deserialize<string>,
1815+
deserializeError: TestDeserializer.Deserialize<MyErrorModel>
1816+
);
1817+
1818+
// Act
1819+
var result = await options(httpClient, 123).ConfigureAwait(false);
1820+
1821+
// Assert
1822+
var successValue = +result;
1823+
Assert.AreEqual(expectedContent, successValue);
1824+
}
1825+
1826+
[TestMethod]
1827+
public async Task CreateOptions_ErrorResponse_ReturnsFailureResult()
1828+
{
1829+
// Arrange
1830+
var expectedErrorContent = "Options Failed";
1831+
using var errorResponse = new HttpResponseMessage(HttpStatusCode.BadRequest)
1832+
{
1833+
Content = new StringContent( /*lang=json,strict*/
1834+
"{\"message\":\"Options Failed\"}"
1835+
),
1836+
};
1837+
using var httpClient = CreateMockHttpClientFactory(response: errorResponse).CreateClient();
1838+
1839+
var options = CreateOptions<string, MyErrorModel, int>(
1840+
url: "http://test.com".ToAbsoluteUrl(),
1841+
buildRequest: id => new HttpRequestParts(
1842+
RelativeUrl: new RelativeUrl($"/items/{id}"),
1843+
Body: null,
1844+
Headers: null
1845+
),
1846+
deserializeSuccess: TestDeserializer.Deserialize<string>,
1847+
deserializeError: TestDeserializer.Deserialize<MyErrorModel>
1848+
);
1849+
1850+
// Act
1851+
var result = await options(httpClient, 123).ConfigureAwait(false);
1852+
1853+
// Assert
1854+
var httpError = !result;
1855+
if (httpError is not ResponseError(var body, var statusCode, var headers))
1856+
{
1857+
throw new InvalidOperationException("Expected error response");
1858+
}
1859+
1860+
Assert.AreEqual(HttpStatusCode.BadRequest, statusCode);
1861+
Assert.AreEqual(expectedErrorContent, body.Message);
1862+
}
1863+
1864+
[TestMethod]
1865+
public async Task CreateOptions_CancellationToken_CancelsRequest()
1866+
{
1867+
// Arrange
1868+
using var cts = new CancellationTokenSource();
1869+
using var httpClient = CreateMockHttpClientFactory(
1870+
exceptionToThrow: new TaskCanceledException()
1871+
)
1872+
.CreateClient();
1873+
1874+
var options = CreateOptions<string, MyErrorModel, int>(
1875+
url: "http://test.com".ToAbsoluteUrl(),
1876+
buildRequest: id => new HttpRequestParts(
1877+
RelativeUrl: new RelativeUrl($"/items/{id}"),
1878+
Body: null,
1879+
Headers: null
1880+
),
1881+
deserializeSuccess: TestDeserializer.Deserialize<string>,
1882+
deserializeError: TestDeserializer.Deserialize<MyErrorModel>
1883+
);
1884+
1885+
await cts.CancelAsync().ConfigureAwait(false);
1886+
1887+
// Act
1888+
var result = await options(httpClient, 123, cts.Token).ConfigureAwait(false);
1889+
1890+
// Assert
1891+
var exception = !result switch
1892+
{
1893+
ExceptionError(var ex) => ex,
1894+
ResponseError(var b, var sc, var h) => throw new InvalidOperationException(
1895+
"Expected exception error"
1896+
),
1897+
};
1898+
1899+
Assert.IsInstanceOfType<TaskCanceledException>(exception);
1900+
}
16891901
}

RestClient.Net.CsTest/Models/User.cs

Lines changed: 0 additions & 10 deletions
This file was deleted.

0 commit comments

Comments
 (0)