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).
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Novelly.Api.Agent;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class NovelAgentToolsetTests : ServiceTestFixture
|
||||
{
|
||||
private NovelAgentToolset _toolset = null!;
|
||||
|
||||
protected override void OnSetUp() =>
|
||||
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||
|
||||
private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value);
|
||||
|
||||
[Test]
|
||||
public void The_toolset_offers_every_tool_the_stdio_server_offered()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"list_novels", "create_novel", "get_novel_brief", "update_novel_brief",
|
||||
"list_characters", "get_character", "create_character", "update_character",
|
||||
"get_character_beats", "get_character_arc", "add_arc_stage", "update_arc_stage",
|
||||
"delete_arc_stage", "reorder_arc_stages", "set_arc_stage_beats", "relate_characters",
|
||||
"link_character_identity", "unlink_character_identity",
|
||||
"get_chapter_outline", "create_beat", "update_beat", "delete_beat",
|
||||
"assign_character_to_beats", "reorder_beats", "move_beats",
|
||||
"list_chapters", "get_chapter", "create_chapter", "update_chapter",
|
||||
"list_tags", "get_tag_references", "create_tag", "update_tag", "delete_tag",
|
||||
"list_locations", "get_location_references", "create_location", "update_location", "delete_location",
|
||||
"list_open_questions", "raise_open_question", "update_open_question",
|
||||
"resolve_open_question", "reopen_question", "delete_open_question"
|
||||
};
|
||||
|
||||
Assert.That(_toolset.Definitions.Select(t => t.Name), Is.EquivalentTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_novel_scoped_tools_schema_already_declares_a_novel_id()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var tool in _toolset.Definitions.Where(t => t.RequiresNovelId))
|
||||
{
|
||||
Assert.That(tool.InputSchema.TryGetProperty("novelId", out _), Is.False, tool.Name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creating_a_novel_through_a_tool_makes_it_appear_in_the_novel_list()
|
||||
{
|
||||
var result = await _toolset.ExecuteAsync(
|
||||
"create_novel", Guid.Empty, Input(new { title = "The Ash Ledger" }));
|
||||
|
||||
var novels = await Novels.ListAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.False);
|
||||
Assert.That(novels.Select(n => n.Title), Has.Member("The Ash Ledger"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reading_a_character_by_id_returns_its_dossier()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id;
|
||||
|
||||
var result = await _toolset.ExecuteAsync(
|
||||
"get_character", novelId, Input(new { characterId }));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.False);
|
||||
Assert.That(result.Content, Does.Contain("Ines"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Relating_two_characters_shows_the_pair_on_both_dossiers()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
var inesId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id;
|
||||
var marcoId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Marco"))).Id;
|
||||
|
||||
var result = await _toolset.ExecuteAsync(
|
||||
"relate_characters", novelId,
|
||||
Input(new { characterId = inesId, relatedCharacterId = marcoId, relationshipType = "sister" }));
|
||||
|
||||
var ines = await Characters.GetAsync(inesId);
|
||||
var marco = await Characters.GetAsync(marcoId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.False);
|
||||
Assert.That(ines!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(marcoId));
|
||||
Assert.That(marco!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(inesId));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Setting_an_arc_stages_beats_replaces_whatever_was_there()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main))).Id;
|
||||
var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id;
|
||||
var beatId = (await Beats.CreateAsync(chapterId, new CreateBeatRequest("Arrival", CharacterIds: [characterId]))).Id;
|
||||
var stageId = (await Arcs.CreateAsync(characterId, new CreateArcStageRequest("Doubt"))).Id;
|
||||
|
||||
var result = await _toolset.ExecuteAsync(
|
||||
"set_arc_stage_beats", novelId, Input(new { arcStageId = stageId, beatIds = new[] { beatId.ToString() } }));
|
||||
|
||||
Assert.That(result.IsError, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creating_a_tag_with_a_colour_then_renaming_it_keeps_it_on_what_carried_it()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
|
||||
var created = await _toolset.ExecuteAsync(
|
||||
"create_tag", novelId, Input(new { name = "Foreshadowing", color = "#9a4a2f" }));
|
||||
var tagsAfterCreate = await Tags.ListAsync(novelId);
|
||||
var tagId = tagsAfterCreate.Single(t => t.Name == "Foreshadowing").Id;
|
||||
|
||||
var renamed = await _toolset.ExecuteAsync(
|
||||
"update_tag", novelId, Input(new { tagId, name = "Setup" }));
|
||||
var tagsAfterRename = await Tags.ListAsync(novelId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(created.IsError, Is.False);
|
||||
Assert.That(renamed.IsError, Is.False);
|
||||
Assert.That(tagsAfterRename.Select(t => t.Name), Has.Member("Setup"));
|
||||
Assert.That(tagsAfterRename.Select(t => t.Name), Has.No.Member("Foreshadowing"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_tag_leaves_the_characters_that_carried_it_alone()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
var characterId = (await Characters.CreateAsync(
|
||||
novelId, new CreateCharacterRequest("Ines", Tags: ["Foreshadowing"]))).Id;
|
||||
var tagId = (await Tags.ListAsync(novelId)).Single(t => t.Name == "Foreshadowing").Id;
|
||||
|
||||
var result = await _toolset.ExecuteAsync("delete_tag", novelId, Input(new { tagId }));
|
||||
|
||||
var character = await Characters.GetAsync(characterId);
|
||||
var tagsAfter = await Tags.ListAsync(novelId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.False);
|
||||
Assert.That(tagsAfter, Is.Empty);
|
||||
Assert.That(character, Is.Not.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creating_and_renaming_a_location_updates_it_everywhere()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
|
||||
await _toolset.ExecuteAsync("create_location", novelId, Input(new { name = "The Salt Flats" }));
|
||||
var locationId = (await Locations.ListAsync(novelId)).Single(l => l.Name == "The Salt Flats").Id;
|
||||
|
||||
var renamed = await _toolset.ExecuteAsync(
|
||||
"update_location", novelId, Input(new { locationId, name = "The Great Salt Flats" }));
|
||||
var locationsAfter = await Locations.ListAsync(novelId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(renamed.IsError, Is.False);
|
||||
Assert.That(locationsAfter.Select(l => l.Name), Has.Member("The Great Salt Flats"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Updating_an_open_question_can_detach_it_from_its_chapter()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id;
|
||||
var questionId = (await Questions.CreateAsync(
|
||||
novelId, new Novelly.Api.Questions.CreateOpenQuestionRequest("Who sent the letter?", ChapterId: chapterId)))!.Id;
|
||||
|
||||
var result = await _toolset.ExecuteAsync(
|
||||
"update_open_question", novelId, Input(new { questionId, clearChapter = true }));
|
||||
|
||||
var question = await Questions.GetAsync(questionId);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsError, Is.False);
|
||||
Assert.That(question!.ChapterId, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Asking_for_an_unknown_id_comes_back_as_an_error_result()
|
||||
{
|
||||
var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id;
|
||||
var missingId = Guid.NewGuid();
|
||||
|
||||
Assert.Multiple(async () =>
|
||||
{
|
||||
var character = await _toolset.ExecuteAsync("get_character", novelId, Input(new { characterId = missingId }));
|
||||
Assert.That(character.IsError, Is.True);
|
||||
|
||||
var tag = await _toolset.ExecuteAsync("update_tag", novelId, Input(new { tagId = missingId, name = "x" }));
|
||||
Assert.That(tag.IsError, Is.True);
|
||||
|
||||
var location = await _toolset.ExecuteAsync("delete_location", novelId, Input(new { locationId = missingId }));
|
||||
Assert.That(location.IsError, Is.True);
|
||||
|
||||
var question = await _toolset.ExecuteAsync("update_open_question", novelId, Input(new { questionId = missingId }));
|
||||
Assert.That(question.IsError, Is.True);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user