James Wampler 897fb442a1 Unify MCP and agent tool surfaces onto one registry in NovelAgentToolset
Fixes NovelAgentService continuing a conversation under the wrong
novel's route, since FindConversationAsync matched by id alone. Then
extends NovelAgentToolset to all 45 tools the stdio MCP server offered
(tag/location CRUD, character relationships, arc-stage beat pinning,
question editing, cross-novel novel listing/creation), tagging each
with whether it needs an explicit novel scope so a later MCP adapter
can inject it. Renames the toolset's 33 existing schemas from
snake_case to camelCase to match .NET/REST convention, since nothing
external consumes them.

Lays the groundwork to serve this same registry over MCP at /mcp and
retire the separate stdio Novelly.Mcp project (docs/plans/api/mcp_http_merge_plan.md).
2026-08-21 10:52:50 -07:00
2026-08-20 14:21:44 -07:00

Novelly

GitHub CI Gitea CI Coverage

Software for planning and writing a novel. You outline the book, keep character dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent embedded in the app that can read and edit the same data you can, and an MCP server that exposes that data to Claude Code, Claude Desktop, or any other MCP client.

The point of the three-way arrangement is that there is exactly one source of truth. The React UI, the embedded agent, and the MCP server all go through the same REST API, so an edit made from a chat in Claude Code and an edit made by typing in the browser are the same edit.

Stack

Piece Built with
Novelly.Api ASP.NET Core 10 minimal APIs, EF Core 10 + SQLite, Anthropic SDK, OpenAPI
Novelly.AppHost .NET Aspire orchestration for the API and the web client
Novelly.ServiceDefaults Shared OpenTelemetry, health checks and service discovery
Novelly.Mcp MCP stdio server (ModelContextProtocol)
Novelly.Web React 19, TypeScript, Vite, TanStack Query, Tailwind v4

The back end is one project organised by feature, not by layer. Each feature folder — Novels/, Characters/, Chapters/, Beats/, Tags/, Locations/, Agent/ — holds its entity, DTOs, service and endpoints together, so adding a capability means touching one folder rather than four. Common/ holds what genuinely crosses features and Data/ holds the DbContext and migrations.

Running it

Prerequisites: .NET 10 SDK and Node 20+.

Everything at once, through Aspire:

cd src/Novelly.Web && npm install && cd -
dotnet run --project src/Novelly.AppHost

That starts the API on :5080 and the Vite dev server on :5173, and opens the Aspire dashboard for logs, traces and metrics across both.

Or run the two halves separately:

# 1. API — creates and migrates novel.db on first run, listens on :5080
ASPNETCORE_URLS=http://localhost:5080 dotnet run --project src/Novelly.Api

# 2. Web — dev server on :5173, proxies /api to :5080
cd src/Novelly.Web && npm install && npm run dev

Open http://localhost:5173.

The app is fully usable without an Anthropic key — only the Agent tab needs one. To turn the agent on:

export ANTHROPIC_API_KEY=sk-ant-...

Without it, agent endpoints return 503 Agent unavailable with an explanatory message and everything else keeps working.

Tests

dotnet test                             # 174 tests
cd src/Novelly.Web && npm run build     # typecheck + bundle

git push runs both through a Husky pre-push hook (scripts/ci/prepush.sh). Install the hook once with npm install at the repo root.

Tests run against real in-memory SQLite rather than the EF in-memory provider, so they exercise the cascade deletes and query translation the app actually ships with.

Configuration

src/Novelly.Api/appsettings.json:

{
  "ConnectionStrings": { "Novel": "Data Source=novel.db" },
  "Cors": { "Origins": [ "http://localhost:5173" ] },
  "Agent": {
    "Model": "claude-opus-5",
    "MaxTokens": 16000,
    "Effort": "high",        // low | medium | high | max
    "MaxIterations": 12      // tool-call ceiling per user turn
  }
}

The API key is read from ANTHROPIC_API_KEY or, if you prefer, Agent:ApiKey — keep it out of appsettings.json and use user-secrets or the environment.

The data model

