Add novel-writing app: .NET 10 API, React front end, agent and MCP server

Builds out the vertical slice for planning and writing a novel. Three front
ends — the React UI, an embedded Claude agent, and an MCP stdio server — all go
through one REST API, so an edit made from Claude Code and one made in the
browser are the same edit.

Layout:
  Domain          entities and enums, no dependencies
  Application     services, DTOs, the agent tool-use loop and its 15 tools
  Infrastructure  EF Core 10 + SQLite, Anthropic SDK client
  Api             ASP.NET Core 10 minimal APIs, OpenAPI, ProblemDetails
  Mcp             MCP stdio server, 21 tools over the same REST API
  Web             React 19 + Vite + TanStack Query + Tailwind v4

Data model is Project > Characters / OutlineNodes / Chapters > Scenes, plus
agent conversations. The outline is a self-nesting tree so acts, sequences and
beats can be arranged however the book wants; scenes carry goal/conflict/outcome
because that is what the agent drafts prose from.

Notes on a few choices:

- Conversation history replays to the model as text only. The agent re-reads
  current state through its tools rather than trusting a record of edits that
  may since have changed in the UI.
- The user's turn is persisted before the tool loop runs, so a question is
  recorded even when the model call fails. Turn order uses an explicit sequence
  column; timestamps tie when a turn completes inside one tick.
- Tool failures return is_error results rather than throwing, so the model can
  read the message and correct itself. MCP tools do the same via CallToolResult,
  which keeps the API's own message instead of a generic SDK error.
- The Anthropic client is constructed lazily. It is injected into the agent
  service, which also serves read-only endpoints, and those should keep working
  on an install with no key. Sending without one returns 503, not 400.
- DateTimeOffset is stored as UTC ticks. SQLite refuses to ORDER BY the default
  text form, which every "recently updated first" listing depends on.

