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.
This commit is contained in:
James Wampler
2026-08-21 10:59:52 -07:00
parent ab773615f8
commit 2d6bb9fc57
11 changed files with 0 additions and 813 deletions
-101
View File
@@ -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<NovelApiClient> logger)
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public Task<CallToolResult> GetAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Get, path), ct);
public Task<CallToolResult> PostAsync(string path, object body, CancellationToken ct = default) =>
SendAsync(new HttpRequestMessage(HttpMethod.Post, path)
{
Content = JsonContent.Create(body, options: Options)
}, ct);
public Task<CallToolResult> PatchAsync(string path, object body, CancellationToken ct = default) =>
SendAsync(new HttpRequestMessage(HttpMethod.Patch, path)
{
Content = JsonContent.Create(body, options: Options)
}, ct);
public Task<CallToolResult> PutAsync(string path, object body, CancellationToken ct = default) =>
SendAsync(new HttpRequestMessage(HttpMethod.Put, path)
{
Content = JsonContent.Create(body, options: Options)
}, ct);
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
private async Task<CallToolResult> 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<JsonElement>(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<JsonElement>(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;
}
}
}
-17
View File
@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageReference Include="ModelContextProtocol" Version="2.1.0" />
</ItemGroup>
</Project>
-31
View File
@@ -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<NovelApiClient>(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();
-92
View File
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
}
-226
View File
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> UnlinkCharacterIdentity(
NovelApiClient api,
[Description("The character's id.")] Guid characterId,
CancellationToken ct) =>
api.DeleteAsync($"/api/characters/{characterId}/identity", ct);
}
-54
View File
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> DeleteLocation(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
CancellationToken ct) =>
api.DeleteAsync($"/api/locations/{locationId}", ct);
}
-75
View File
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
}
-54
View File
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
}
-92
View File
@@ -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<CallToolResult> 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<string> { $"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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> DeleteOpenQuestion(
NovelApiClient api,
[Description("The question's id.")] Guid questionId,
CancellationToken ct) =>
api.DeleteAsync($"/api/questions/{questionId}", ct);
}
-56
View File
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> DeleteTag(
NovelApiClient api,
[Description("The tag's id.")] Guid tagId,
CancellationToken ct) =>
api.DeleteAsync($"/api/tags/{tagId}", ct);
}