Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# TLDR;

# Summary

# Details
175 changes: 71 additions & 104 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

**The safest way to make REST calls in C#**

**New!** Generate MCP servers from OpenAPI specs!!!

Built from the ground up with functional programming, type safety, and modern .NET patterns. Successor to the [original RestClient.Net](https://www.nuget.org/packages/RestClient.Net.Abstractions).

## What Makes It Different
Expand All @@ -16,6 +18,7 @@ This library is uncompromising in its approach to type safety and functional des

## Features

- **Generate an MCP Server and client code** from an OpenAPI 3.x spec.
- **Result Types** - Returns `Result<TSuccess, HttpError<TError>>` with closed hierarchy types for compile-time safety (Outcome package)
- **Zero Exceptions** - No exception throwing for predictable error handling
- **Progress Reporting** - Built-in download/upload progress tracking
Expand Down Expand Up @@ -108,6 +111,74 @@ global using ExceptionErrorPost = Outcome.HttpError<ErrorResponse>.ExceptionErro

If you use the OpenAPI generator, it will generate these type aliases for you automatically.

## OpenAPI Client and MCP Code Generation

Generate type-safe C# clients and MCP servers from OpenAPI specs.

### Client Generation

```bash
dotnet add package RestClient.Net.OpenApiGenerator
```

Generate extension methods from OpenAPI 3.x specs:

```csharp
// Generated code usage
using YourApi.Generated;

var httpClient = factory.CreateClient();

// All HTTP methods supported with Result types
var user = await httpClient.GetUserById("123", ct);
var created = await httpClient.CreateUser(newUser, ct);
var updated = await httpClient.UpdateUser((Params: "123", Body: user), ct);
var deleted = await httpClient.DeleteUser("123", ct);

// Pattern match on results
switch (user)
{
case OkUser(var success):
Console.WriteLine($"User: {success.Name}");
break;
case ErrorUser(var error):
Console.WriteLine($"Error: {error.StatusCode}");
break;
}
```

The generator creates extension methods on `HttpClient`, model classes from schemas, and result type aliases for pattern matching.

### MCP Server Generation

Generate Model Context Protocol servers for Claude Code from OpenAPI specs:

```bash
# Generate API client first
dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \
-u api.yaml \
-o Generated \
-n YourApi.Generated

# Generate MCP tools from the same spec
dotnet run --project RestClient.Net.McpGenerator.Cli -- \
--openapi-url api.yaml \
--output-file Generated/McpTools.g.cs \
--namespace YourApi.Mcp \
--server-name YourApi \
--ext-namespace YourApi.Generated \
--tags "Search,Resources"
```

The MCP generator wraps the generated extension methods as MCP tools that Claude Code can invoke.

**Complete example:** See `Samples/NucliaDbClient.McpServer` for a working MCP server built from the NucliaDB OpenAPI spec. The example includes:
- Generated client code (`Samples/NucliaDbClient/Generated`)
- Generated MCP tools (`NucliaDbMcpTools.g.cs`)
- MCP server host project (`NucliaDbClient.McpServer`)
- Docker Compose setup for NucliaDB
- Claude Code integration script (`run-for-claude.sh`)

## Exhaustiveness Checking with Exhaustion

**Exhaustion is integral to RestClient.Net's safety guarantees.** It's a Roslyn analyzer that ensures you handle every possible case when pattern matching on Result types.
Expand Down Expand Up @@ -155,110 +226,6 @@ Exhaustion works by analyzing sealed type hierarchies in switch expressions and

If you don't handle all three, your code won't compile.

### OpenAPI Code Generation

Generate type-safe extension methods from OpenAPI specs:

```csharp
using JSONPlaceholder.Generated;

// Get HttpClient from factory
var httpClient = factory.CreateClient();

// GET all todos
var todos = await httpClient.GetTodos(ct);

// GET todo by ID
var todo = await httpClient.GetTodoById(1, ct);
switch (todo)
{
case OkTodo(var success):
Console.WriteLine($"Todo: {success.Title}");
break;
case ErrorTodo(var error):
Console.WriteLine($"Error: {error.StatusCode} - {error.Body}");
break;
}

// POST - create a new todo
var newTodo = new TodoInput { Title = "New Task", UserId = 1, Completed = false };
var created = await httpClient.CreateTodo(newTodo, ct);

// PUT - update with path param and body
var updated = await httpClient.UpdateTodo((Params: 1, Body: newTodo), ct);

// DELETE - returns Unit
var deleted = await httpClient.DeleteTodo(1, ct);
```

```bash
dotnet add package RestClient.Net.OpenApiGenerator
```

Define your schema (OpenAPI 3.x):
```yaml
openapi: 3.0.0
paths:
/users/{id}:
get:
operationId: getUserById
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/users:
post:
operationId: createUser
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
```

The generator creates:
1. **Extension methods** - Strongly-typed methods on `HttpClient`
2. **Model classes** - DTOs from schema definitions
3. **Result type aliases** - Convenient `OkUser` and `ErrorUser` types

Generated usage:
```csharp
// Get HttpClient from factory
var httpClient = factory.CreateClient();

// GET with path parameter
var user = await httpClient.GetUserById("123", ct);

// POST with body
var created = await httpClient.CreateUser(newUser, ct);

// PUT with path param and body
var updated = await httpClient.UpdateUser((Params: "123", Body: user), ct);

// DELETE returns Unit
var deleted = await httpClient.DeleteUser("123", ct);
```

All generated methods:
- Create extension methods on `HttpClient` (use with `IHttpClientFactory.CreateClient()`)
- Return `Result<TSuccess, HttpError<TError>>` for functional error handling
- Bundle URL/body/headers into `HttpRequestParts` via `buildRequest`
- Support progress reporting through `ProgressReportingHttpContent`

### Progress Reporting

You can track upload progress with `ProgressReportingHttpContent`. This example writes to the console when there is a progress report.
Expand Down
32 changes: 29 additions & 3 deletions RestClient.Net.McpGenerator.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ static void PrintUsage()
Console.WriteLine(
" --ext-class <class> Extensions class name (default: 'ApiExtensions')"
);
Console.WriteLine(
" -t, --tags <tag1,tag2> Comma-separated list of OpenAPI tags to include (optional)"
);
Console.WriteLine(" -h, --help Show this help message");
}

