From 2d6bb9fc579767e290c5d75bb1f1a5fcaafac28d Mon Sep 17 00:00:00 2001 From: James Wampler Date: Fri, 21 Aug 2026 10:59:52 -0700 Subject: [PATCH] Delete the stdio MCP server now that the API serves /mcp directly src/Novelly.Mcp was a separate stdio process, unbuilt by CI, that looped back over HTTP to the same REST API the previous commit's /mcp endpoint now calls in-process. Nothing else referenced it (not CI, not Docker, not the AppHost), so removal is just the project, its solution entry, and scripts/publish-mcp.sh. Updates .mcp.json / .mcp.json.example to the type: http form, fixes .claude/agents/outline-importer.md's already-stale tool references (list_projects/create_project/etc. never existed; the real names are list_novels/create_novel/etc.), and rewrites README + CLAUDE.md's description of the MCP surface and how to verify it at runtime. --- scripts/publish-mcp.sh | 15 -- src/Novelly.Mcp/NovelApiClient.cs | 101 ---------- src/Novelly.Mcp/Novelly.Mcp.csproj | 17 -- src/Novelly.Mcp/Program.cs | 31 ---- src/Novelly.Mcp/Tools/BeatTools.cs | 92 --------- src/Novelly.Mcp/Tools/CharacterTools.cs | 226 ----------------------- src/Novelly.Mcp/Tools/LocationTools.cs | 54 ------ src/Novelly.Mcp/Tools/ManuscriptTools.cs | 75 -------- src/Novelly.Mcp/Tools/NovelTools.cs | 54 ------ src/Novelly.Mcp/Tools/QuestionTools.cs | 92 --------- src/Novelly.Mcp/Tools/TagTools.cs | 56 ------ 11 files changed, 813 deletions(-) delete mode 100755 scripts/publish-mcp.sh delete mode 100644 src/Novelly.Mcp/NovelApiClient.cs delete mode 100644 src/Novelly.Mcp/Novelly.Mcp.csproj delete mode 100644 src/Novelly.Mcp/Program.cs delete mode 100644 src/Novelly.Mcp/Tools/BeatTools.cs delete mode 100644 src/Novelly.Mcp/Tools/CharacterTools.cs delete mode 100644 src/Novelly.Mcp/Tools/LocationTools.cs delete mode 100644 src/Novelly.Mcp/Tools/ManuscriptTools.cs delete mode 100644 src/Novelly.Mcp/Tools/NovelTools.cs delete mode 100644 src/Novelly.Mcp/Tools/QuestionTools.cs delete mode 100644 src/Novelly.Mcp/Tools/TagTools.cs diff --git a/scripts/publish-mcp.sh b/scripts/publish-mcp.sh deleted file mode 100755 index 695be23..0000000 --- a/scripts/publish-mcp.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# Rebuilds the standalone Novelly.Mcp binary that Claude Code (or Claude Desktop) spawns -# per .mcp.json. Aspire does not run or manage this process, so nothing else rebuilds it — -# run this after pulling changes that touch src/Novelly.Mcp, or the MCP server silently -# keeps serving whatever was published last. -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")/.." && source ./scripts/ci/lib.sh -cd "$CI_ROOT" - -ensure_dotnet - -log "Publishing Novelly.Mcp to ./mcp-server" -dotnet publish src/Novelly.Mcp -c Release -o ./mcp-server - -log "Done. Reconnect the MCP server (e.g. /mcp in Claude Code) to pick up the new build." diff --git a/src/Novelly.Mcp/NovelApiClient.cs b/src/Novelly.Mcp/NovelApiClient.cs deleted file mode 100644 index 2f45e01..0000000 --- a/src/Novelly.Mcp/NovelApiClient.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Protocol; - -namespace Novelly.Mcp; - -public class NovelApiClient(HttpClient http, ILogger logger) -{ - 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 PutAsync(string path, object body, CancellationToken ct = default) => - SendAsync(new HttpRequestMessage(HttpMethod.Put, path) - { - Content = JsonContent.Create(body, options: Options) - }, ct); - - public Task DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct); - - private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) - { - HttpResponseMessage response; - try - { - response = await http.SendAsync(request, ct); - } - catch (HttpRequestException ex) - { - logger.LogError(ex, "Could not reach the Novelly API at {BaseAddress}", http.BaseAddress); - 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.Unauthorized => $"Not permitted: the Novelly API rejected the service api key. Set NOVELLY_API_KEY to match the API's Auth:ServiceApiKey. ({detail})", - HttpStatusCode.Forbidden => $"Not permitted: {detail}", - 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 }; - - private string Prettify(string json) - { - try - { - return JsonSerializer.Serialize(JsonSerializer.Deserialize(json), Options); - } - catch (JsonException ex) - { - logger.LogWarning(ex, "Response body was not valid JSON; returning it unformatted"); - return json; - } - } - - private string? TryReadProblemDetail(string body) - { - try - { - var problem = JsonSerializer.Deserialize(body); - return problem.TryGetProperty("detail", out var detail) ? detail.GetString() : null; - } - catch (JsonException ex) - { - logger.LogWarning(ex, "Error response body was not valid JSON problem details"); - return null; - } - } -} diff --git a/src/Novelly.Mcp/Novelly.Mcp.csproj b/src/Novelly.Mcp/Novelly.Mcp.csproj deleted file mode 100644 index dbb9fdd..0000000 --- a/src/Novelly.Mcp/Novelly.Mcp.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - Exe - net10.0 - enable - enable - latest - - - - - - - - - diff --git a/src/Novelly.Mcp/Program.cs b/src/Novelly.Mcp/Program.cs deleted file mode 100644 index becd712..0000000 --- a/src/Novelly.Mcp/Program.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Novelly.Mcp; - -var builder = Host.CreateApplicationBuilder(args); - -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace); -builder.Logging.SetMinimumLevel(LogLevel.Warning); - -var apiBaseUrl = builder.Configuration["NOVELLY_API_URL"] ?? "http://localhost:5080"; -var apiKey = builder.Configuration["NOVELLY_API_KEY"]; - -builder.Services.AddHttpClient(client => -{ - client.BaseAddress = new Uri(apiBaseUrl); - client.Timeout = TimeSpan.FromSeconds(30); - - if (!string.IsNullOrWhiteSpace(apiKey)) - { - client.DefaultRequestHeaders.Add("X-Novelly-Api-Key", apiKey); - } -}); - -builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(); - -await builder.Build().RunAsync(); diff --git a/src/Novelly.Mcp/Tools/BeatTools.cs b/src/Novelly.Mcp/Tools/BeatTools.cs deleted file mode 100644 index ad67317..0000000 --- a/src/Novelly.Mcp/Tools/BeatTools.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class BeatTools -{ - [McpServerTool(Name = "get_chapter_outline")] - [Description("Read a chapter's outline: its beats in order. Each beat is one row — a short " - + "title, who it belongs to, what happened, and what it sets up. The chapter's " - + "summary paragraph and drafted prose sit on the chapter itself, via get_chapter.")] - public static Task GetChapterOutline( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - CancellationToken ct) => - api.GetAsync($"/api/chapters/{chapterId}/beats", ct); - - [McpServerTool(Name = "create_beat")] - [Description("Add a beat to a chapter's outline. Keep the title to three to five words — it " - + "is a handle, not a sentence; detail belongs in whatHappened and whatsNext.")] - public static Task CreateBeat( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - [Description("Three to five words naming the beat.")] string title, - CancellationToken ct, - [Description("Position in the chapter. Appended to the end when omitted.")] int? sortOrder = null, - [Description("Ids of the characters whose beat this is.")] Guid[]? characterIds = null, - [Description("The event itself.")] string? whatHappened = null, - [Description("What it sets in motion — the hook into the next beat.")] string? whatsNext = null, - [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => - api.PostAsync($"/api/chapters/{chapterId}/beats", - new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct); - - [McpServerTool(Name = "update_beat")] - [Description("Revise a beat. Only the fields you supply change. Supplying a characterIds or " - + "tag list replaces the beat's characters or tags outright — pass an empty list " - + "to clear one, and include everything you want to keep.")] - public static Task UpdateBeat( - NovelApiClient api, - [Description("The beat's id.")] Guid beatId, - CancellationToken ct, - [Description("Three to five words naming the beat.")] string? title = null, - [Description("Position in the chapter.")] int? sortOrder = null, - [Description("Ids of the characters whose beat this is. Replaces the existing list.")] Guid[]? characterIds = null, - [Description("The event itself.")] string? whatHappened = null, - [Description("What it sets in motion.")] string? whatsNext = null, - [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => - api.PatchAsync($"/api/beats/{beatId}", - new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct); - - [McpServerTool(Name = "delete_beat")] - [Description("Remove a beat from a chapter's outline. Confirm with the writer first.")] - public static Task DeleteBeat( - NovelApiClient api, - [Description("The beat's id.")] Guid beatId, - CancellationToken ct) => - api.DeleteAsync($"/api/beats/{beatId}", ct); - - [McpServerTool(Name = "assign_character_to_beats")] - [Description("Add a character to several beats at once. Leaves each beat's existing characters " - + "and other fields alone — this only adds, it never removes.")] - public static Task AssignCharacterToBeats( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - [Description("Id of the character to add.")] Guid characterId, - [Description("Ids of the beats to add the character to.")] Guid[] beatIds, - CancellationToken ct) => - api.PostAsync($"/api/chapters/{chapterId}/beats/assign-character", new { characterId, beatIds }, ct); - - [McpServerTool(Name = "reorder_beats")] - [Description("Renumber a chapter's beats to match the order given. List every beat id in the " - + "order wanted; any left out keep their relative position at the end.")] - public static Task ReorderBeats( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - [Description("Beat ids in their new order.")] Guid[] beatIds, - CancellationToken ct) => - api.PostAsync($"/api/chapters/{chapterId}/beats/reorder", new { beatIds }, ct); - - [McpServerTool(Name = "move_beats")] - [Description("Move one or more beats from one chapter to another, appending them to the " - + "target chapter's end in the order given.")] - public static Task MoveBeats( - NovelApiClient api, - [Description("The beats' current chapter id.")] Guid chapterId, - [Description("Id of the chapter to move the beats into.")] Guid targetChapterId, - [Description("Ids of the beats to move.")] Guid[] beatIds, - CancellationToken ct) => - api.PostAsync($"/api/chapters/{chapterId}/beats/move", new { targetChapterId, beatIds }, ct); -} diff --git a/src/Novelly.Mcp/Tools/CharacterTools.cs b/src/Novelly.Mcp/Tools/CharacterTools.cs deleted file mode 100644 index f89c51b..0000000 --- a/src/Novelly.Mcp/Tools/CharacterTools.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class CharacterTools -{ - [McpServerTool(Name = "list_characters")] - [Description("List a novel's character dossiers in full, including their relationships.")] - public static Task ListCharacters( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct) => - api.GetAsync($"/api/novels/{novelId}/characters", ct); - - [McpServerTool(Name = "get_character")] - [Description("Read one character's dossier.")] - public static Task GetCharacter( - NovelApiClient api, - [Description("The character's id.")] Guid characterId, - CancellationToken ct) => - api.GetAsync($"/api/characters/{characterId}", ct); - - [McpServerTool(Name = "create_character")] - [Description("Add a character dossier to a novel. Name is the only requirement — leave a field " - + "blank when the writer has not decided it yet rather than inventing detail.")] - public static Task CreateCharacter( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - [Description("The character's name.")] string name, - CancellationToken ct, - [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] - string? role = null, - [Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")] - string? importance = null, - [Description("Age, exact or approximate.")] string? age = null, - [Description("The pronouns this character uses.")] string? pronouns = null, - [Description("What they do.")] string? occupation = null, - [Description("How they look.")] string? appearance = null, - [Description("Temperament, habits, how they treat people.")] string? personality = null, - [Description("History that shapes who they are now.")] string? backstory = null, - [Description("What they consciously pursue, weighed against what they actually need.")] string? motivation = null, - [Description("The war inside them and what in the world opposes them.")] string? conflict = null, - [Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null, - [Description("Anything else worth recording.")] string? notes = null, - [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null, - [Description("Other names this character is known by.")] string[]? aliases = null) => - api.PostAsync($"/api/novels/{novelId}/characters", new - { - name, - role = role ?? "Supporting", - importance = importance ?? "Supporting", - age, - pronouns, - occupation, - appearance, - personality, - backstory, - motivation, - conflict, - voice, - notes, - tags, - aliases - }, ct); - - [McpServerTool(Name = "update_character")] - [Description("Revise an existing character dossier. Only the fields you supply change.")] - public static Task UpdateCharacter( - NovelApiClient api, - [Description("The character's id.")] Guid characterId, - CancellationToken ct, - [Description("New name.")] string? name = null, - [Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")] - string? role = null, - [Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")] - string? importance = null, - [Description("Age, exact or approximate.")] string? age = null, - [Description("The pronouns this character uses.")] string? pronouns = null, - [Description("What they do.")] string? occupation = null, - [Description("How they look.")] string? appearance = null, - [Description("Temperament, habits, how they treat people.")] string? personality = null, - [Description("History that shapes who they are now.")] string? backstory = null, - [Description("What they consciously pursue, weighed against what they actually need.")] string? motivation = null, - [Description("The war inside them and what in the world opposes them.")] string? conflict = null, - [Description("Speech patterns and register.")] string? voice = null, - [Description("Anything else worth recording.")] string? notes = null, - [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null, - [Description("Other names this character is known by. Replaces the existing aliases.")] string[]? aliases = null) => - api.PatchAsync($"/api/characters/{characterId}", new - { - name, - role, - importance, - age, - pronouns, - occupation, - appearance, - personality, - backstory, - motivation, - conflict, - voice, - notes, - tags, - aliases - }, ct); - - [McpServerTool(Name = "get_character_beats")] - [Description("Every beat this character appears in, across the whole book, in manuscript order. " - + "This is what the character actually does on the page, as opposed to what the " - + "dossier claims about them — read it before revising a character.")] - public static Task GetCharacterBeats( - NovelApiClient api, - [Description("The character's id.")] Guid characterId, - CancellationToken ct) => - api.GetAsync($"/api/characters/{characterId}/beats", ct); - - [McpServerTool(Name = "get_character_arc")] - [Description("Read a main character's arc: the ordered stages of how they change, each " - + "optionally pinned to the chapter where it lands.")] - public static Task GetCharacterArc( - NovelApiClient api, - [Description("The character's id.")] Guid characterId, - CancellationToken ct) => - api.GetAsync($"/api/characters/{characterId}/arc", ct); - - [McpServerTool(Name = "add_arc_stage")] - [Description("Add a stage to a character's arc. Arcs are kept for main characters — promote " - + "the character with update_character first if they are still Supporting.")] - public static Task AddArcStage( - NovelApiClient api, - [Description("Id of the character whose arc to add to.")] Guid characterId, - [Description("A short handle for the change, three to five words.")] string title, - CancellationToken ct, - [Description("What this stage of the arc results in for the character — what shifts, and what it costs them.")] string? result = null, - [Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null, - [Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) => - api.PostAsync($"/api/characters/{characterId}/arc", - new { title, sortOrder, result, chapterId }, ct); - - [McpServerTool(Name = "update_arc_stage")] - [Description("Revise a stage of a character's arc. Only the fields you supply change.")] - public static Task UpdateArcStage( - NovelApiClient api, - [Description("The arc stage's id.")] Guid arcStageId, - CancellationToken ct, - [Description("New title for the stage.")] string? title = null, - [Description("What this stage of the arc results in for the character.")] string? result = null, - [Description("Id of the chapter where this stage lands.")] Guid? chapterId = null, - [Description("Position in the arc.")] int? sortOrder = null) => - api.PatchAsync($"/api/arc-stages/{arcStageId}", - new { title, sortOrder, result, chapterId }, ct); - - [McpServerTool(Name = "delete_arc_stage")] - [Description("Remove a stage from a character's arc.")] - public static Task DeleteArcStage( - NovelApiClient api, - [Description("The arc stage's id.")] Guid arcStageId, - CancellationToken ct) => - api.DeleteAsync($"/api/arc-stages/{arcStageId}", ct); - - [McpServerTool(Name = "reorder_arc_stages")] - [Description("Renumber a character's arc to match the order given. Stages left out keep their " - + "relative position after the ones listed.")] - public static Task ReorderArcStages( - NovelApiClient api, - [Description("Id of the character whose arc to reorder.")] Guid characterId, - [Description("Arc stage ids in the order wanted.")] string[] stageIds, - CancellationToken ct) => - api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct); - - [McpServerTool(Name = "set_arc_stage_beats")] - [Description("Set which beats belong to an arc stage, replacing its current set. This groups the " - + "chapter-level beats that establish or pay off this stage of the character's arc. A " - + "beat moved into this stage leaves any other stage of the same character it was in. " - + "Each beat must already include this character.")] - public static Task SetArcStageBeats( - NovelApiClient api, - [Description("The arc stage's id.")] Guid arcStageId, - [Description("Beat ids that belong to this stage, replacing whatever was there before.")] string[] beatIds, - CancellationToken ct) => - api.PostAsync($"/api/arc-stages/{arcStageId}/beats", new { beatIds }, ct); - - [McpServerTool(Name = "relate_characters")] - [Description("Record a relationship between two characters in the same novel. Creates both directions " - + "at once — characterId's side and relatedCharacterId's side — so the pair always shows up " - + "on both dossiers.")] - public static Task RelateCharacters( - NovelApiClient api, - [Description("Id of the character the relationship belongs to.")] Guid characterId, - [Description("Id of the character they are related to.")] Guid relatedCharacterId, - [Description("How characterId is related to relatedCharacterId, e.g. 'sister', 'rival', 'former mentor'.")] string relationshipType, - CancellationToken ct, - [Description("How relatedCharacterId is related back to characterId, if different — e.g. 'brother' for " - + "'sister'. Defaults to relationshipType when the relation is symmetric, like 'rival'.")] - string? reciprocalRelationshipType = null, - [Description("What the relationship is like, and where it is headed.")] string? description = null) => - api.PostAsync($"/api/characters/{characterId}/relationships", - new { relatedCharacterId, relationshipType, reciprocalRelationshipType, description }, ct); - - [McpServerTool(Name = "link_character_identity")] - [Description("Record that this character is really another character — e.g. a character introduced " - + "under one name who is later revealed to be a character already in the novel under " - + "another name. Both characters keep their own dossier and beats; the canonical identity " - + "is whichever character you link to.")] - public static Task LinkCharacterIdentity( - NovelApiClient api, - [Description("Id of the character being revealed as someone else.")] Guid characterId, - [Description("Id of the character this one really is.")] Guid sameCharacterAsId, - CancellationToken ct, - [Description("Id of the chapter where the reveal happens, if any.")] Guid? revealedInChapterId = null, - [Description("Context on the reveal, e.g. how and why the disguise held.")] string? note = null) => - api.PutAsync($"/api/characters/{characterId}/identity", - new { sameCharacterAsId, revealedInChapterId, note }, ct); - - [McpServerTool(Name = "unlink_character_identity")] - [Description("Remove a character's identity link, restoring it to its own separate identity.")] - public static Task UnlinkCharacterIdentity( - NovelApiClient api, - [Description("The character's id.")] Guid characterId, - CancellationToken ct) => - api.DeleteAsync($"/api/characters/{characterId}/identity", ct); -} diff --git a/src/Novelly.Mcp/Tools/LocationTools.cs b/src/Novelly.Mcp/Tools/LocationTools.cs deleted file mode 100644 index af16422..0000000 --- a/src/Novelly.Mcp/Tools/LocationTools.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class LocationTools -{ - [McpServerTool(Name = "list_locations")] - [Description("List a novel's locations with how many chapters are set there. " - + "Read this before inventing a new location so you reuse the writer's vocabulary.")] - public static Task ListLocations( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct) => - api.GetAsync($"/api/novels/{novelId}/locations", ct); - - [McpServerTool(Name = "get_location_references")] - [Description("Cross-reference a location: every chapter set there.")] - public static Task GetLocationReferences( - NovelApiClient api, - [Description("The location's id.")] Guid locationId, - CancellationToken ct) => - api.GetAsync($"/api/locations/{locationId}/references", ct); - - [McpServerTool(Name = "create_location")] - [Description("Create a location explicitly. Applying an unknown location by name to a chapter " - + "also creates it, so this is only needed to set one up ahead of time.")] - public static Task CreateLocation( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - [Description("The location's name. Unique within the novel, matched case-insensitively.")] string name, - CancellationToken ct) => - api.PostAsync($"/api/novels/{novelId}/locations", new { name }, ct); - - [McpServerTool(Name = "update_location")] - [Description("Rename a location. Renaming updates it everywhere it is applied.")] - public static Task UpdateLocation( - NovelApiClient api, - [Description("The location's id.")] Guid locationId, - [Description("New name.")] string name, - CancellationToken ct) => - api.PatchAsync($"/api/locations/{locationId}", new { name }, ct); - - [McpServerTool(Name = "delete_location")] - [Description("Move a location to the trash. Whatever carried it is left alone — only the label goes. " - + "It can be restored from the Trash page within the retention window.")] - public static Task DeleteLocation( - NovelApiClient api, - [Description("The location's id.")] Guid locationId, - CancellationToken ct) => - api.DeleteAsync($"/api/locations/{locationId}", ct); -} diff --git a/src/Novelly.Mcp/Tools/ManuscriptTools.cs b/src/Novelly.Mcp/Tools/ManuscriptTools.cs deleted file mode 100644 index fb1c691..0000000 --- a/src/Novelly.Mcp/Tools/ManuscriptTools.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class ManuscriptTools -{ - [McpServerTool(Name = "list_chapters")] - [Description("List a novel's chapters in manuscript order, with beat and word counts.")] - public static Task ListChapters( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct) => - api.GetAsync($"/api/novels/{novelId}/chapters", ct); - - [McpServerTool(Name = "get_chapter")] - [Description("Read one chapter in full: its outline (beats) and its drafted prose.")] - public static Task GetChapter( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - CancellationToken ct) => - api.GetAsync($"/api/chapters/{chapterId}", ct); - - [McpServerTool(Name = "create_chapter")] - [Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number. " - + "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.")] - public static Task CreateChapter( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - [Description("Chapter title.")] string title, - CancellationToken ct, - [Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null, - [Description("FrontMatter, Body, or BackMatter. Defaults to Body.")] string? kind = null, - [Description("The chapter's outline summary paragraph.")] string? summary = null, - [Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null, - [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, - [Description("Target length in words.")] int? targetWordCount = null, - [Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null, - [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => - api.PostAsync($"/api/novels/{novelId}/chapters", new - { - title, - number, - kind = kind ?? "Body", - summary, - locations, - status = status ?? "Planned", - targetWordCount, - prose, - tags - }, ct); - - [McpServerTool(Name = "update_chapter")] - [Description("Revise a chapter's title, number, kind, summary, locations, notes, status " - + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in " - + "markdown; the word count is recomputed automatically.")] - public static Task UpdateChapter( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - CancellationToken ct, - [Description("New title.")] string? title = null, - [Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null, - [Description("FrontMatter, Body, or BackMatter.")] string? kind = null, - [Description("The chapter's outline summary paragraph.")] string? summary = null, - [Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null, - [Description("Anything else worth recording.")] string? notes = null, - [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, - [Description("Target length in words.")] int? targetWordCount = null, - [Description("The chapter's drafted text, in markdown.")] string? prose = null, - [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => - api.PatchAsync($"/api/chapters/{chapterId}", - new { title, number, kind, summary, locations, notes, status, targetWordCount, prose, tags }, ct); -} diff --git a/src/Novelly.Mcp/Tools/NovelTools.cs b/src/Novelly.Mcp/Tools/NovelTools.cs deleted file mode 100644 index 9d262f5..0000000 --- a/src/Novelly.Mcp/Tools/NovelTools.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class NovelTools -{ - [McpServerTool(Name = "list_novels")] - [Description("List every novel, with counts of characters, chapters and drafted words. " - + "Start here to find the novel id everything else needs.")] - public static Task ListNovels(NovelApiClient api, CancellationToken ct) => - api.GetAsync("/api/novels", ct); - - [McpServerTool(Name = "get_novel_brief")] - [Description("Read a novel's title, author, genre, logline, synopsis, notes and word-count target.")] - public static Task GetNovel( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct) => - api.GetAsync($"/api/novels/{novelId}", ct); - - [McpServerTool(Name = "create_novel")] - [Description("Create a new novel.")] - public static Task CreateNovel( - NovelApiClient api, - [Description("Working title.")] string title, - CancellationToken ct, - [Description("Author name.")] string? author = null, - [Description("Genre or category.")] string? genre = null, - [Description("One-sentence pitch.")] string? logline = null, - [Description("Paragraph-length summary of the whole book.")] string? synopsis = null, - [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, - [Description("Target manuscript length in words.")] int? targetWordCount = null) => - api.PostAsync("/api/novels", new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); - - [McpServerTool(Name = "update_novel_brief")] - [Description("Revise a novel's top-level fields. Only the fields you supply change; " - + "pass an empty string to clear one.")] - public static Task UpdateNovel( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct, - [Description("New title.")] string? title = null, - [Description("Author name.")] string? author = null, - [Description("Genre or category.")] string? genre = null, - [Description("One-sentence pitch.")] string? logline = null, - [Description("Paragraph-length summary of the whole book.")] string? synopsis = null, - [Description("Free-form notes on theme, tone, comparable titles.")] string? notes = null, - [Description("Target manuscript length in words.")] int? targetWordCount = null) => - api.PatchAsync($"/api/novels/{novelId}", - new { title, author, genre, logline, synopsis, notes, targetWordCount }, ct); -} diff --git a/src/Novelly.Mcp/Tools/QuestionTools.cs b/src/Novelly.Mcp/Tools/QuestionTools.cs deleted file mode 100644 index c7927cb..0000000 --- a/src/Novelly.Mcp/Tools/QuestionTools.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class QuestionTools -{ - [McpServerTool(Name = "list_open_questions")] - [Description("The decisions the writer has not made yet, newest first. Read this before " - + "proposing changes — an open question marks somewhere the writer is still " - + "thinking, not a gap to fill in for them.")] - public static Task ListOpenQuestions( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct, - [Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null, - [Description("Narrow to questions about one character.")] Guid? characterId = null, - [Description("Include questions already settled. Defaults to false.")] bool includeResolved = false) - { - var query = new List { $"includeResolved={includeResolved.ToString().ToLowerInvariant()}" }; - - if (chapterId is { } chapter) - { - query.Add($"chapterId={chapter}"); - } - - if (characterId is { } character) - { - query.Add($"characterId={character}"); - } - - return api.GetAsync($"/api/novels/{novelId}/questions?{string.Join('&', query)}", ct); - } - - [McpServerTool(Name = "raise_open_question")] - [Description("Record a question the writer has not settled, attached to the chapter outline " - + "and/or the character it is about. Prefer raising a question over guessing.")] - public static Task RaiseOpenQuestion( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - [Description("The question, in one line.")] string question, - CancellationToken ct, - [Description("The thinking around it — options considered, and what each costs.")] string? detail = null, - [Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null, - [Description("Id of the character this is about, if any.")] Guid? characterId = null) => - api.PostAsync($"/api/novels/{novelId}/questions", - new { question, detail, chapterId, characterId }, ct); - - [McpServerTool(Name = "update_open_question")] - [Description("Revise a question or change what it is attached to. Only the fields you supply change.")] - public static Task UpdateOpenQuestion( - NovelApiClient api, - [Description("The question's id.")] Guid questionId, - CancellationToken ct, - [Description("New wording for the question.")] string? question = null, - [Description("New detail. Pass an empty string to clear it.")] string? detail = null, - [Description("Attach to this chapter outline.")] Guid? chapterId = null, - [Description("Attach to this character.")] Guid? characterId = null, - [Description("Detach from its chapter.")] bool clearChapter = false, - [Description("Detach from its character.")] bool clearCharacter = false) => - api.PatchAsync($"/api/questions/{questionId}", - new { question, detail, chapterId, characterId, clearChapter, clearCharacter }, ct); - - [McpServerTool(Name = "resolve_open_question")] - [Description("Settle a question with what the writer decided. Set appendToNotes to also write " - + "the resolution into the notes of the chapter and character it hangs off.")] - public static Task ResolveOpenQuestion( - NovelApiClient api, - [Description("The question's id.")] Guid questionId, - [Description("What was decided.")] string resolution, - CancellationToken ct, - [Description("Also append the resolution to the associated notes.")] bool appendToNotes = false) => - api.PostAsync($"/api/questions/{questionId}/resolve", new { resolution, appendToNotes }, ct); - - [McpServerTool(Name = "reopen_question")] - [Description("Put a resolved question back on the list. Anything already appended to notes stays.")] - public static Task ReopenQuestion( - NovelApiClient api, - [Description("The question's id.")] Guid questionId, - CancellationToken ct) => - api.PostAsync($"/api/questions/{questionId}/reopen", new { }, ct); - - [McpServerTool(Name = "delete_open_question")] - [Description("Delete a question outright. Resolving is usually better — it keeps the decision.")] - public static Task DeleteOpenQuestion( - NovelApiClient api, - [Description("The question's id.")] Guid questionId, - CancellationToken ct) => - api.DeleteAsync($"/api/questions/{questionId}", ct); -} diff --git a/src/Novelly.Mcp/Tools/TagTools.cs b/src/Novelly.Mcp/Tools/TagTools.cs deleted file mode 100644 index 6d05fcb..0000000 --- a/src/Novelly.Mcp/Tools/TagTools.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.ComponentModel; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace Novelly.Mcp.Tools; - -[McpServerToolType] -public static class TagTools -{ - [McpServerTool(Name = "list_tags")] - [Description("List a novel's tags with how many characters, chapters and beats carry each. " - + "Read this before inventing a new tag so you reuse the writer's vocabulary.")] - public static Task ListTags( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - CancellationToken ct) => - api.GetAsync($"/api/novels/{novelId}/tags", ct); - - [McpServerTool(Name = "get_tag_references")] - [Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this " - + "to trace a motif, a thread, or a piece of setup through the book.")] - public static Task GetTagReferences( - NovelApiClient api, - [Description("The tag's id.")] Guid tagId, - CancellationToken ct) => - api.GetAsync($"/api/tags/{tagId}/references", ct); - - [McpServerTool(Name = "create_tag")] - [Description("Create a tag explicitly. Applying an unknown tag by name to a character, " - + "chapter or beat also creates it, so this is only needed to set a colour up front.")] - public static Task CreateTag( - NovelApiClient api, - [Description("The novel's id.")] Guid novelId, - [Description("The tag's name. Unique within the novel, matched case-insensitively.")] string name, - CancellationToken ct, - [Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) => - api.PostAsync($"/api/novels/{novelId}/tags", new { name, color }, ct); - - [McpServerTool(Name = "update_tag")] - [Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")] - public static Task UpdateTag( - NovelApiClient api, - [Description("The tag's id.")] Guid tagId, - CancellationToken ct, - [Description("New name.")] string? name = null, - [Description("Hex colour, e.g. \"#9a4a2f\".")] string? color = null) => - api.PatchAsync($"/api/tags/{tagId}", new { name, color }, ct); - - [McpServerTool(Name = "delete_tag")] - [Description("Delete a tag. Whatever carried it is left alone — only the label goes.")] - public static Task DeleteTag( - NovelApiClient api, - [Description("The tag's id.")] Guid tagId, - CancellationToken ct) => - api.DeleteAsync($"/api/tags/{tagId}", ct); -}