using System.Net; using System.Net.Http.Json; using System.Text.Json; using ModelContextProtocol.Protocol; namespace Novelly.Mcp; /// /// Thin wrapper over the Novelly REST API. The MCP server deliberately owns no /// domain logic of its own — it is a second front end onto the same API the web client /// uses, so an edit made from Claude Code and one made in the browser are the same edit. /// public class NovelApiClient(HttpClient http) { private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true }; public Task GetAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Get, path), ct); public Task PostAsync(string path, object body, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body, options: Options) }, ct); public Task PatchAsync(string path, object body, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Patch, path) { Content = JsonContent.Create(body, options: Options) }, ct); public Task DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct); /// /// Sends the request and shapes the outcome as a tool result. Failures come back as /// `isError` results carrying the API's own message, rather than as exceptions the /// SDK would flatten into "an error occurred" — the model can act on the former. /// private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { HttpResponseMessage response; try { response = await http.SendAsync(request, ct); } catch (HttpRequestException ex) { // The API not being up is the most common failure here, and a bare connection // exception tells the model nothing actionable. return Error($"Could not reach the Novelly API at {http.BaseAddress}. Is it running? ({ex.Message})"); } var body = await response.Content.ReadAsStringAsync(ct); if (response.IsSuccessStatusCode) { return Ok(string.IsNullOrWhiteSpace(body) ? "{\"ok\":true}" : Prettify(body)); } var detail = TryReadProblemDetail(body) ?? body; return Error(response.StatusCode switch { HttpStatusCode.NotFound => $"Not found: {detail}", HttpStatusCode.BadRequest => $"Rejected: {detail}", _ => $"API returned {(int)response.StatusCode}: {detail}" }); } private static CallToolResult Ok(string text) => new() { Content = [new TextContentBlock { Text = text }] }; private static CallToolResult Error(string message) => new() { Content = [new TextContentBlock { Text = message }], IsError = true }; /// Reformats the API's compact JSON so tool output reads well in a transcript. private static string Prettify(string json) { try { return JsonSerializer.Serialize(JsonSerializer.Deserialize(json), Options); } catch (JsonException) { return json; } } private static string? TryReadProblemDetail(string body) { try { var problem = JsonSerializer.Deserialize(body); return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null; } catch (JsonException) { return null; } } }