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:
James Wampler
2026-08-21 10:56:24 -07:00
parent 897fb442a1
commit ab773615f8
6 changed files with 258 additions and 0 deletions
@@ -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<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;
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Novelly.Api.Mcp;
public static class McpEndpoints
{
public static IEndpointRouteBuilder MapNovelMcp(this IEndpointRouteBuilder app)
{
app.MapMcp("/mcp");
return app;
}
}
+109
View File
@@ -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());
}
}
+1
View File
@@ -14,6 +14,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<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.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
+2
View File
@@ -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();