Serve the unified tool registry over MCP Streamable HTTP at /mcp
Adds ModelContextProtocol.AspNetCore and registers AddMcpServer with WithListToolsHandler/WithCallToolHandler rather than 45 attribute methods, so both handlers resolve the scoped NovelAgentToolset per request and reuse its hand-built schemas directly instead of fighting the SDK's delegate-based schema inference. NovelMcpTools (src/Novelly.Api/Mcp/) is the adapter: it injects a required novelId property into the advertised schema for tools that need one and extracts it back out at call time, since the web agent gets novelId ambiently from its route but an MCP client has no route to supply it from. /mcp inherits auth from the existing fallback policy (cookie or X-Novelly-Api-Key) by adding no authorization metadata of its own — chaining .RequireAuthorization() would apply the default, cookie-only policy instead and break the API key. Verified end to end against a running instance: initialize advertises capabilities.tools, tools/list returns all 45 with novelId injected only where needed, tool errors map to result.isError rather than a JSON-RPC error, and a write (create_tag) round-trips correctly with the service user's identity intact.
This commit is contained in:
@@ -15,11 +15,13 @@ using Novelly.Api.Data;
|
|||||||
using Novelly.Api.Genres;
|
using Novelly.Api.Genres;
|
||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Locations;
|
using Novelly.Api.Locations;
|
||||||
|
using Novelly.Api.Mcp;
|
||||||
using Novelly.Api.Novels;
|
using Novelly.Api.Novels;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
using Novelly.Api.Trash;
|
using Novelly.Api.Trash;
|
||||||
using Novelly.Api.Users;
|
using Novelly.Api.Users;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
namespace Novelly.Api.Common;
|
namespace Novelly.Api.Common;
|
||||||
|
|
||||||
@@ -111,6 +113,19 @@ public static class NovellyServiceRegistration
|
|||||||
|
|
||||||
services.AddModelValidatorsFromAssemblyContaining<Program>();
|
services.AddModelValidatorsFromAssemblyContaining<Program>();
|
||||||
|
|
||||||
|
services.AddMcpServer(options => options.ServerInfo = new Implementation { Name = "novelly", Version = "1.0.0" })
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithListToolsHandler((request, ct) =>
|
||||||
|
{
|
||||||
|
var toolset = request.Services!.GetRequiredService<NovelAgentToolset>();
|
||||||
|
return ValueTask.FromResult(new ListToolsResult { Tools = [.. NovelMcpTools.Describe(toolset.Definitions)] });
|
||||||
|
})
|
||||||
|
.WithCallToolHandler((request, ct) =>
|
||||||
|
{
|
||||||
|
var toolset = request.Services!.GetRequiredService<NovelAgentToolset>();
|
||||||
|
return new ValueTask<CallToolResult>(NovelMcpTools.CallAsync(toolset, toolset.Definitions, request.Params!, ct));
|
||||||
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Novelly.Api.Mcp;
|
||||||
|
|
||||||
|
public static class McpEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNovelMcp(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapMcp("/mcp");
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Tool> Describe(IReadOnlyList<AgentToolDefinition> definitions) =>
|
||||||
|
[.. definitions.Select(definition => new Tool
|
||||||
|
{
|
||||||
|
Name = definition.Name,
|
||||||
|
Description = definition.Description,
|
||||||
|
InputSchema = definition.RequiresNovelId ? WithNovelId(definition.InputSchema) : definition.InputSchema
|
||||||
|
})];
|
||||||
|
|
||||||
|
public static async Task<CallToolResult> CallAsync(
|
||||||
|
NovelAgentToolset toolset,
|
||||||
|
IReadOnlyList<AgentToolDefinition> 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<string, JsonElement>? arguments)
|
||||||
|
{
|
||||||
|
if (arguments is null)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>("{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = new JsonObject();
|
||||||
|
foreach (var (key, value) in arguments)
|
||||||
|
{
|
||||||
|
obj[key] = JsonNode.Parse(value.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(node.ToJsonString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.1.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Novelly.Api.Data;
|
|||||||
using Novelly.Api.Genres;
|
using Novelly.Api.Genres;
|
||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Locations;
|
using Novelly.Api.Locations;
|
||||||
|
using Novelly.Api.Mcp;
|
||||||
using Novelly.Api.Novels;
|
using Novelly.Api.Novels;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
@@ -112,6 +113,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
app.MapNovelMcp();
|
||||||
|
|
||||||
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
|
||||||
app.MapUiSettingsEndpoints();
|
app.MapUiSettingsEndpoints();
|
||||||
|
|||||||
@@ -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<NovelAgentToolset>.Instance);
|
||||||
|
|
||||||
|
private static CallToolRequestParams Params(string name, object? arguments = null) => new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Arguments = arguments is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(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<Tool>(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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user