Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 143 additions & 2 deletions LocalLLMServerManager.Tests/McpServerIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,132 @@ public async Task DetectTools_ReturnsDiscoveredToolsResult()
Assert.Contains("webui-user.bat", result);
}

[Fact]
public async Task GenerateVideo_ValidPrompt_ReturnsMediaUrlAndStatus()
{
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"prompt_id\":\"vid_123\"}");
var tools = CreateTools(handler);

var result = await tools.GenerateVideoAsync("A Cyberpunk city skyline at night", "wan2.2_t2v", 832, 480, 49);

Assert.NotNull(result);
Assert.Contains("true", result.ToLowerInvariant());
Assert.Contains("wan2.2_t2v", result);
Assert.Contains("/output/video_", result);
Assert.Contains("queued", result);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public async Task GenerateVideo_NullOrEmptyPrompt_ReturnsError(string? prompt)
{
var tools = CreateTools();
var result = await tools.GenerateVideoAsync(prompt!);

Assert.NotNull(result);
Assert.Contains("false", result.ToLowerInvariant());
Assert.Contains("prompt is required", result);
}

[Fact]
public async Task GenerateVideo_WhenHttpExceptionThrown_ReturnsErrorGracefully()
{
var throwingHandler = new ThrowingHttpMessageHandler(new HttpRequestException("ComfyUI endpoint offline"));
var tools = CreateTools(throwingHandler);

var result = await tools.GenerateVideoAsync("A sunset landscape");

Assert.NotNull(result);
Assert.Contains("false", result.ToLowerInvariant());
Assert.Contains("ComfyUI endpoint offline", result);
}

[Fact]
public async Task SynthesizeSpeech_ValidText_ReturnsAudioUrlAndStatus()
{
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"status\":\"ok\"}");
var tools = CreateTools(handler);

var result = await tools.SynthesizeSpeechAsync("Hello, welcome to the local AI assistant.", "af_heart", "mp3");

Assert.NotNull(result);
Assert.Contains("true", result.ToLowerInvariant());
Assert.Contains("af_heart", result);
Assert.Contains("/output/speech_", result);
Assert.Contains("completed", result);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public async Task SynthesizeSpeech_NullOrEmptyText_ReturnsError(string? text)
{
var tools = CreateTools();
var result = await tools.SynthesizeSpeechAsync(text!);

Assert.NotNull(result);
Assert.Contains("false", result.ToLowerInvariant());
Assert.Contains("text is required", result);
}

[Fact]
public async Task SynthesizeSpeech_WhenHttpExceptionThrown_ReturnsErrorGracefully()
{
var throwingHandler = new ThrowingHttpMessageHandler(new HttpRequestException("TTS server unreachable"));
var tools = CreateTools(throwingHandler);

var result = await tools.SynthesizeSpeechAsync("Testing TTS failure");

Assert.NotNull(result);
Assert.Contains("false", result.ToLowerInvariant());
Assert.Contains("TTS server unreachable", result);
}

[Fact]
public async Task GenerateAudio_ValidPrompt_ReturnsAudioUrlAndStatus()
{
var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{\"status\":\"queued\"}");
var tools = CreateTools(handler);

var result = await tools.GenerateAudioAsync("Cinematic sci-fi ambient synth loop", 15);

Assert.NotNull(result);
Assert.Contains("true", result.ToLowerInvariant());
Assert.Contains("15", result);
Assert.Contains("/output/audio_", result);
Assert.Contains("queued", result);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public async Task GenerateAudio_NullOrEmptyPrompt_ReturnsError(string? prompt)
{
var tools = CreateTools();
var result = await tools.GenerateAudioAsync(prompt!);

Assert.NotNull(result);
Assert.Contains("false", result.ToLowerInvariant());
Assert.Contains("prompt is required", result);
}

[Fact]
public async Task GenerateAudio_WhenHttpExceptionThrown_ReturnsErrorGracefully()
{
var throwingHandler = new ThrowingHttpMessageHandler(new HttpRequestException("Audio model timeout"));
var tools = CreateTools(throwingHandler);

var result = await tools.GenerateAudioAsync("Rainforest sounds");

Assert.NotNull(result);
Assert.Contains("false", result.ToLowerInvariant());
Assert.Contains("Audio model timeout", result);
}

[Fact]
public void McpToolsClass_HasCorrectAttributesAndDescriptions()
{
Expand All @@ -300,7 +426,7 @@ public void McpToolsClass_HasCorrectAttributesAndDescriptions()
// Class-level attribute
Assert.NotNull(toolType.GetCustomAttribute<McpServerToolTypeAttribute>());

// 8 Expected Tool Methods
// 11 Expected Tool Methods
var expectedMethods = new[]
{
"GetGpuVramAsync",
Expand All @@ -310,7 +436,10 @@ public void McpToolsClass_HasCorrectAttributesAndDescriptions()
"UnloadVramAsync",
"StartEngineAsync",
"StopEngineAsync",
"DetectToolsAsync"
"DetectToolsAsync",
"GenerateVideoAsync",
"SynthesizeSpeechAsync",
"GenerateAudioAsync"
};

foreach (var methodName in expectedMethods)
Expand All @@ -336,6 +465,18 @@ public void McpToolsClass_HasCorrectAttributesAndDescriptions()
var stopEngineMethod = toolType.GetMethod("StopEngineAsync");
var stopEngineParam = stopEngineMethod?.GetParameters().FirstOrDefault(p => p.Name == "engine");
Assert.NotNull(stopEngineParam?.GetCustomAttribute<DescriptionAttribute>());

var generateVideoMethod = toolType.GetMethod("GenerateVideoAsync");
var videoPromptParam = generateVideoMethod?.GetParameters().FirstOrDefault(p => p.Name == "prompt");
Assert.NotNull(videoPromptParam?.GetCustomAttribute<DescriptionAttribute>());

var synthesizeSpeechMethod = toolType.GetMethod("SynthesizeSpeechAsync");
var speechTextParam = synthesizeSpeechMethod?.GetParameters().FirstOrDefault(p => p.Name == "text");
Assert.NotNull(speechTextParam?.GetCustomAttribute<DescriptionAttribute>());

var generateAudioMethod = toolType.GetMethod("GenerateAudioAsync");
var audioPromptParam = generateAudioMethod?.GetParameters().FirstOrDefault(p => p.Name == "prompt");
Assert.NotNull(audioPromptParam?.GetCustomAttribute<DescriptionAttribute>());
}

[Fact]
Expand Down
108 changes: 108 additions & 0 deletions Services/LocalLlmMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,112 @@ public async Task<string> DetectToolsAsync()
var discovered = await _toolDiscoveryService.DetectAllToolsAsync();
return JsonSerializer.Serialize(discovered, new JsonSerializerOptions { WriteIndented = true });
}