Novel ──┬── Character ──┬── CharacterRelationship
        │               └── CharacterArcStage  (the arc: flat, ordered)
        ├── Chapter ──── Beat   (the outline: flat, ordered)
        ├── Tag  (applied to characters, chapters and beats)
        ├── Location  (applied to chapters)
        ├── OpenQuestion  (attached to a chapter and/or a character)
        └── AgentConversation ── AgentMessage

A chapter outline is a paragraph plus a table. The paragraph is the chapter's Summary; the table is its beats. Each beat is one row:

Column What goes in it
Beat A three-to-five word handle — "she burns the atlas", not a sentence
Characters Who's in the beat. Optional; not every beat belongs to anyone, and a beat can name more than one
What happened The event itself
What's next What it sets in motion — the hook into the following beat

Beats are flat and ordered by SortOrder within their chapter. There is no nesting and no tree — reordering is one call that takes the beat ids in the order wanted. The outline tab also rolls up the distinct set of characters across a chapter's beats under the beat and word counts, each linking to that character's page — a quick cast list without opening every beat.

Beats plan; the chapter's Prose carries the draft. A chapter has one prose field, written and redrafted in place; WordCount is recomputed from it whenever it changes. There is no separate scene entity — the outline (beats) and the draft (prose) are the two views of the same chapter.

Main characters carry the book; supporting characters hold it up. A character's Importance (Main or Supporting) is separate from their Role — role is the part they play in the story, importance is how much of it they take, and a mentor can be either. Characters start supporting and get promoted. The two together drive the ordering, so the protagonist is always the first name on the list.

A main character's arc is a table, not a paragraph. ArcSummary still holds the sentence version; CharacterArcStage breaks the same change into ordered steps, each optionally pinned to the chapter it lands in. It is the same flat-and-ordered shape as the beat table, for the same reason. Nothing refuses an arc on a supporting character — demoting someone should not delete their work.

The character page reads the outlines back. GET /api/characters/{id}/beats returns every beat a character appears in, in manuscript order, each carrying its chapter so the page links straight into that chapter's outline. It is the dossier's reality check: what they actually do on the page, as against what the sheet claims about them.

Open questions are what you have not decided. A question hangs off a chapter outline, a character, both, or neither. It can be resolved, reopened or deleted, and resolving can append the decision to the notes of whatever it was attached to — so a settled question ends up where you re-read it rather than in a list you have stopped looking at. Resolved questions drop off the list unless you ask for them.

Tags cross-reference the book. A tag is scoped to one novel, unique by name (case-insensitively), and can be attached to any character, chapter or beat. Applying an unknown tag by name creates it, so tagging is one action rather than two. GET /api/tags/{id}/references returns everything carrying a tag, which is how you trace a motif or a thread across all three kinds at once.

A chapter can have several locations. Locations replaced the old single free-text Setting field: a chapter now carries a multi-select list of locations (where and when it takes place), each a novel-scoped, name-deduped entity — applying an unknown location by name creates it, same as tags. A "Locations" tab on the novel lists every location with its chapter count, and GET /api/locations/{id}/references lists every chapter set there.

The embedded agent

NovelAgentService runs the tool-use loop: it calls the Messages API, executes any tools Claude asks for, feeds every result back in a single user turn, and repeats until Claude stops asking. It has 33 tools covering the brief, characters and their arcs, chapter outlines (beats), tags, locations and open questions — all of them going through the same application services the REST API uses.

A few deliberate choices worth knowing about:

  • Conversation history replays as text only. Tool calls are not replayed into the transcript. The agent re-reads current state through its tools instead, which is more reliable than trusting a record of edits that may since have changed in the UI.
  • The user's turn is persisted before the loop runs, so a question is recorded even if the model call fails.
  • Tool failures come back as is_error results, not exceptions — the model reads the message and corrects itself.
  • MaxIterations caps tool calls per turn. On hitting it the agent says so rather than silently truncating.
  • The system prompt is cached (cache_control: ephemeral), so every turn after the first reads it back at a fraction of the input price.

The MCP server

A stdio MCP server exposing 45 tools over the same REST API. It holds no domain logic of its own — it is a second front end, not a second implementation.

Build it, then point your MCP client at the produced binary:

./scripts/publish-mcp.sh

