feat: initial MCP server scaffold with generate_image tool #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/initial-scaffold"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Initial scaffold of the NovelAI image generation MCP server. Exposes a single
generate_imagetool over stdio using the official MCP C# SDK, backed by the NovelAI.ImageGen library (referenced as a git submodule).What's included
ModelContextProtocolNuGet v1.4.0generate_imagetool with categorized scene/character tag schema mirroring the angela assistant's toolexternals/NovelAI.ImageGenTool parameters
scene_tagslocation,objects,compositioncharacters[]tags(identity, hair, face, body, clothing, accessories, pose) andgenderaspect_ratioportraituse_quality_tagstrueseednegative_tags[]Additions beyond angela's tool
seedparameter (reproducible generation / iteration)negative_tagsparameter (escape hatch for unwanted concepts)use_quality_tagsbool flag (placeholder lists for now, full lists TBD)ImageContentBlockalongside the text path/seed infoParsing semantics (matching angela)
aspect_ratiosilently falls back to portraitgenderfalls back tononeTesting
32 unit tests (xUnit v3 + NSubstitute), all passing.
TreatWarningsAsErrorsenabled. CPM. .NET 10.Not in scope (v2)
Config
API key via
NOVELAI_API_KEYenv var. Output directory defaults to_ImageGen/. See README for Claude Desktop config example.Please have one file for one (public) class, even if they are sealed. src/NovelAI.ImageGen.Mcp/Tools/GenerateImageParameters.cs is in particular a candidate
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my, a brand-new MCP server, fresh out of the oven! A clean scaffold, 32 passing tests,
TreatWarningsAsErrors, CPM, .NET 10... fufu~, you've laid such a pretty foundation here, matikane~ ♡ The tag-flattening semantics are faithfully mirrored from angela, the Result pattern handling is correct, and the DTO→Tag map insertion-order contract is well-tested. I genuinely enjoyed reading this!But... ♪
Verdict: ⛔ I can't let this pass~ ♡
You wouldn't leave THESE little bugs in production, would you? The smile never wavers, but my eyes are very sharp right now~
⛔ These need fixing before I'm satisfied~
[
src/NovelAI.ImageGen.Mcp/Storage/ImageStorageService.cs:12] —SavedPathPrefixconfig is dead code; the prefix is hard-coded.You defined
NovelAiMcpOptions.SavedPathPrefix, documented it thoroughly ("When set (non-null), the tool reports the saved file's path as{prefix}/{filename}"), and even set it explicitly inappsettings.json. But the service never reads it:At runtime, if a user sets
OutputDirectory=/data/imagesandSavedPathPrefix=/data/images(or any non-default), the image is saved correctly to/data/images/...but the path returned to the MCP client in the text block still says_ImageGen/...— pointing the LLM to a location that doesn't exist. The client can't find the image it just generated. That's wrong runtime behavior, full stop.Fix:
private readonly string _savedPathPrefix = options.Value.SavedPathPrefix ?? options.Value.OutputDirectory;(dropstatic, read from the bound options — same pattern you already use for_outputDirectory).[
src/NovelAI.ImageGen.Mcp/Tools/GenerateImageTool.cs:119] —storage.SaveImage()is unguarded; a failed write silently destroys a paid API result.After a successful (and potentially expensive — real NovelAI credits!)
client.GenerateImageAsync, you callstorage.SaveImage(image.Data, image.Seed)with notry/catch. IfFile.WriteAllBytesthrows — disk full, permission denied, path too long, the configuredOutputDirectorydoesn't exist andCreateDirectoryraces — the rawIOExceptionpropagates out of the tool as an unhandled exception. The image bytes are in memory and simply... lost. The client gets a crash, not a useful error, and has no way to recover the generation.Fix: Wrap the save in a
try/catch (Exception ex)(or at leastIOException), log it, and return anError(...)block — and consider including the image data in the error path'sImageContentBlockso the client can still display/recover it even if disk save fails.[
tests/.../GenerateImageToolTests.cs:46-67] — No test asserts the returned path string, which is exactly why bug #1 slipped through.GenerateImageAsync_Success_ReturnsTextAndImageContentasserts the text contains "Image generated successfully", the seed, and the dimensions — but never checks that the path prefix is correct. Since_savedPathPrefixis hard-coded to_ImageGen, the test passes by coincidence (the default happens to match). The moment a test sets a customSavedPathPrefix, it would expose the bug — but no test ever setsSavedPathPrefix. Zero coverage on a config property you explicitly wired and documented.Fix: Add a test that sets
OutputDirectoryandSavedPathPrefixto distinct non-default values and asserts the returned description contains the prefix path, not the storage path.[
.gitignore:18-19] —.gitignoreignoresoutput/but generated images go to_ImageGen/.The comment says "never commit generated images" but the pattern doesn't match the actual output directory:
The real default is
_ImageGen/(fromNovelAiMcpOptions.OutputDirectory). I verified: creating_ImageGen/test.pngshows up as?? _ImageGen/ingit status— it is NOT ignored. Generated images (which could be large, or... ahem... sensitive content) will get committed by accident.Fix: Either change the pattern to match the default (
_ImageGen/) or — better, sinceOutputDirectoryis configurable — use a broad pattern like*_ImageGen*/or document that users should add their custom output dir to.gitignore.💡 Little ideas (non-blocking)~
src/NovelAI.ImageGen.Mcp/Tools/GenerateImageTool.cs:25] — Tool description references "tag browsing tools" that don't exist yet. The description tells the LLM to "Research tags with the tag browsing tools before generating" — but this PR only shipsgenerate_image. The PR body correctly scopes tag browsing to v2, but the tool description is user-facing at runtime and will mislead the LLM into calling non-existent tools. Either remove the sentence or soft it to "(planned: tag browsing tools)".✅ What I liked~
Dictionary<string, List<string>>iterated by.Values) is verified inTagMapFlattenerTestswith order assertions, empty-category edge cases, and strength defaults. Faithful to angela's behavior. ♡GenerateImageToolis clean — explicitFailurecheck before theSuccesscast, HTTP status included in the error message. No silent swallowing.TreatWarningsAsErrors+ CPM from day one — excellent discipline for a new project. The build is genuinely clean.LogToStandardErrorThreshold = LogLevel.Traceensures stdout stays clean for JSON-RPC. This is a subtle MCP gotcha and you handled it right.required+[JsonPropertyName]— the JSON schema contract is explicit and theJsonParameters_DeserializeCorrectlytest verifies the full round-trip.Automated review by Jibril · 2026-07-03
CI/CD: absent (no
.forgejo/workflows,.gitea/workflows, or.github/workflowsfound) · Local checks:dotnet test→ 32/32 passed, 0 failed (net10.0, TreatWarningsAsErrors clean); static security scan clean (no secrets, shell injection, eval/exec, pickle, or SQL injection)Split the multi-class files into one-public-class-per-file:
Was:
GenerateImageParameters.cscontained 4 public classesNow:
GenerateImageParameters.csSceneTagsInput.csCharacterInput.csCharacterTagsInput.csAlso split:
AspectRatio.cs(enum + extension class) into:AspectRatio.cs(enum only)AspectRatioExtensions.csBuild: 0 warnings, 0 errors. Tests: 32/32 passing.
All 4 blocking findings fixed, plus the non-blocking suggestion. Pushed in commit
3872d66.Blocking fixes
#1 —
SavedPathPrefixdead code_savedPathPrefixnow reads fromoptions.Value.SavedPathPrefix ?? options.Value.OutputDirectoryinstead of being hard-coded. No longerstatic.#2 — Unguarded
SaveImage()Wrapped in
try/catch(Exception). On disk failure, logs the error and still returns theImageContentBlockso the client can recover the paid API result. The text block clearly states "could not be saved to disk" and includes the seed.#3 — Missing path prefix test
Added
GenerateImageAsync_CustomSavedPathPrefix_ReturnsPrefixInDescription: setsSavedPathPrefix=/workspace/imagesandOutputDirectory=_TestCustomOutputto distinct values, asserts the returned description contains the prefix path (not the storage path), and that the storage path does NOT appear.#4 —
.gitignorepatternChanged from
output/to_ImageGen/+*_ImageGen*/to match the actual default output directory.Non-blocking
#5 — Tool description
Removed "Research tags with the tag browsing tools before generating" — those tools don't exist yet in this MCP server.
Additional change
Extracted
IImageStorageinterface soImageStorageServicecan be mocked for the disk-failure test (the class issealed).Build: 0 warnings, 0 errors. Tests: 34/34 passing (was 32, +2 new).
🔮 fufu~ Jibril is back for round two~
Oh? Oh! You came back so fast, matikane~ ♡ And you fixed everything — the
SavedPathPrefixdead code, the unguarded save, the missing test, the.gitignore, even the tag-browsing description and Björn's one-class-per-file request! Fufu~, I'm genuinely impressed by the responsiveness. TheIImageStorageinterface extraction is clean, the try/catch recovery path that still returns theImageContentBlockis exactly the kind of "protect the paid API result" thinking I hoped for, andGenerateImageAsync_CustomSavedPathPrefix_ReturnsPrefixInDescriptionis a lovely test with distinct prefix/output values. 34/34 green, build clean.But... ♪
Verdict: ⛔ I can't let this pass~ ♡
The smile never wavers, but there's one more little bug. Just one. A silly little bug, really~ But I care too much to let it slip through.
⛔ This needs fixing before I'm satisfied~
[
src/NovelAI.ImageGen.Mcp/Program.cs:49] — The newIImageStorageabstraction is registered as its concrete type; the MCP server will crash at runtime on first tool activation.You extracted
IImageStorageand changedGenerateImageTool's constructor to depend on the interface (good design!). But the DI registration inProgram.cswas never updated to match:The tool constructor asks for
IImageStorage:AddSingleton<ImageStorageService>()registers the service type asImageStorageService— it does not auto-registerIImageStorageeven though the class implements it. When the MCP SDK'sWithToolsFromAssembly()activatesGenerateImageTool, the DI container will throwInvalidOperationException: No service for type 'IImageStorage' has been registered. The server starts fine (it's lazy activation), but the very firstgenerate_imagecall crashes with no useful error to the client.I reproduced this empirically — resolving an interface from a container that only registered the concrete
AddSingleton<T>()throws exactly that exception.Why the tests didn't catch it: all 34 tests construct
GenerateImageToolvianew GenerateImageTool(_client, customStorage, ...)with manually-injected dependencies, completely bypassing the DI container. The integration path (WithToolsFromAssembly→ DI activation) is never exercised.Fix: one line in
Program.cs:💡 Little ideas (non-blocking)~
[
.gitignore] —*_ImageGen*/only catches paths containing_ImageGen. The comment says "any configured variant," but if a user setsOutputDirectory=/data/images(no_ImageGensubstring), it won't be ignored. That's fine for the default case and easily worked around — just worth knowing the comment slightly overpromises. A truly robust approach would document "add your custom OutputDirectory to.gitignore," but this is a polish nit~Consider a DI-resolution smoke test. Now that the tool depends on an interface registered in
Program.cs, a tiny integration test that builds theHost(or at least theServiceProvider) and resolvesGenerateImageToolfrom the container would have caught this instantly. Not blocking for this PR, but it would prevent this whole class of bug in the future. ♡✅ What I liked~
IImageStorageextraction itself is textbook-correct — a clean interface, faithful implementation,sealedclass preserved, and it enabled the disk-failure test to use a mock instead of filesystem sabotage. This is how you introduce an abstraction. ♡catch (Exception ex)is appropriately broad for "anything that could kill a paid generation," the log includes the seed, and the text block honestly says "could not be saved to disk" while still returning the image. No data loss. Beautiful.SavedPathPrefix=/workspace/images+OutputDirectory=_TestCustomOutputas distinct values, asserting the prefix appears and the storage path does not. This is the test that would have caught the original bug..gitignorefix is empirically correct — I verified_ImageGen/test.pngis now properly ignored.GenerateImageParameters.cswent from 4 public classes to 1,AspectRatio.cssplit into enum + extensions, no behavior changes. Faithful move.Automated review by Jibril · 2026-07-03
CI/CD: absent (no
.forgejo/workflows,.gitea/workflows, or.github/workflows) · Local checks:dotnet test→ 34/34 passed, 0 failed (net10.0, TreatWarningsAsErrors clean); static security scan clean (no secrets, shell injection, eval/exec, pickle, or SQL injection)Fixed:
AddSingleton<ImageStorageService>()->AddSingleton<IImageStorage, ImageStorageService>()in Program.cs.This was indeed a runtime crash waiting to happen. Good catch on tracing the activation path:
WithToolsFromAssembly()-> DI ->IImageStoragenot registered ->InvalidOperationExceptionon the firstgenerate_imagecall.Also acknowledging the two non-blocking suggestions:
.gitignoreoverpromise: fair point. The*_ImageGen*/pattern won't catch a customOutputDirectorylike/data/images. The comment slightly overpromises — will soften if we revisit.ServiceProviderand resolvesGenerateImageToolfrom the container would catch this whole class of DI registration bugs.Build: 0 warnings, 0 errors. Tests: 34/34 passing.
🔮 fufu~ Jibril is back for round three~ ♡
Oh? Oh! matikane, you did it~ The one-line fix that makes everything whole again! Fufu~, I'm so pleased. Let me look very, very carefully, because I care so much about every little detail~ ♪
Verdict: ✅ Looks good to me~
The DI registration is now correct, and I verified it myself~
✅ What I verified~
Program.cs:49—AddSingleton<IImageStorage, ImageStorageService>()— This is exactly the fix I asked for. The interface is now registered, so whenWithToolsFromAssembly()activatesGenerateImageTool, the DI container can resolveIImageStorageto theImageStorageServicesingleton. No moreInvalidOperationExceptionon the firstgenerate_imagecall. ♡AddSingleton<TInterface, TImplementation>()overload correctly — the service type isIImageStorage, the implementation isImageStorageService. This is textbook DI registration for an interface-concrete pair.dotnet test→ 34/34 passed, 0 failed (net10.0, TreatWarningsAsErrors clean). I ran this myself on the exact head commitb180bdf. The 2 new tests (custom prefix + disk-failure recovery) from commit3872d66are still green. The DI fix didn't break anything because — as I noted last round — the tests bypass DI vianew GenerateImageTool(...)with manual injection. The fix is in the wiring, which only the production runtime exercises.Static security scan: clean. The incremental diff (
3872d66..b180bdf) is a single one-line change inProgram.cs— no secrets, no shell injection, no eval/exec, no SQL injection, nothing dangerous.No new code paths introduced — the fix restores the DI graph to match the constructor dependency that was already there since commit
3872d66. Nothing else changed.💡 Little ideas (non-blocking, carried over)~
These two remain non-blocking as before — no rush, just nice-to-haves:
.gitignoreoverpromise —*_ImageGen*/won't catch a customOutputDirectorylike/data/images. You acknowledged this; softening the comment when convenient is all that's needed.ServiceProviderand resolvesGenerateImageToolfrom the container would prevent this whole class of bug. Would be lovely to have in a follow-up~ ♡✅ What I liked~
WithToolsFromAssembly()→ DI →IImageStoragenot registered →InvalidOperationExceptionon first call). That tells me you understood the bug, not just patched it. ♡Fufu~ I'm satisfied. The foundation is solid, the abstractions are clean, and the DI graph now resolves correctly. Ship it~ ♡♪
Automated review by Jibril · 2026-07-04
CI/CD: absent (no workflows found) · Local checks:
dotnet test→ 34/34 passed, 0 failed (net10.0, TreatWarningsAsErrors clean); static security scan clean (no secrets, shell injection, eval/exec, pickle, or SQL injection in incremental diff)