diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index e6d6733..7903742 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -15,11 +15,13 @@ using Novelly.Api.Data; using Novelly.Api.Genres; using Novelly.Api.Imports; using Novelly.Api.Locations; +using Novelly.Api.Mcp; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; using Novelly.Api.Trash; using Novelly.Api.Users; +using ModelContextProtocol.Protocol; namespace Novelly.Api.Common; @@ -111,6 +113,19 @@ public static class NovellyServiceRegistration services.AddModelValidatorsFromAssemblyContaining(); + services.AddMcpServer(options => options.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" }) + .WithHttpTransport() + .WithListToolsHandler((request, ct) => + { + var toolset = request.Services!.GetRequiredService(); + return ValueTask.FromResult(new ListToolsResult { Tools = [.. NovelMcpTools.Describe(toolset.Definitions)] }); + }) + .WithCallToolHandler((request, ct) => + { + var toolset = request.Services!.GetRequiredService(); + return new ValueTask(NovelMcpTools.CallAsync(toolset, toolset.Definitions, request.Params!, ct)); + }); + return services; } } diff --git a/src/Novelly.Api/Mcp/McpEndpoints.cs b/src/Novelly.Api/Mcp/McpEndpoints.cs new file mode 100644 index 0000000..9884634 --- /dev/null +++ b/src/Novelly.Api/Mcp/McpEndpoints.cs @@ -0,0 +1,10 @@ +namespace Novelly.Api.Mcp; + +public static class McpEndpoints +{ + public static IEndpointRouteBuilder MapNovelMcp(this IEndpointRouteBuilder app) + { + app.MapMcp("/mcp"); + return app; + } +} diff --git a/src/Novelly.Api/Mcp/NovelMcpTools.cs b/src/Novelly.Api/Mcp/NovelMcpTools.cs new file mode 100644 index 0000000..cd71b3d --- /dev/null +++ b/src/Novelly.Api/Mcp/NovelMcpTools.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol.Protocol; +using Novelly.Api.Agent; + +namespace Novelly.Api.Mcp; + +public static class NovelMcpTools +{ + private const string NovelIdProperty = "novelId"; + + public static IReadOnlyList Describe(IReadOnlyList definitions) => + [.. definitions.Select(definition => new Tool + { + Name = definition.Name, + Description = definition.Description, + InputSchema = definition.RequiresNovelId ? WithNovelId(definition.InputSchema) : definition.InputSchema + })]; + + public static async Task CallAsync( + NovelAgentToolset toolset, + IReadOnlyList definitions, + CallToolRequestParams parameters, + CancellationToken ct) + { + var definition = definitions.FirstOrDefault(d => d.Name == parameters.Name); + if (definition is null) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"No such tool: '{parameters.Name}'." }] + }; + } + + var arguments = ToJsonElement(parameters.Arguments); + + Guid novelId; + if (definition.RequiresNovelId) + { + try + { + novelId = JsonInput.RequiredGuid(arguments, NovelIdProperty); + } + catch (ArgumentException ex) + { + return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = ex.Message }] }; + } + } + else + { + novelId = Guid.Empty; + } + + var result = await toolset.ExecuteAsync(parameters.Name, novelId, arguments, ct); + + return new CallToolResult + { + IsError = result.IsError, + Content = [new TextContentBlock { Text = result.Content }] + }; + } + + private static JsonElement ToJsonElement(IDictionary? arguments) + { + if (arguments is null) + { + return JsonSerializer.Deserialize("{}"); + } + + var obj = new JsonObject(); + foreach (var (key, value) in arguments) + { + obj[key] = JsonNode.Parse(value.GetRawText()); + } + + return JsonSerializer.Deserialize(obj.ToJsonString()); + } + + private static JsonElement WithNovelId(JsonElement schema) + { + var node = JsonNode.Parse(schema.GetRawText())!.AsObject(); + var properties = new JsonObject + { + [NovelIdProperty] = new JsonObject + { + ["type"] = "string", + ["description"] = "The novel's id." + } + }; + + if (node["properties"] is JsonObject existingProperties) + { + foreach (var (key, value) in existingProperties.ToList()) + { + existingProperties.Remove(key); + properties[key] = value; + } + } + + node["properties"] = properties; + + var required = node["required"] as JsonArray ?? []; + required.Insert(0, NovelIdProperty); + node["required"] = required; + + return JsonSerializer.Deserialize(node.ToJsonString()); + } +} diff --git a/src/Novelly.Api/Novelly.Api.csproj b/src/Novelly.Api/Novelly.Api.csproj index 3019eb9..1e7a70d 100644 --- a/src/Novelly.Api/Novelly.Api.csproj +++ b/src/Novelly.Api/Novelly.Api.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 5adfcc1..9375054 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -12,6 +12,7 @@ using Novelly.Api.Data; using Novelly.Api.Genres; using Novelly.Api.Imports; using Novelly.Api.Locations; +using Novelly.Api.Mcp; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -112,6 +113,7 @@ if (app.Environment.IsDevelopment()) } app.MapDefaultEndpoints(); +app.MapNovelMcp(); app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous(); app.MapUiSettingsEndpoints(); diff --git a/tests/Novelly.Api.Tests/NovelMcpToolsTests.cs b/tests/Novelly.Api.Tests/NovelMcpToolsTests.cs new file mode 100644 index 0000000..750a91d --- /dev/null +++ b/tests/Novelly.Api.Tests/NovelMcpToolsTests.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; +using Novelly.Api.Agent; +using Novelly.Api.Mcp; +using Novelly.Api.Novels; + +namespace Novelly.Api.Tests; + +[TestFixture] +public class NovelMcpToolsTests : ServiceTestFixture +{ + private NovelAgentToolset _toolset = null!; + + protected override void OnSetUp() => + _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger.Instance); + + private static CallToolRequestParams Params(string name, object? arguments = null) => new() + { + Name = name, + Arguments = arguments is null + ? null + : JsonSerializer.Deserialize>(JsonSerializer.Serialize(arguments)) + }; + + [Test] + public void Every_agent_tool_is_advertised_over_mcp() + { + var tools = NovelMcpTools.Describe(_toolset.Definitions); + + Assert.Multiple(() => + { + Assert.That(tools, Has.Count.EqualTo(45)); + Assert.That(tools.Select(t => t.Name), Is.Unique); + Assert.That(tools, Has.All.Matches(t => !string.IsNullOrWhiteSpace(t.Description))); + }); + } + + [Test] + public void Tools_that_work_on_a_whole_novel_advertise_a_required_novel_id() + { + var tool = NovelMcpTools.Describe(_toolset.Definitions).Single(t => t.Name == "list_tags"); + + Assert.Multiple(() => + { + Assert.That(tool.InputSchema.GetProperty("properties").TryGetProperty("novelId", out _), Is.True); + Assert.That( + tool.InputSchema.GetProperty("required").EnumerateArray().Select(e => e.GetString()), + Has.Member("novelId")); + }); + } + + [Test] + public void Tools_addressed_by_child_id_do_not_advertise_a_novel_id() + { + var tool = NovelMcpTools.Describe(_toolset.Definitions).Single(t => t.Name == "delete_tag"); + + Assert.That(tool.InputSchema.GetProperty("properties").TryGetProperty("novelId", out _), Is.False); + } + + [Test] + public void Listing_novels_over_mcp_needs_no_novel_id() + { + var tool = NovelMcpTools.Describe(_toolset.Definitions).Single(t => t.Name == "list_novels"); + + Assert.That(tool.InputSchema.GetProperty("properties").TryGetProperty("novelId", out _), Is.False); + } + + [Test] + public void Advertised_schemas_stay_valid_json_schema_objects() + { + Assert.Multiple(() => + { + foreach (var tool in NovelMcpTools.Describe(_toolset.Definitions)) + { + Assert.That(tool.InputSchema.GetProperty("type").GetString(), Is.EqualTo("object"), tool.Name); + Assert.That(tool.InputSchema.TryGetProperty("properties", out _), Is.True, tool.Name); + } + }); + } + + [Test] + public async Task Calling_a_novel_scoped_tool_without_a_novel_id_comes_back_as_an_error_result() + { + var result = await NovelMcpTools.CallAsync(_toolset, _toolset.Definitions, Params("list_tags"), CancellationToken.None); + + Assert.That(result.IsError, Is.True); + } + + [Test] + public async Task Calling_a_tool_over_mcp_runs_it_against_the_novel_in_the_arguments() + { + var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; + + var result = await NovelMcpTools.CallAsync( + _toolset, _toolset.Definitions, Params("get_novel_brief", new { novelId }), CancellationToken.None); + + var text = ((TextContentBlock)result.Content![0]).Text; + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.False); + Assert.That(text, Does.Contain("The Salt Road")); + }); + } + + [Test] + public async Task A_tool_that_finds_nothing_comes_back_as_an_error_result() + { + var result = await NovelMcpTools.CallAsync( + _toolset, _toolset.Definitions, Params("get_character", new { characterId = Guid.NewGuid() }), CancellationToken.None); + + var text = ((TextContentBlock)result.Content![0]).Text; + + Assert.Multiple(() => + { + Assert.That(result.IsError, Is.True); + Assert.That(text, Does.Contain("was not found")); + }); + } +}