Tests run against real in-memory SQLite rather than the EF in-memory provider so
they exercise the cascade deletes and query translation that actually ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 3c85bab4a4
commit 0d7b7a6f30
91 changed files with 9935 additions and 1 deletions
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class ChapterService(INovelDbContext db)
{
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
var chapters = await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Scenes)
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
return [.. chapters.Select(c => c.ToSummaryDto())];
}
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
var chapter = new Chapter
{
ProjectId = projectId,
Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(projectId, ct),
Summary = request.Summary,
PovCharacterId = request.PovCharacterId,
Setting = request.Setting,
Notes = request.Notes,
Status = request.Status,
TargetWordCount = request.TargetWordCount
};
db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct);
return (await FindAsync(chapter.Id, ct)).ToDto();
}
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{
var chapter = await FindAsync(id, ct);
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.PovCharacterId = request.PovCharacterId ?? chapter.PovCharacterId;
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
chapter.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var chapter = await FindAsync(id, ct);
db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct);
}
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
{
var max = await db.Chapters
.Where(c => c.ProjectId == projectId)
.MaxAsync(c => (int?)c.Number, ct);
return (max ?? 0) + 1;
}
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) =>
await db.Chapters
.Include(c => c.PovCharacter)
.Include(c => c.Scenes)
.ThenInclude(s => s.PovCharacter)
.FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Chapter), id);
}
@@ -0,0 +1,136 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class CharacterService(INovelDbContext db)
{
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
var characters = await Query()
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Role)
.ThenBy(c => c.Name)
.ToListAsync(ct);
return [.. characters.Select(c => c.ToDto())];
}
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{
await EnsureProjectExists(projectId, ct);
var character = new Character
{
ProjectId = projectId,
Name = request.Name,
Role = request.Role,
Age = request.Age,
Pronouns = request.Pronouns,
Occupation = request.Occupation,
Appearance = request.Appearance,
Personality = request.Personality,
Backstory = request.Backstory,
Want = request.Want,
Need = request.Need,
InternalConflict = request.InternalConflict,
ExternalConflict = request.ExternalConflict,
ArcSummary = request.ArcSummary,
Voice = request.Voice,
Notes = request.Notes
};
db.Characters.Add(character);
await db.SaveChangesAsync(ct);
return character.ToDto();
}
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
{
var character = await FindAsync(id, ct);
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role;
character.Age = Patch.Apply(character.Age, request.Age);
character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns);
character.Occupation = Patch.Apply(character.Occupation, request.Occupation);
character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
character.Personality = Patch.Apply(character.Personality, request.Personality);
character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
character.Want = Patch.Apply(character.Want, request.Want);
character.Need = Patch.Apply(character.Need, request.Need);
character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
character.Voice = Patch.Apply(character.Voice, request.Voice);
character.Notes = Patch.Apply(character.Notes, request.Notes);
character.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return character.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var character = await FindAsync(id, ct);
db.Characters.Remove(character);
await db.SaveChangesAsync(ct);
}
public async Task<CharacterDto> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{
var character = await FindAsync(characterId, ct);
var related = await db.Characters
.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct)
?? throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
if (related.ProjectId != character.ProjectId)
{
throw new InvalidOperationException("Characters must belong to the same project to be related.");
}
db.CharacterRelationships.Add(new CharacterRelationship
{
CharacterId = characterId,
RelatedCharacterId = request.RelatedCharacterId,
RelationshipType = request.RelationshipType,
Description = request.Description
});
await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct)).ToDto();
}
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
{
var relationship = await db.CharacterRelationships
.FirstOrDefaultAsync(r => r.Id == relationshipId, ct)
?? throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct);
}
private IQueryable<Character> Query() =>
db.Characters
.Include(c => c.Relationships)
.ThenInclude(r => r.RelatedCharacter);
private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Character), id);
private async Task EnsureProjectExists(Guid projectId, CancellationToken ct)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
}
}
@@ -0,0 +1,166 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class OutlineService(INovelDbContext db)
{
/// <summary>Returns the project's outline as a tree of root nodes with children inlined.</summary>
public async Task<IReadOnlyList<OutlineNodeDto>> GetTreeAsync(Guid projectId, CancellationToken ct = default)
{
var nodes = await db.OutlineNodes
.Where(n => n.ProjectId == projectId)
.ToListAsync(ct);
return BuildTree(nodes, parentId: null);
}
public async Task<OutlineNodeDto> GetAsync(Guid id, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
var siblings = await db.OutlineNodes.Where(n => n.ProjectId == node.ProjectId).ToListAsync(ct);
return BuildNode(node, siblings);
}
public async Task<OutlineNodeDto> CreateAsync(
Guid projectId, CreateOutlineNodeRequest request, CancellationToken ct = default)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
if (request.ParentId is { } parentId && !await db.OutlineNodes.AnyAsync(n => n.Id == parentId, ct))
{
throw new NotFoundException(nameof(OutlineNode), parentId);
}
var node = new OutlineNode
{
ProjectId = projectId,
ParentId = request.ParentId,
NodeType = request.NodeType,
Title = request.Title,
Summary = request.Summary,
ChapterId = request.ChapterId,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(projectId, request.ParentId, ct)
};
db.OutlineNodes.Add(node);
await db.SaveChangesAsync(ct);
return BuildNode(node, []);
}
public async Task<OutlineNodeDto> UpdateAsync(
Guid id, UpdateOutlineNodeRequest request, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
node.Title = Patch.Apply(node.Title, request.Title) ?? node.Title;
node.NodeType = request.NodeType ?? node.NodeType;
node.Summary = Patch.Apply(node.Summary, request.Summary);
node.SortOrder = request.SortOrder ?? node.SortOrder;
node.ChapterId = request.ChapterId ?? node.ChapterId;
node.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return await GetAsync(id, ct);
}
/// <summary>
/// Reparents a node. Refuses to move a node under one of its own descendants, which
/// would detach the subtree from the tree entirely.
/// </summary>
public async Task<OutlineNodeDto> MoveAsync(Guid id, MoveOutlineNodeRequest request, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
if (request.ParentId == id)
{
throw new InvalidOperationException("An outline node cannot be its own parent.");
}
if (request.ParentId is { } newParentId)
{
var allNodes = await db.OutlineNodes
.Where(n => n.ProjectId == node.ProjectId)
.ToListAsync(ct);
if (!allNodes.Any(n => n.Id == newParentId))
{
throw new NotFoundException(nameof(OutlineNode), newParentId);
}
if (DescendantIds(allNodes, id).Contains(newParentId))
{
throw new InvalidOperationException("An outline node cannot be moved beneath its own descendant.");
}
}
node.ParentId = request.ParentId;
node.SortOrder = request.SortOrder;
node.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return await GetAsync(id, ct);
}
/// <summary>Deletes a node and its entire subtree.</summary>
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
var allNodes = await db.OutlineNodes
.Where(n => n.ProjectId == node.ProjectId)
.ToListAsync(ct);
var doomed = DescendantIds(allNodes, id).Append(id).ToHashSet();
db.OutlineNodes.RemoveRange(allNodes.Where(n => doomed.Contains(n.Id)));
await db.SaveChangesAsync(ct);
}
private async Task<int> NextSortOrderAsync(Guid projectId, Guid? parentId, CancellationToken ct)
{
var max = await db.OutlineNodes
.Where(n => n.ProjectId == projectId && n.ParentId == parentId)
.MaxAsync(n => (int?)n.SortOrder, ct);
return (max ?? 0) + 1;
}
private async Task<OutlineNode> FindAsync(Guid id, CancellationToken ct) =>
await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct)
?? throw new NotFoundException(nameof(OutlineNode), id);
private static IReadOnlyList<OutlineNodeDto> BuildTree(List<OutlineNode> all, Guid? parentId) =>
[
.. all
.Where(n => n.ParentId == parentId)
.OrderBy(n => n.SortOrder)
.ThenBy(n => n.Title)
.Select(n => new OutlineNodeDto(
n.Id, n.ProjectId, n.ParentId, n.NodeType, n.Title, n.Summary,
n.SortOrder, n.ChapterId, BuildTree(all, n.Id)))
];
private static OutlineNodeDto BuildNode(OutlineNode node, List<OutlineNode> all) => new(
node.Id, node.ProjectId, node.ParentId, node.NodeType, node.Title, node.Summary,
node.SortOrder, node.ChapterId, BuildTree(all, node.Id));
private static IEnumerable<Guid> DescendantIds(List<OutlineNode> all, Guid rootId)
{
var frontier = new Queue<Guid>([rootId]);
while (frontier.Count > 0)
{
var current = frontier.Dequeue();
foreach (var child in all.Where(n => n.ParentId == current))
{
yield return child.Id;
frontier.Enqueue(child.Id);
}
}
}
}
@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class ProjectService(INovelDbContext db)
{
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) =>
await db.Projects
.OrderByDescending(p => p.UpdatedAt)
.Select(p => new ProjectSummaryDto(
p.Id,
p.Title,
p.Author,
p.Genre,
p.Logline,
p.TargetWordCount,
p.Characters.Count,
p.Chapters.Count,
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
p.UpdatedAt))
.ToListAsync(ct);
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
var project = new Project
{
Title = request.Title,
Author = request.Author,
Genre = request.Genre,
Logline = request.Logline,
Synopsis = request.Synopsis,
Notes = request.Notes,
TargetWordCount = request.TargetWordCount
};
db.Projects.Add(project);
await db.SaveChangesAsync(ct);
return project.ToDto();
}
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
var project = await FindAsync(id, ct);
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author);
project.Genre = Patch.Apply(project.Genre, request.Genre);
project.Logline = Patch.Apply(project.Logline, request.Logline);
project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
project.Notes = Patch.Apply(project.Notes, request.Notes);
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
project.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return project.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var project = await FindAsync(id, ct);
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
}
private async Task<Project> FindAsync(Guid id, CancellationToken ct) =>
await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new NotFoundException(nameof(Project), id);
}
/// <summary>
/// Patch semantics shared by every update endpoint: a null value leaves the field
/// untouched, an empty string clears it.
/// </summary>
internal static class Patch
{
public static string? Apply(string? current, string? incoming) => incoming switch
{
null => current,
"" => null,
_ => incoming
};
}
@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class SceneService(INovelDbContext db)
{
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
var scenes = await Query()
.Where(s => s.ChapterId == chapterId)
.OrderBy(s => s.SortOrder)
.ToListAsync(ct);
return [.. scenes.Select(s => s.ToDto())];
}
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{
throw new NotFoundException(nameof(Chapter), chapterId);
}
var scene = new Scene
{
ChapterId = chapterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
Summary = request.Summary,
Goal = request.Goal,
Conflict = request.Conflict,
Outcome = request.Outcome,
PovCharacterId = request.PovCharacterId,
Location = request.Location,
Prose = request.Prose,
WordCount = SceneMapping.CountWords(request.Prose),
Status = request.Status
};
db.Scenes.Add(scene);
await db.SaveChangesAsync(ct);
return (await FindAsync(scene.Id, ct)).ToDto();
}
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{
var scene = await FindAsync(id, ct);
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
scene.Summary = Patch.Apply(scene.Summary, request.Summary);
scene.Goal = Patch.Apply(scene.Goal, request.Goal);
scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict);
scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome);
scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId;
scene.Location = Patch.Apply(scene.Location, request.Location);
scene.Status = request.Status ?? scene.Status;
if (request.Prose is not null)
{
scene.Prose = Patch.Apply(scene.Prose, request.Prose);
scene.WordCount = SceneMapping.CountWords(scene.Prose);
}
scene.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var scene = await FindAsync(id, ct);
db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct);
}
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
var max = await db.Scenes
.Where(s => s.ChapterId == chapterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
return (max ?? 0) + 1;
}
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
?? throw new NotFoundException(nameof(Scene), id);
}