Expand All @@ -52,6 +55,7 @@ static void PrintUsage()
var serverName = "ApiMcp";
var extensionsNamespace = "Generated";
var extensionsClass = "ApiExtensions";
string? tagsFilter = null;

for (var i = 0; i < args.Length; i++)
{
Expand Down Expand Up @@ -79,6 +83,10 @@ static void PrintUsage()
case "--ext-class":
extensionsClass = GetNextArg(args, i++, "ext-class") ?? extensionsClass;
break;
case "-t"
or "--tags":
tagsFilter = GetNextArg(args, i++, "tags");
break;
default:
break;
}
Expand All @@ -104,7 +112,8 @@ static void PrintUsage()
namespaceName,
serverName,
extensionsNamespace,
extensionsClass
extensionsClass,
tagsFilter
);
}

Expand Down Expand Up @@ -154,13 +163,29 @@ static async Task GenerateCode(Config config)
}

Console.WriteLine($"Read {openApiSpec.Length} characters\n");

// Parse tags filter if provided
ISet<string>? includeTags = null;
if (!string.IsNullOrWhiteSpace(config.TagsFilter))
{
includeTags = new HashSet<string>(
config.TagsFilter.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
),
StringComparer.OrdinalIgnoreCase
);
Console.WriteLine($"Filtering to tags: {string.Join(", ", includeTags)}");
}

Console.WriteLine("Generating MCP tools code...");

var result = McpServerGenerator.Generate(
openApiSpec,
@namespace: config.Namespace,
serverName: config.ServerName,
extensionsNamespace: config.ExtensionsNamespace
extensionsNamespace: config.ExtensionsNamespace,
includeTags: includeTags
);

