Files
novelly/src/Novelly.Api/Program.cs
T
James Wampler ab773615f8 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.
2026-08-21 10:56:24 -07:00

141 lines
4.1 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Activity;
using Novelly.Api.Agent;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
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 Serilog;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, config) => config
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"));
builder.AddServiceDefaults();
builder.Services.AddNovelly(builder.Configuration);
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var corsOrigins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:5173"];
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
.WithOrigins(corsOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()));
var migrateOnly = args.Contains("--migrate-only");
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<NovelDbContext>();
try
{
await db.Database.MigrateAsync();
}
catch (Exception ex)
{
app.Logger.LogCritical(ex, "Database migration failed on startup");
Environment.Exit(1);
}
if (migrateOnly)
{
app.Logger.LogInformation("Migration complete, exiting ({MigrateOnlyFlag})", "--migrate-only");
Environment.Exit(0);
}
await ServiceUser.EnsureSeededAsync(db, builder.Configuration[ServiceApiKeyAuthenticationHandler.ConfigurationKey], app.Logger);
await ActivityBackfill.RunAsync(db, app.Logger);
var importRoot = builder.Configuration.GetSection(ImportOptions.SectionName)[nameof(ImportOptions.RootPath)];
if (!string.IsNullOrWhiteSpace(importRoot))
{
Directory.CreateDirectory(importRoot);
}
}
app.UseSerilogRequestLogging();
app.UseExceptionHandler(handler => handler.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var (status, title) = exception switch
{
AgentNotConfiguredException => (StatusCodes.Status503ServiceUnavailable, "Agent unavailable"),
NotAuthorizedException => (StatusCodes.Status403Forbidden, "Forbidden"),
ArgumentException or InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid request"),
_ => (StatusCodes.Status500InternalServerError, "Unexpected error")
};
app.Logger.Log(
status == StatusCodes.Status500InternalServerError ? LogLevel.Error : LogLevel.Warning,
exception,
"Handled {StatusCode} on {Path}: {Title}", status, context.Request.Path, title);
await Results
.Problem(title: title, detail: exception?.Message, statusCode: status)
.ExecuteAsync(context);
}));
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapDefaultEndpoints();
app.MapNovelMcp();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUiSettingsEndpoints();
app.MapUserEndpoints();
app.MapNovelMemberEndpoints();
app.MapNovelEndpoints()
.MapCharacterEndpoints()
.MapChapterEndpoints()
.MapBeatEndpoints()
.MapTagEndpoints()
.MapLocationEndpoints()
.MapGenreEndpoints()
.MapOpenQuestionEndpoints()
.MapAgentEndpoints()
.MapImportEndpoints()
.MapActivityEndpoints()
.MapTrashEndpoints();
app.Run();
[ExcludeFromCodeCoverage]
public partial class Program;