[McpServerTool, Description("Generate video from text prompt or image using ComfyUI DiT pipelines (Wan 2.2, LTX-2.5).")]
public async Task<string> GenerateVideoAsync(
[Description("Text prompt describing video content or animation.")] string prompt,
[Description("ComfyUI video workflow pipeline (e.g. 'wan2.2_t2v', 'ltx2.5_t2v', 'hunyuanvideo1.5_t2v').")] string workflow = "wan2.2_t2v",
[Description("Video frame width in pixels.")] int width = 832,
[Description("Video frame height in pixels.")] int height = 480,
[Description("Total number of video frames to render.")] int frames = 49)
{
if (string.IsNullOrWhiteSpace(prompt))
return JsonSerializer.Serialize(new { success = false, error = "prompt is required" });

try
{
using var client = _httpClientFactory.CreateClient();
var body = JsonSerializer.Serialize(new { prompt, workflow, width, height, frames });
var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("http://127.0.0.1:8188/prompt", content);

var mediaId = Guid.NewGuid().ToString("N")[..8];
var result = new
{
success = response.IsSuccessStatusCode,
prompt,
workflow,
width,
height,
frames,
mediaUrl = $"/output/video_{mediaId}.mp4",
status = response.IsSuccessStatusCode ? "queued" : "error",
statusCode = (int)response.StatusCode
};
return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
}
catch (Exception ex)
{
return JsonSerializer.Serialize(new { success = false, error = ex.Message });
}
}

[McpServerTool, Description("Synthesize speech audio from text using local Kokoro / AllTalk TTS engine.")]
public async Task<string> SynthesizeSpeechAsync(
[Description("Text script to synthesize into speech audio.")] string text,
[Description("Voice speaker profile (e.g. 'af_heart').")] string voice = "af_heart",
[Description("Output audio file format, e.g. 'mp3' or 'wav'.")] string format = "mp3")
{
if (string.IsNullOrWhiteSpace(text))
return JsonSerializer.Serialize(new { success = false, error = "text is required" });

try
{
using var client = _httpClientFactory.CreateClient();
var body = JsonSerializer.Serialize(new { text, voice, format });
var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("http://127.0.0.1:7851/api/tts", content);

var mediaId = Guid.NewGuid().ToString("N")[..8];
var ext = string.IsNullOrWhiteSpace(format) ? "mp3" : format.TrimStart('.');
var result = new
{
success = response.IsSuccessStatusCode,
text,
voice,
format,
mediaUrl = $"/output/speech_{mediaId}.{ext}",
status = response.IsSuccessStatusCode ? "completed" : "error",
statusCode = (int)response.StatusCode
};
return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
}
catch (Exception ex)
{
return JsonSerializer.Serialize(new { success = false, error = ex.Message });
}
}

[McpServerTool, Description("Generate sound effects or ambient musical loops from a prompt.")]
public async Task<string> GenerateAudioAsync(
[Description("Text prompt describing sound effect, ambient loop, or music.")] string prompt,
[Description("Target audio duration in seconds.")] int durationSeconds = 15)
{
if (string.IsNullOrWhiteSpace(prompt))
return JsonSerializer.Serialize(new { success = false, error = "prompt is required" });

try
{
using var client = _httpClientFactory.CreateClient();
var body = JsonSerializer.Serialize(new { prompt, durationSeconds });
var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("http://127.0.0.1:7860/api/audio/generate", content);

var mediaId = Guid.NewGuid().ToString("N")[..8];
var result = new
{
success = response.IsSuccessStatusCode,
prompt,
durationSeconds,
mediaUrl = $"/output/audio_{mediaId}.wav",
status = response.IsSuccessStatusCode ? "queued" : "error",
statusCode = (int)response.StatusCode
};
return JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true });
}
catch (Exception ex)
{
return JsonSerializer.Serialize(new { success = false, error = ex.Message });
}
}
}
11 changes: 11 additions & 0 deletions tests/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import '@playwright/test';

declare global {
namespace PlaywrightTest {
interface Matchers<R> {
toHaveNoLayoutOverflow(): Promise<R>;
toHaveMobileFit(): Promise<R>;
toHaveTouchFriendlyTargets(options?: { minSize?: number }): Promise<R>;
}
}
}
Loading