#pragma warning disable IDE0010
Expand All @@ -186,5 +211,6 @@ internal sealed record Config(
string Namespace,
string ServerName,
string ExtensionsNamespace,
string ExtensionsClass
string ExtensionsClass,
string? TagsFilter
);
7 changes: 5 additions & 2 deletions RestClient.Net.McpGenerator/McpServerGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ public static class McpServerGenerator
/// <param name="namespace">The namespace for generated MCP tools.</param>
/// <param name="serverName">The MCP server name.</param>
/// <param name="extensionsNamespace">The namespace of the pre-generated extensions.</param>
/// <param name="includeTags">Optional set of tags to include. If specified, only operations with these tags are generated.</param>
/// <returns>A Result containing the generated C# code or error message.</returns>
#pragma warning disable CA1054
public static Result<string, string> Generate(
string openApiContent,
string @namespace,
string serverName,
string extensionsNamespace
string extensionsNamespace,
ISet<string>? includeTags = null
)
#pragma warning restore CA1054
{
Expand All @@ -43,7 +45,8 @@ string extensionsNamespace
document,
@namespace,
serverName,
extensionsNamespace
extensionsNamespace,
includeTags
)
);
}
Expand Down
33 changes: 28 additions & 5 deletions RestClient.Net.McpGenerator/McpToolGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ internal static class McpToolGenerator
/// <param name="namespace">The namespace for the MCP server.</param>
/// <param name="serverName">The MCP server name.</param>
/// <param name="extensionsNamespace">The namespace of the extensions.</param>
/// <param name="includeTags">Optional set of tags to filter operations. If specified, only operations with these tags are generated.</param>
/// <returns>The generated MCP tools code.</returns>
public static string GenerateTools(
OpenApiDocument document,
string @namespace,
string serverName,
string extensionsNamespace
string extensionsNamespace,
ISet<string>? includeTags = null
)
{
var tools = new List<string>();
Expand All @@ -38,6 +40,21 @@ string extensionsNamespace

foreach (var operation in path.Value.Operations)
{
// Skip if tags filter is specified and operation doesn't match
if (includeTags != null && includeTags.Count > 0)
{
var operationTags = operation.Value.Tags;
if (
operationTags == null
|| !operationTags.Any(tag =>
includeTags.Contains(tag.Name, StringComparer.OrdinalIgnoreCase)
)
)
{
continue;
}
}

var toolMethod = GenerateTool(
path.Key,
operation.Key,
Expand All @@ -61,11 +78,13 @@ string extensionsNamespace
using System.Text.Json;
using Outcome;
using {{extensionsNamespace}};
using ModelContextProtocol.Server;

namespace {{@namespace}};

/// <summary>MCP server tools for {{serverName}} API.</summary>
public class {{serverName}}Tools(IHttpClientFactory httpClientFactory)
[McpServerToolType]
public static class {{serverName}}Tools
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Expand Down Expand Up @@ -208,13 +227,17 @@ string errorType
var okAlias = $"Ok{responseType}";
var errorAlias = $"Error{responseType}";

var httpClientParam =
methodParamsStr.Length > 0 ? "HttpClient httpClient, " : "HttpClient httpClient";
var allParams = httpClientParam + methodParamsStr;

return $$"""
/// <summary>{{SanitizeDescription(summary)}}</summary>
{{paramDescriptions}}
[Description("{{SanitizeDescription(summary)}}")]
public async Task<string> {{toolName}}({{methodParamsStr}})
/// <param name="httpClient">HttpClient instance</param>
[McpServerTool, Description("{{SanitizeDescription(summary)}}")]
public static async Task<string> {{toolName}}({{allParams}})
{
var httpClient = httpClientFactory.CreateClient();
var result = await httpClient.{{extensionMethodName}}({{extensionCallArgsStr}});

return result switch
Expand Down
Loading