Aspire does not run or manage this process — it's a separate stdio subprocess your MCP client spawns directly, so nothing rebuilds it automatically. Re-run the script (and reconnect your MCP client) after pulling changes that touch src/Novelly.Mcp, or it keeps serving whatever was published last, including against a stale auth contract.

Copy .mcp.json.example to .mcp.json (gitignored, since it carries your API key) and fill in the key:

{
  "mcpServers": {
    "novelly": {
      "command": "/absolute/path/to/mcp-server/Novelly.Mcp",
      "env": {
        "NOVELLY_API_URL": "http://localhost:5080",
        "NOVELLY_API_KEY": "<matches the API's Auth:ServiceApiKey user secret>"
      }
    }
  }
}

The API must be running, with Auth:ServiceApiKey set (e.g. via dotnet user-secrets set Auth:ServiceApiKey <key> -p src/Novelly.Api) to the same value as NOVELLY_API_KEY above. If the API is not running, or the key is missing or mismatched, the tools say so in a message the model can act on rather than failing opaquely.

Importing an existing outline

.claude/agents/outline-importer.md is a Claude Code subagent that reads an author's outline already on disk — chapter files and character dossiers in the folder shape described in that file — and writes it into a new Novelly project over the MCP server above. It's read-only against the source files and keeps a resumable ledger (.novelly-import.json) next to them, since a full book is more tool calls than fit in one turn. Invoke it with the source folder path once the API is running and .mcp.json is set up.

API surface

GET /api/health, plus:

Resource Routes
Novels GET|POST /api/novels, GET|PATCH|DELETE /api/novels/{id}
Characters GET|POST /api/novels/{id}/characters, GET|PATCH|DELETE /api/characters/{id}, POST /api/characters/{id}/relationships
Chapters GET|POST /api/novels/{id}/chapters, GET|PATCH|DELETE /api/chapters/{id}
Beats GET|POST /api/chapters/{id}/beats, POST /api/chapters/{id}/beats/reorder, GET|PATCH|DELETE /api/beats/{id}
Tags GET|POST /api/novels/{id}/tags, GET /api/tags/{id}/references, PATCH|DELETE /api/tags/{id}
Locations GET|POST /api/novels/{id}/locations, GET /api/locations/{id}/references, PATCH|DELETE /api/locations/{id}
Arcs GET|POST /api/characters/{id}/arc, POST /api/characters/{id}/arc/reorder, GET|PATCH|DELETE /api/arc-stages/{id}
Questions GET|POST /api/novels/{id}/questions, GET|PATCH|DELETE /api/questions/{id}, POST /api/questions/{id}/resolve, POST /api/questions/{id}/reopen
Agent GET /api/novels/{id}/agent/conversations, POST /api/novels/{id}/agent/messages, GET|DELETE /api/conversations/{id}

PATCH bodies are partial: an omitted field is left alone, an empty string clears it. A tags array replaces that item's tags outright and creates any names the novel has not seen; the same is true of a chapter's locations array. Omitting either leaves it untouched. Enums travel as names ("Protagonist", "Drafted"), never ordinals. In development the OpenAPI document is at /openapi/v1.json.

Known issues

  • react-router-dom 7.18.2 carries GHSA-qwww-vcr4-c8h2 (CSRF bypass in RSC mode). No patched release exists yet, and every version below the affected range carries 14 worse advisories. This app is a client-only SPA and does not use RSC mode, so the advisory does not apply — but npm audit will flag it until a fix ships. Upgrade when one does.

Where this could go next

The vertical slice is complete but thin in places. The obvious next steps:

  • Stream agent responses over SSE instead of returning the finished turn.
  • Drag-and-drop beat reordering (the reorder endpoint is already there; the UI uses up/down buttons).
  • Filter chapters and characters by tag from the list views, not just the Tags tab.
  • A manuscript export (Markdown, DOCX) built from chapters and scenes in order.
  • Revision history for scene prose.
  • Authentication, if this is ever going to run anywhere but localhost.
S
Description
Software plan + write novel — ASP.NET Core, React, embedded Claude agent
Readme
1.1 MiB
Languages
C# 71.3%
TypeScript 26.8%
Shell 1.4%
CSS 0.4%
Dockerfile 0.1%