diff --git a/ErsatzTV.Mcp.Tests/BoundedLineReaderTests.cs b/ErsatzTV.Mcp.Tests/BoundedLineReaderTests.cs new file mode 100644 index 000000000..c62f62a7b --- /dev/null +++ b/ErsatzTV.Mcp.Tests/BoundedLineReaderTests.cs @@ -0,0 +1,87 @@ +using System.Text; +using ErsatzTV.Mcp; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Mcp.Tests; + +[TestFixture] +public class BoundedLineReaderTests +{ + [Test] + public async Task ReadLineAsync_Should_Return_Line_Without_Trailing_Newline() + { + using var reader = new StringReader("hello world\n"); + + BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024); + + line.EndOfStream.ShouldBeFalse(); + line.Overflowed.ShouldBeFalse(); + line.Text.ShouldBe("hello world"); + } + + [Test] + public async Task ReadLineAsync_Should_Return_Line_Of_Exactly_Cap_Length_Intact() + { + // The cap is the inclusive max: a line of exactly `cap` chars is returned, not overflowed. + using var reader = new StringReader("abcdefgh\n"); + + BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 8); + + line.Overflowed.ShouldBeFalse(); + line.Text.ShouldBe("abcdefgh"); + } + + [Test] + public async Task ReadLineAsync_Should_Strip_Carriage_Return_In_Crlf() + { + using var reader = new StringReader("hello\r\n"); + + BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024); + + line.Text.ShouldBe("hello"); + } + + [Test] + public async Task ReadLineAsync_Should_Signal_End_Of_Stream() + { + using var reader = new StringReader(string.Empty); + + BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024); + + line.EndOfStream.ShouldBeTrue(); + } + + [Test] + public async Task ReadLineAsync_Should_Overflow_And_Not_Buffer_Oversized_Line() + { + // A line far longer than the cap must be reported overflowed with no buffered text — + // the memory-exhaustion guard. + string oversized = new string('x', 10_000) + "\n"; + using var reader = new StringReader(oversized); + + BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 16); + + line.EndOfStream.ShouldBeFalse(); + line.Overflowed.ShouldBeTrue(); + line.Text.ShouldBeEmpty(); + } + + [Test] + public async Task ReadLineAsync_Should_Keep_Subsequent_Lines_Aligned_After_Overflow() + { + // After draining an oversized line, the next line must still be read intact. + var content = new StringBuilder() + .Append(new string('x', 100)).Append('\n') + .Append("good\n") + .ToString(); + using var reader = new StringReader(content); + + BoundedLineReader.Line first = await BoundedLineReader.ReadLineAsync(reader, 16); + BoundedLineReader.Line second = await BoundedLineReader.ReadLineAsync(reader, 16); + + first.Overflowed.ShouldBeTrue(); + second.Overflowed.ShouldBeFalse(); + second.Text.ShouldBe("good"); + } +} diff --git a/ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj b/ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj new file mode 100644 index 000000000..7bd2aef80 --- /dev/null +++ b/ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/ErsatzTV.Mcp.Tests/ErsatzTvApiClientTests.cs b/ErsatzTV.Mcp.Tests/ErsatzTvApiClientTests.cs new file mode 100644 index 000000000..d8652a6a3 --- /dev/null +++ b/ErsatzTV.Mcp.Tests/ErsatzTvApiClientTests.cs @@ -0,0 +1,264 @@ +using System.Net; +using System.Text.Json; +using ErsatzTV.Mcp; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Mcp.Tests; + +[TestFixture] +public class ErsatzTvApiClientTests +{ + [Test] + public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key() + { + CapturingHandler handler = new("""{"id":12,"name":"Kids"}"""); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret")); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition( + "ersatztv_get_channel", + "Get channel", + HttpMethod.Get, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))), + JsonDocument.Parse("""{"id":12}""").RootElement, + CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Text.ShouldBe("""{"id":12,"name":"Kids"}"""); + handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/channels/12")); + handler.ApiKey.ShouldBe("secret"); + } + + [Test] + public async Task CallToolAsync_Should_Url_Encode_Path_Parameters() + { + CapturingHandler handler = new("""{"id":1}"""); + var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null)); + + await client.CallToolAsync( + new ToolDefinition( + "ersatztv_get_resolution_by_name", + "Get resolution", + HttpMethod.Get, + "/api/ffmpeg/resolution/by-name/{name}", + ToolInputSchemas.Object(("name", "string", "Resolution name", true))), + JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement, + CancellationToken.None); + + handler.RequestUri.ShouldBe(new Uri("http://localhost/api/ffmpeg/resolution/by-name/1920%20x%201080")); + } + + [Test] + public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status() + { + CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound); + var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null)); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition( + "ersatztv_get_channel", + "Get channel", + HttpMethod.Get, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))), + JsonDocument.Parse("""{"id":404}""").RootElement, + CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.Text.ShouldContain("404"); + result.Text.ShouldContain("Resource not found"); + } + + [Test] + public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only() + { + CapturingHandler handler = new("{}"); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null)); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition( + "ersatztv_delete_channel", + "Delete channel", + HttpMethod.Delete, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))), + JsonDocument.Parse("""{"id":12}""").RootElement, + CancellationToken.None); + + result.IsError.ShouldBeTrue(); + result.Text.ShouldContain("read-only"); + // The request must never reach the API. + handler.RequestUri.ShouldBeNull(); + } + + [Test] + public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled() + { + CapturingHandler handler = new("""{"ok":true}"""); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true)); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition( + "ersatztv_delete_channel", + "Delete channel", + HttpMethod.Delete, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))), + JsonDocument.Parse("""{"id":12}""").RootElement, + CancellationToken.None); + + result.IsError.ShouldBeFalse(); + handler.RequestUri.ShouldBe(new Uri("http://localhost/api/channels/12")); + } + + [Test] + public async Task CallToolAsync_Should_Truncate_Oversized_Response() + { + CapturingHandler handler = new(new string('x', 500)); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16)); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty), + JsonDocument.Parse("{}").RootElement, + CancellationToken.None); + + result.Text.ShouldStartWith(new string('x', 16)); + result.Text.ShouldContain("truncated"); + result.Text.Length.ShouldBeLessThan(500); + } + + [Test] + public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix() + { + CapturingHandler handler = new("""{"id":12}"""); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null)); + + await client.CallToolAsync( + new ToolDefinition( + "ersatztv_get_channel", + "Get channel", + HttpMethod.Get, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))), + JsonDocument.Parse("""{"id":12}""").RootElement, + CancellationToken.None); + + handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/channels/12")); + } + + [Test] + public async Task CallToolAsync_Should_Reject_Unknown_Argument() + { + CapturingHandler handler = new("{}"); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null)); + + await Should.ThrowAsync(async () => await client.CallToolAsync( + new ToolDefinition( + "ersatztv_get_channel", + "Get channel", + HttpMethod.Get, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))), + JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement, + CancellationToken.None)); + + handler.RequestUri.ShouldBeNull(); + } + + [Test] + public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter() + { + CapturingHandler handler = new("{}"); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null)); + + // ".." would canonicalize the URL onto a different route — must be rejected pre-flight. + await Should.ThrowAsync(async () => await client.CallToolAsync( + new ToolDefinition( + "ersatztv_get_resolution_by_name", + "Get resolution", + HttpMethod.Get, + "/api/ffmpeg/resolution/by-name/{name}", + ToolInputSchemas.Object(("name", "string", "Resolution name", true))), + JsonDocument.Parse("""{"name":".."}""").RootElement, + CancellationToken.None)); + + handler.RequestUri.ShouldBeNull(); + } + + [Test] + public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap() + { + CapturingHandler handler = new("""{"ok":true}"""); + // int.MaxValue would overflow `cap + 1` to a negative array length; the client must + // clamp to the default instead of crashing. + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue)); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty), + JsonDocument.Parse("{}").RootElement, + CancellationToken.None); + + result.IsError.ShouldBeFalse(); + result.Text.ShouldBe("""{"ok":true}"""); + } + + [Test] + public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint() + { + // "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it + // mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char. + CapturingHandler handler = new("ab\U0001F600"); + var client = new ErsatzTvApiClient( + new HttpClient(handler), + new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4)); + + ToolCallResult result = await client.CallToolAsync( + new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty), + JsonDocument.Parse("{}").RootElement, + CancellationToken.None); + + result.Text.ShouldStartWith("ab"); + result.Text.ShouldNotContain("�"); + result.Text.ShouldContain("truncated"); + } + + private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK) + : HttpMessageHandler + { + public Uri? RequestUri { get; private set; } + public string? ApiKey { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUri = request.RequestUri; + ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable? values) + ? values.Single() + : null; + + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(response) + }); + } + } +} diff --git a/ErsatzTV.Mcp.Tests/McpServerTests.cs b/ErsatzTV.Mcp.Tests/McpServerTests.cs new file mode 100644 index 000000000..12ffa2122 --- /dev/null +++ b/ErsatzTV.Mcp.Tests/McpServerTests.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using ErsatzTV.Mcp; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Mcp.Tests; + +[TestFixture] +public class McpServerTests +{ + [Test] + public async Task HandleAsync_Should_Return_Server_Capabilities_For_Initialize() + { + using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"""); + + JsonElement result = response.RootElement.GetProperty("result"); + result.GetProperty("protocolVersion").GetString().ShouldBe("2024-11-05"); + result.GetProperty("serverInfo").GetProperty("name").GetString().ShouldBe("ersatztv-mcp"); + result.GetProperty("capabilities").TryGetProperty("tools", out _).ShouldBeTrue(); + } + + [Test] + public async Task HandleAsync_Should_List_Tools() + { + using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"""); + + string[] toolNames = response.RootElement + .GetProperty("result") + .GetProperty("tools") + .EnumerateArray() + .Select(t => t.GetProperty("name").GetString()) + .OfType() + .ToArray(); + + toolNames.ShouldContain("ersatztv_list_channels"); + toolNames.ShouldContain("ersatztv_get_version"); + } + + [Test] + public async Task HandleAsync_Should_Call_Tool_And_Return_Text_Content() + { + using JsonDocument response = await HandleAsync( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""", + new StubToolExecutor("""{"apiVersion":3,"appVersion":"develop"}""")); + + JsonElement content = response.RootElement.GetProperty("result").GetProperty("content").EnumerateArray().Single(); + content.GetProperty("type").GetString().ShouldBe("text"); + content.GetProperty("text").GetString().ShouldBe("""{"apiVersion":3,"appVersion":"develop"}"""); + } + + [Test] + public async Task HandleAsync_Should_Return_Method_Not_Found_For_Unknown_Tool() + { + using JsonDocument response = await HandleAsync( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"missing","arguments":{}}}"""); + + response.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32602); + string message = response.RootElement.GetProperty("error").GetProperty("message").GetString().ShouldNotBeNull(); + message.ShouldContain("Unknown tool"); + } + + [Test] + public async Task HandleAsync_Should_Return_Parse_Error_For_Malformed_Json() + { + McpServer server = new(new StubToolExecutor("{}")); + + // A malformed line must be answered with a JSON-RPC parse error, never crash the loop. + string? response = await server.HandleAsync("{ this is not json", CancellationToken.None); + + using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull()); + document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32700); + document.RootElement.GetProperty("id").ValueKind.ShouldBe(JsonValueKind.Null); + } + + [Test] + public async Task HandleAsync_Should_Return_Invalid_Request_For_Missing_Method() + { + McpServer server = new(new StubToolExecutor("{}")); + + string? response = await server.HandleAsync("""{"jsonrpc":"2.0","id":7}""", CancellationToken.None); + + using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull()); + document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600); + document.RootElement.GetProperty("id").GetInt32().ShouldBe(7); + } + + [Test] + public async Task HandleAsync_Should_Return_Invalid_Request_For_Non_Object_Request() + { + McpServer server = new(new StubToolExecutor("{}")); + + string? response = await server.HandleAsync("5", CancellationToken.None); + + using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull()); + document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600); + } + + [Test] + public async Task HandleAsync_Should_Ignore_Notifications() + { + McpServer server = new(new StubToolExecutor("{}")); + + string? response = await server.HandleAsync("""{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", CancellationToken.None); + + response.ShouldBeNull(); + } + + [Test] + public async Task HandleAsync_Should_Return_Internal_Error_When_Executor_Throws_Transport_Error() + { + // A network/transport failure must still yield a JSON-RPC error for the id, not escape + // HandleAsync (which would leave a compliant client hanging). + McpServer server = new(new ThrowingToolExecutor(new HttpRequestException("connection refused"))); + + string? response = await server.HandleAsync( + """{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""", + CancellationToken.None); + + using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull()); + document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32603); + document.RootElement.GetProperty("id").GetInt32().ShouldBe(9); + } + + private static async Task HandleAsync(string request, IToolExecutor? executor = null) + { + McpServer server = new(executor ?? new StubToolExecutor("{}")); + string? response = await server.HandleAsync(request, CancellationToken.None); + response.ShouldNotBeNull(); + return JsonDocument.Parse(response); + } + + private sealed class StubToolExecutor(string response) : IToolExecutor + { + public Task CallToolAsync( + ToolDefinition tool, + JsonElement arguments, + CancellationToken cancellationToken) => + Task.FromResult(new ToolCallResult(false, response)); + } + + private sealed class ThrowingToolExecutor(Exception exception) : IToolExecutor + { + public Task CallToolAsync( + ToolDefinition tool, + JsonElement arguments, + CancellationToken cancellationToken) => + throw exception; + } +} diff --git a/ErsatzTV.Mcp.Tests/ToolArgumentValidatorTests.cs b/ErsatzTV.Mcp.Tests/ToolArgumentValidatorTests.cs new file mode 100644 index 000000000..7a221a621 --- /dev/null +++ b/ErsatzTV.Mcp.Tests/ToolArgumentValidatorTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using ErsatzTV.Mcp; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Mcp.Tests; + +[TestFixture] +public class ToolArgumentValidatorTests +{ + private static ToolDefinition IdTool() => new( + "ersatztv_get_channel", + "Get channel", + HttpMethod.Get, + "/api/channels/{id}", + ToolInputSchemas.Object(("id", "integer", "Channel id", true))); + + [Test] + public void Validate_Should_Accept_Well_Formed_Arguments() + { + Should.NotThrow(() => + ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12}""").RootElement)); + } + + [Test] + public void Validate_Should_Accept_Empty_Arguments_For_No_Param_Tool() + { + ToolDefinition tool = new("ersatztv_get_version", "Version", HttpMethod.Get, "/api/version", ToolInputSchemas.Empty); + + Should.NotThrow(() => + ToolArgumentValidator.Validate(tool, JsonDocument.Parse("{}").RootElement)); + } + + [Test] + public void Validate_Should_Reject_Unknown_Argument() + { + Should.Throw(() => + ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12,"extra":1}""").RootElement)); + } + + [Test] + public void Validate_Should_Reject_Missing_Required_Argument() + { + Should.Throw(() => + ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("{}").RootElement)); + } + + [Test] + public void Validate_Should_Reject_Wrong_Type() + { + Should.Throw(() => + ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":"twelve"}""").RootElement)); + } + + [Test] + public void Validate_Should_Reject_Non_Object_Arguments() + { + Should.Throw(() => + ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("[]").RootElement)); + } +} diff --git a/ErsatzTV.Mcp.Tests/ToolCatalogTests.cs b/ErsatzTV.Mcp.Tests/ToolCatalogTests.cs new file mode 100644 index 000000000..ce47ad348 --- /dev/null +++ b/ErsatzTV.Mcp.Tests/ToolCatalogTests.cs @@ -0,0 +1,63 @@ +using ErsatzTV.Mcp; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Mcp.Tests; + +[TestFixture] +public class ToolCatalogTests +{ + [Test] + public void All_Should_Expose_Read_First_Current_Api_Tools() + { + string[] names = ToolCatalog.All.Select(t => t.Name).ToArray(); + + names.ShouldContain("ersatztv_list_channels"); + names.ShouldContain("ersatztv_get_channel"); + names.ShouldContain("ersatztv_list_collections"); + names.ShouldContain("ersatztv_get_collection"); + names.ShouldContain("ersatztv_list_smart_collections"); + names.ShouldContain("ersatztv_get_smart_collection"); + names.ShouldContain("ersatztv_list_schedules"); + names.ShouldContain("ersatztv_get_schedule"); + names.ShouldContain("ersatztv_list_schedule_items"); + names.ShouldContain("ersatztv_get_playout"); + names.ShouldContain("ersatztv_list_ffmpeg_profiles"); + names.ShouldContain("ersatztv_get_ffmpeg_profile"); + names.ShouldContain("ersatztv_get_resolution_by_name"); + names.ShouldContain("ersatztv_list_sessions"); + names.ShouldContain("ersatztv_get_version"); + } + + [Test] + public void All_Should_Not_Expose_Deferred_Redesign_Workflow_Tools() + { + string[] names = ToolCatalog.All.Select(t => t.Name).ToArray(); + + names.ShouldNotContain("ersatztv_create_channel_from_lineup"); + names.ShouldNotContain("ersatztv_list_channel_templates"); + names.ShouldNotContain("ersatztv_browse_library"); + names.ShouldNotContain("ersatztv_upload_channel_logo"); + names.ShouldNotContain("ersatztv_resume_playback"); + } + + [Test] + public void All_Should_Only_Expose_Get_Tools_In_V0() + { + ToolCatalog.All + .Where(t => t.HttpMethod != HttpMethod.Get) + .Select(t => t.Name) + .ShouldBeEmpty(); + } + + [Test] + public void Get_Channel_Tool_Should_Have_OpenApi_Aligned_Path_And_Id_Input() + { + ToolDefinition tool = ToolCatalog.Find("ersatztv_get_channel").ShouldNotBeNull(); + + tool.HttpMethod.ShouldBe(HttpMethod.Get); + tool.PathTemplate.ShouldBe("/api/channels/{id}"); + tool.InputSchema.RootElement.GetProperty("required").EnumerateArray().Single().GetString().ShouldBe("id"); + tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue(); + } +} diff --git a/ErsatzTV.Mcp/BoundedLineReader.cs b/ErsatzTV.Mcp/BoundedLineReader.cs new file mode 100644 index 000000000..6d508b9a9 --- /dev/null +++ b/ErsatzTV.Mcp/BoundedLineReader.cs @@ -0,0 +1,56 @@ +namespace ErsatzTV.Mcp; + +/// +/// Reads newline-delimited lines from a with a hard character cap, so a +/// hostile client cannot exhaust memory by sending an enormous line with no newline. A line longer +/// than the cap is drained (not buffered) and reported as overflowed rather than returned. +/// +public static class BoundedLineReader +{ + public readonly record struct Line(bool EndOfStream, bool Overflowed, string Text); + + public const int DefaultMaxChars = 1024 * 1024; + + public static async Task ReadLineAsync(TextReader reader, int maxChars = DefaultMaxChars) + { + int cap = maxChars > 0 ? maxChars : DefaultMaxChars; + var builder = new System.Text.StringBuilder(); + var buffer = new char[1]; + bool sawAny = false; + bool overflowed = false; + + while (await reader.ReadAsync(buffer, 0, 1) == 1) + { + sawAny = true; + char c = buffer[0]; + if (c == '\n') + { + return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString()); + } + + if (c == '\r') + { + continue; + } + + if (overflowed || builder.Length >= cap) + { + // Past the cap: stop buffering and free what we have, but keep draining to the + // newline so the next line stays aligned. + overflowed = true; + builder.Clear(); + continue; + } + + builder.Append(c); + } + + if (!sawAny) + { + return new Line(true, false, string.Empty); + } + + // Final line with no trailing newline. + return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString()); + } +} diff --git a/ErsatzTV.Mcp/ErsatzTV.Mcp.csproj b/ErsatzTV.Mcp/ErsatzTV.Mcp.csproj new file mode 100644 index 000000000..cd19a3563 --- /dev/null +++ b/ErsatzTV.Mcp/ErsatzTV.Mcp.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/ErsatzTV.Mcp/ErsatzTvApiClient.cs b/ErsatzTV.Mcp/ErsatzTvApiClient.cs new file mode 100644 index 000000000..87a489512 --- /dev/null +++ b/ErsatzTV.Mcp/ErsatzTvApiClient.cs @@ -0,0 +1,154 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ErsatzTV.Mcp; + +public sealed partial class ErsatzTvApiClient(HttpClient httpClient, ErsatzTvApiClientOptions options) : IToolExecutor +{ + public async Task CallToolAsync( + ToolDefinition tool, + JsonElement arguments, + CancellationToken cancellationToken) + { + // Runtime backstop for the read-only posture: even if a catalog entry is wrong, + // a non-GET tool cannot execute unless writes are explicitly enabled. This is the + // forward-compatible seam for future write/operational tools (#58) — they run only + // when the operator opts in via ERSATZTV_ALLOW_WRITES. + if (!options.AllowWrites && tool.HttpMethod != HttpMethod.Get) + { + return new ToolCallResult( + true, + $"Refused: tool '{tool.Name}' uses HTTP {tool.HttpMethod.Method}, but this MCP server is " + + "read-only. Set ERSATZTV_ALLOW_WRITES=true to enable write/operational tools."); + } + + ToolArgumentValidator.Validate(tool, arguments); + + string path = BuildPath(tool.PathTemplate, arguments); + using var request = new HttpRequestMessage(tool.HttpMethod, CombineUri(options.BaseUrl, path)); + if (!string.IsNullOrWhiteSpace(options.ApiKey)) + { + request.Headers.Add("X-Api-Key", options.ApiKey); + } + + // ResponseHeadersRead streams the body so we can cap it without buffering the whole + // thing — but that moves the body read outside HttpClient.Timeout, so a per-request + // timeout token must cover the entire operation (headers + body) or a slow-drip + // upstream would hang the single-threaded session. + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(options.EffectiveRequestTimeout); + CancellationToken token = timeoutCts.Token; + + using HttpResponseMessage response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + token); + string body = await ReadCappedBodyAsync(response.Content, token); + if (response.IsSuccessStatusCode) + { + return new ToolCallResult(false, body); + } + + string message = $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}"; + return new ToolCallResult(true, message); + } + + // Read at most MaxResponseBytes from the response, truncating gracefully with a marker + // rather than buffering an unbounded body into memory / the model's context. + private async Task ReadCappedBodyAsync(HttpContent content, CancellationToken cancellationToken) + { + int cap = options.MaxResponseBytes is > 0 and <= ErsatzTvApiClientOptions.MaxAllowedResponseBytes + ? options.MaxResponseBytes + : ErsatzTvApiClientOptions.DefaultMaxResponseBytes; + await using Stream stream = await content.ReadAsStreamAsync(cancellationToken); + + // One extra byte lets us detect (but not keep) overflow past the cap. + byte[] buffer = new byte[cap + 1]; + int total = 0; + int read; + while (total < buffer.Length + && (read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken)) > 0) + { + total += read; + } + + bool truncated = total > cap; + int length = truncated ? TrimToUtf8Boundary(buffer, cap) : total; + string text = Encoding.UTF8.GetString(buffer, 0, length); + return truncated + ? text + $"\n…[truncated: response exceeded {cap} bytes]" + : text; + } + + // When cutting at a fixed byte cap, back off any incomplete trailing UTF-8 sequence so the + // decoded text ends on a complete code point instead of a U+FFFD replacement char. + private static int TrimToUtf8Boundary(byte[] buffer, int length) + { + int i = length; + while (i > 0 && (buffer[i - 1] & 0b1100_0000) == 0b1000_0000) + { + i--; // step back over UTF-8 continuation bytes (10xxxxxx) + } + + if (i == 0) + { + return length; // all continuation bytes (not valid UTF-8) — leave the cut as-is + } + + byte lead = buffer[i - 1]; + int expected = lead switch + { + < 0x80 => 1, + >= 0xF0 => 4, + >= 0xE0 => 3, + >= 0xC0 => 2, + _ => 1 // stray continuation byte as "lead"; leave the cut as-is + }; + + // Keep the sequence if it is complete within the cap; otherwise drop the incomplete lead. + return length - (i - 1) >= expected ? length : i - 1; + } + + private static Uri CombineUri(Uri baseUrl, string absolutePath) + { + // absolutePath is a root-relative "/api/..." path. new Uri(baseUrl, "/api/...") would + // discard any path prefix on baseUrl (e.g. a reverse-proxy mount like http://host/etv/), + // so combine on the base's full path instead to preserve the prefix. + string prefix = baseUrl.GetLeftPart(UriPartial.Path).TrimEnd('/'); + return new Uri(prefix + absolutePath); + } + + private static string BuildPath(string pathTemplate, JsonElement arguments) + { + string path = pathTemplate; + foreach (JsonProperty property in arguments.EnumerateObject()) + { + string value = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() ?? string.Empty + : property.Value.GetRawText(); + + // EscapeDataString escapes '/', but bare "." / ".." survive and would collapse the + // URL onto a different route during Uri canonicalization — reject them. This assumes + // each {param} is its own path segment (true for every current template); if a template + // ever concatenates two adjacent params, revalidate the substituted path as a whole. + if (value is "." or "..") + { + throw new ArgumentException($"Invalid value for argument '{property.Name}': '{value}'."); + } + + path = path.Replace("{" + property.Name + "}", Uri.EscapeDataString(value), StringComparison.Ordinal); + } + + Match unresolved = PathParameterRegex().Match(path); + if (unresolved.Success) + { + throw new ArgumentException($"Missing required argument '{unresolved.Groups[1].Value}'"); + } + + return path; + } + + [GeneratedRegex(@"\{([^}]+)\}")] + private static partial Regex PathParameterRegex(); +} diff --git a/ErsatzTV.Mcp/McpServer.cs b/ErsatzTV.Mcp/McpServer.cs new file mode 100644 index 000000000..652425a7e --- /dev/null +++ b/ErsatzTV.Mcp/McpServer.cs @@ -0,0 +1,166 @@ +using System.Text.Json; + +namespace ErsatzTV.Mcp; + +public sealed class McpServer(IToolExecutor toolExecutor) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + // Process-lifetime document so NullId stays valid; used as the JSON-RPC id for + // parse errors / requests with no usable id. + private static readonly JsonDocument NullIdDocument = JsonDocument.Parse("null"); + private static readonly JsonElement NullId = NullIdDocument.RootElement; + + public async Task HandleAsync(string requestJson, CancellationToken cancellationToken) + { + JsonDocument request; + try + { + request = JsonDocument.Parse(requestJson); + } + catch (JsonException) + { + // A malformed line must never crash the session loop (JSON-RPC parse error, id null). + return SerializeError(NullId, -32700, "Parse error: invalid JSON."); + } + + using (request) + { + JsonElement root = request.RootElement; + JsonElement id = NullId; + bool hasId = false; + if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("id", out JsonElement idValue)) + { + id = idValue; + hasId = true; + } + + try + { + if (root.ValueKind != JsonValueKind.Object) + { + throw new JsonRpcException(-32600, "Invalid Request: expected a JSON-RPC object."); + } + + if (!root.TryGetProperty("method", out JsonElement methodElement) + || methodElement.ValueKind != JsonValueKind.String) + { + throw new JsonRpcException(-32600, "Invalid Request: missing or non-string 'method'."); + } + + // No id ⇒ notification ⇒ no response. + if (!hasId) + { + return null; + } + + string? method = methodElement.GetString(); + object result = method switch + { + "initialize" => InitializeResult(), + "tools/list" => ToolsListResult(), + "tools/call" => await CallToolAsync(RequireParams(root), cancellationToken), + _ => throw new JsonRpcException(-32601, $"Method not found: {method}") + }; + + return SerializeResponse(id, result); + } + catch (JsonRpcException ex) + { + return SerializeError(id, ex.Code, ex.Message); + } + catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException or InvalidOperationException) + { + return hasId ? SerializeError(id, -32602, ex.Message) : null; + } + catch (Exception ex) + { + // Transport/timeout/unexpected failures (HttpRequestException, a fired request + // timeout, etc.) must still return a JSON-RPC error for the id — otherwise a + // compliant client blocks forever awaiting a response that never comes. + return hasId ? SerializeError(id, -32603, $"Internal error: {ex.Message}") : null; + } + } + } + + private static JsonElement RequireParams(JsonElement root) => + root.TryGetProperty("params", out JsonElement parameters) + ? parameters + : throw new JsonRpcException(-32602, "Invalid params: missing 'params'."); + + private static object InitializeResult() => new + { + protocolVersion = "2024-11-05", + capabilities = new + { + tools = new { } + }, + serverInfo = new + { + name = "ersatztv-mcp", + version = "0.1.0" + } + }; + + private static object ToolsListResult() => new + { + tools = ToolCatalog.All.Select(t => new + { + name = t.Name, + description = t.Description, + inputSchema = t.InputSchema.RootElement + }) + }; + + private async Task CallToolAsync(JsonElement parameters, CancellationToken cancellationToken) + { + string name = parameters.GetProperty("name").GetString() ?? throw new ArgumentException("Tool name is required"); + ToolDefinition tool = ToolCatalog.Find(name) ?? throw new ArgumentException($"Unknown tool: {name}"); + JsonElement arguments = parameters.TryGetProperty("arguments", out JsonElement args) + ? args + : JsonDocument.Parse("{}").RootElement; + + ToolCallResult result = await toolExecutor.CallToolAsync(tool, arguments, cancellationToken); + return new + { + content = new[] + { + new + { + type = "text", + text = result.Text + } + }, + isError = result.IsError + }; + } + + private static string SerializeResponse(JsonElement id, object result) => + JsonSerializer.Serialize( + new + { + jsonrpc = "2.0", + id = id.Clone(), + result + }, + JsonOptions); + + private static string SerializeError(JsonElement id, int code, string message) => + JsonSerializer.Serialize( + new + { + jsonrpc = "2.0", + id = id.Clone(), + error = new + { + code, + message + } + }, + JsonOptions); + + private sealed class JsonRpcException(int code, string message) : Exception(message) + { + public int Code { get; } = code; + } +} diff --git a/ErsatzTV.Mcp/Program.cs b/ErsatzTV.Mcp/Program.cs new file mode 100644 index 000000000..b6653191f --- /dev/null +++ b/ErsatzTV.Mcp/Program.cs @@ -0,0 +1,87 @@ +namespace ErsatzTV.Mcp; + +public static class Program +{ + public static async Task Main() + { + string baseUrl = Environment.GetEnvironmentVariable("ERSATZTV_URL") ?? "http://localhost:8409"; + string? apiKey = Environment.GetEnvironmentVariable("ERSATZTV_API_KEY"); + bool allowWrites = ParseBool(Environment.GetEnvironmentVariable("ERSATZTV_ALLOW_WRITES")); + int maxResponseBytes = ParseInt( + Environment.GetEnvironmentVariable("ERSATZTV_MAX_RESPONSE_BYTES"), + fallback: ErsatzTvApiClientOptions.DefaultMaxResponseBytes, + min: 1024, + max: ErsatzTvApiClientOptions.MaxAllowedResponseBytes); + int timeoutSeconds = ParseInt( + Environment.GetEnvironmentVariable("ERSATZTV_REQUEST_TIMEOUT_SECONDS"), + fallback: 30, + min: 1, + max: 3600); + var requestTimeout = TimeSpan.FromSeconds(timeoutSeconds); + + // The per-request timeout is enforced via a CancellationToken inside the client (it must + // cover the streamed body read too), so leave HttpClient's own timeout off to avoid a + // second, header-only timer racing it. + using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; + var apiClient = new ErsatzTvApiClient( + httpClient, + new ErsatzTvApiClientOptions(new Uri(baseUrl), apiKey, allowWrites, maxResponseBytes, requestTimeout)); + var server = new McpServer(apiClient); + + while (true) + { + BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(Console.In); + if (line.EndOfStream) + { + break; + } + + if (line.Overflowed) + { + await Console.Error.WriteLineAsync("[ersatztv-mcp] dropped oversized request line."); + continue; + } + + if (string.IsNullOrWhiteSpace(line.Text)) + { + continue; + } + + try + { + string? response = await server.HandleAsync(line.Text, CancellationToken.None); + if (response is not null) + { + await Console.Out.WriteLineAsync(response); + await Console.Out.FlushAsync(); + } + } + catch (Exception ex) + { + // Last-resort guard: a single failed request must never terminate the session. + await Console.Error.WriteLineAsync($"[ersatztv-mcp] error handling request: {ex.Message}"); + } + } + } + + private static bool ParseBool(string? value) + { + if (value is null) + { + return false; + } + + if (bool.TryParse(value, out bool parsed)) + { + return parsed; + } + + string trimmed = value.Trim(); + return trimmed is "1" + || string.Equals(trimmed, "yes", StringComparison.OrdinalIgnoreCase) + || string.Equals(trimmed, "on", StringComparison.OrdinalIgnoreCase); + } + + private static int ParseInt(string? value, int fallback, int min, int max) => + int.TryParse(value, out int parsed) ? Math.Clamp(parsed, min, max) : fallback; +} diff --git a/ErsatzTV.Mcp/ToolArgumentValidator.cs b/ErsatzTV.Mcp/ToolArgumentValidator.cs new file mode 100644 index 000000000..2c7684e6a --- /dev/null +++ b/ErsatzTV.Mcp/ToolArgumentValidator.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace ErsatzTV.Mcp; + +/// +/// Lightweight validation of caller-supplied tool arguments against a tool's declared +/// InputSchema. Not a full JSON-Schema implementation — it enforces the shapes the +/// catalog actually emits (typed properties, a required list, additionalProperties:false) +/// so unknown/malformed arguments are rejected before an API request is built. +/// Throws (mapped to JSON-RPC -32602 by the server). +/// +public static class ToolArgumentValidator +{ + public static void Validate(ToolDefinition tool, JsonElement arguments) + { + if (arguments.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException($"Arguments for tool '{tool.Name}' must be a JSON object."); + } + + JsonElement schema = tool.InputSchema.RootElement; + JsonElement properties = schema.TryGetProperty("properties", out JsonElement props) + ? props + : default; + bool additionalAllowed = !schema.TryGetProperty("additionalProperties", out JsonElement additional) + || additional.ValueKind != JsonValueKind.False; + + foreach (JsonProperty arg in arguments.EnumerateObject()) + { + if (properties.ValueKind != JsonValueKind.Object + || !properties.TryGetProperty(arg.Name, out JsonElement propertySchema)) + { + if (!additionalAllowed) + { + throw new ArgumentException($"Unknown argument '{arg.Name}' for tool '{tool.Name}'."); + } + + continue; + } + + string? type = propertySchema.TryGetProperty("type", out JsonElement typeElement) + ? typeElement.GetString() + : null; + if (!MatchesType(type, arg.Value)) + { + throw new ArgumentException( + $"Argument '{arg.Name}' for tool '{tool.Name}' must be of type '{type}'."); + } + } + + if (schema.TryGetProperty("required", out JsonElement required) + && required.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement name in required.EnumerateArray()) + { + string? propertyName = name.GetString(); + if (propertyName is not null && !arguments.TryGetProperty(propertyName, out _)) + { + throw new ArgumentException( + $"Missing required argument '{propertyName}' for tool '{tool.Name}'."); + } + } + } + } + + private static bool MatchesType(string? type, JsonElement value) => type switch + { + "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _), + "number" => value.ValueKind == JsonValueKind.Number, + "string" => value.ValueKind == JsonValueKind.String, + "boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False, + "array" => value.ValueKind == JsonValueKind.Array, + "object" => value.ValueKind == JsonValueKind.Object, + _ => true + }; +} diff --git a/ErsatzTV.Mcp/ToolCatalog.cs b/ErsatzTV.Mcp/ToolCatalog.cs new file mode 100644 index 000000000..d1907bf3b --- /dev/null +++ b/ErsatzTV.Mcp/ToolCatalog.cs @@ -0,0 +1,45 @@ +namespace ErsatzTV.Mcp; + +public static class ToolCatalog +{ + public static IReadOnlyList All { get; } = + [ + Get("ersatztv_list_channels", "List channels.", "/api/channels"), + Get("ersatztv_get_channel", "Get a channel by id.", "/api/channels/{id}", Id("id", "Channel id.")), + Get("ersatztv_list_collections", "List collections.", "/api/collections"), + Get("ersatztv_get_collection", "Get a collection by id.", "/api/collections/{id}", Id("id", "Collection id.")), + Get("ersatztv_list_smart_collections", "List smart collections.", "/api/smart-collections"), + Get("ersatztv_get_smart_collection", "Get a smart collection by id.", "/api/smart-collections/{id}", Id("id", "Smart collection id.")), + Get("ersatztv_list_schedules", "List schedules.", "/api/schedules"), + Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/schedules/{id}", Id("id", "Schedule id.")), + Get("ersatztv_list_schedule_items", "List schedule items.", "/api/schedules/{id}/items", Id("id", "Schedule id.")), + Get("ersatztv_get_playout", "Get a playout by id.", "/api/playouts/{id}", Id("id", "Playout id.")), + Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/ffmpeg/profiles"), + Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/ffmpeg/profiles/{id}", Id("id", "FFmpeg profile id.")), + Get( + "ersatztv_get_resolution_by_name", + "Get an FFmpeg resolution by name.", + "/api/ffmpeg/resolution/by-name/{name}", + ("name", "string", "Resolution name.", true)), + Get("ersatztv_list_sessions", "List active HLS sessions.", "/api/sessions"), + Get("ersatztv_get_version", "Get API and app version.", "/api/version") + ]; + + public static ToolDefinition? Find(string name) => + All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal)); + + private static ToolDefinition Get( + string name, + string description, + string path, + params (string Name, string Type, string Description, bool Required)[] properties) => + new( + name, + description, + HttpMethod.Get, + path, + properties.Length == 0 ? ToolInputSchemas.Empty : ToolInputSchemas.Object(properties)); + + private static (string Name, string Type, string Description, bool Required) Id(string name, string description) => + (name, "integer", description, true); +} diff --git a/ErsatzTV.Mcp/ToolDefinition.cs b/ErsatzTV.Mcp/ToolDefinition.cs new file mode 100644 index 000000000..b1cd47829 --- /dev/null +++ b/ErsatzTV.Mcp/ToolDefinition.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace ErsatzTV.Mcp; + +public sealed record ToolDefinition( + string Name, + string Description, + HttpMethod HttpMethod, + string PathTemplate, + JsonDocument InputSchema); + +public sealed record ToolCallResult(bool IsError, string Text); + +public sealed record ErsatzTvApiClientOptions( + Uri BaseUrl, + string? ApiKey, + bool AllowWrites = false, + int MaxResponseBytes = ErsatzTvApiClientOptions.DefaultMaxResponseBytes, + TimeSpan RequestTimeout = default) +{ + // Cap the response body buffered back to the model so a large/hostile API + // response cannot exhaust memory or flood the context window. + public const int DefaultMaxResponseBytes = 1024 * 1024; + + // Hard ceiling so a hostile/typo'd cap can't request a huge (or overflowing) allocation. + public const int MaxAllowedResponseBytes = 64 * 1024 * 1024; + + public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30); + + // The per-request timeout, covering headers *and* body (see ErsatzTvApiClient). + public TimeSpan EffectiveRequestTimeout => RequestTimeout > TimeSpan.Zero ? RequestTimeout : DefaultRequestTimeout; +} + +public interface IToolExecutor +{ + Task CallToolAsync( + ToolDefinition tool, + JsonElement arguments, + CancellationToken cancellationToken); +} diff --git a/ErsatzTV.Mcp/ToolInputSchemas.cs b/ErsatzTV.Mcp/ToolInputSchemas.cs new file mode 100644 index 000000000..298ea3a40 --- /dev/null +++ b/ErsatzTV.Mcp/ToolInputSchemas.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace ErsatzTV.Mcp; + +public static class ToolInputSchemas +{ + public static JsonDocument Empty { get; } = JsonDocument.Parse( + """ + {"type":"object","properties":{},"additionalProperties":false} + """); + + public static JsonDocument Object(params (string Name, string Type, string Description, bool Required)[] properties) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("type", "object"); + writer.WriteStartObject("properties"); + foreach ((string name, string type, string description, bool _) in properties) + { + writer.WriteStartObject(name); + writer.WriteString("type", type); + writer.WriteString("description", description); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteStartArray("required"); + foreach ((string name, string _, string _, bool required) in properties.Where(p => p.Required)) + { + writer.WriteStringValue(name); + } + + writer.WriteEndArray(); + writer.WriteBoolean("additionalProperties", false); + writer.WriteEndObject(); + } + + return JsonDocument.Parse(stream.ToArray()); + } +} diff --git a/ErsatzTV.sln b/ErsatzTV.sln index 589b7d4a6..4608d5ed1 100644 --- a/ErsatzTV.sln +++ b/ErsatzTV.sln @@ -32,6 +32,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp", "ErsatzTV.Mcp\ErsatzTV.Mcp.csproj", "{A5BB7668-FE00-49F8-888C-866F75A74BD9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp.Tests", "ErsatzTV.Mcp.Tests\ErsatzTV.Mcp.Tests.csproj", "{C72D3941-6207-4638-AA2A-B5488EDFED28}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -315,6 +319,42 @@ Global {49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU {49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU {49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x64.Build.0 = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x86.Build.0 = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|Any CPU.Build.0 = Release|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x64.ActiveCfg = Release|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x64.Build.0 = Release|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x86.ActiveCfg = Release|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x86.Build.0 = Release|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x64.Build.0 = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU + {A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x86.Build.0 = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x64.ActiveCfg = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x64.Build.0 = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x86.ActiveCfg = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x86.Build.0 = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|Any CPU.Build.0 = Release|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x64.ActiveCfg = Release|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x64.Build.0 = Release|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x86.ActiveCfg = Release|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x86.Build.0 = Release|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x64.Build.0 = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU + {C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x86.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..3ba157123 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,75 @@ +# ErsatzTV MCP Server + +`ErsatzTV.Mcp` is a v0 MCP foundation over the current stable ErsatzTV/ChicoryTV REST API. It is intentionally read-first and maps explicit MCP tools to current OpenAPI-backed endpoints in `ErsatzTV/wwwroot/openapi/v1.json`. + +It does not scrape the web UI and does not read or write SQLite directly. + +## Running + +Build the server: + +```bash +dotnet build ErsatzTV.Mcp/ErsatzTV.Mcp.csproj +``` + +Configure an MCP client to start: + +```bash +dotnet run --project /path/to/ersatztv/ErsatzTV.Mcp/ErsatzTV.Mcp.csproj +``` + +Environment variables: + +| Variable | Default | Purpose | +|---|---:|---| +| `ERSATZTV_URL` | `http://localhost:8409` | Base URL for the ErsatzTV API. A reverse-proxy path prefix (e.g. `https://host/etv/`) is preserved. | +| `ERSATZTV_API_KEY` | unset | Sent as `X-Api-Key` on every API request when configured. | +| `ERSATZTV_ALLOW_WRITES` | `false` | Read-only posture. While `false`, the executor refuses any non-GET tool before it reaches the API — the runtime backstop behind the read-only catalog. Set `true` only once write/operational tools exist and are wanted. | +| `ERSATZTV_MAX_RESPONSE_BYTES` | `1048576` | Cap on the API response body buffered back to the model; larger responses are truncated with a marker. | +| `ERSATZTV_REQUEST_TIMEOUT_SECONDS` | `30` | Per-request HTTP timeout. | + +**`ERSATZTV_API_KEY` is now effectively required.** The ErsatzTV API gates every `/api/*` route (reads included) behind `X-Api-Key` by default (`Api:RequireKeyForReads`); without a key the MCP server's tool calls return `401`. The client sends the key on every request when configured. (`/iptv/*` and `/artwork/*` remain open — those are outside the MCP surface.) + +## Security posture + +- **Read-only by default (runtime-enforced).** The tool catalog is all-GET, and the executor additionally refuses any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true` — so a single wrong catalog entry cannot mutate or delete. Future write/operational tools (#58) slot in behind that opt-in and must still be individually safe/idempotent. +- **Malformed input never crashes the session.** Invalid JSON is answered with a JSON-RPC `-32700` parse error (id `null`); a malformed request object gets `-32600`; the `Program.Main` read loop also catches any unexpected per-line error so one bad line can't terminate the server. +- **Bounded responses.** API bodies are read up to `ERSATZTV_MAX_RESPONSE_BYTES` and truncated, and every request has an HTTP timeout — a large or slow upstream response can't exhaust memory or hang the session. +- **Arguments are validated** against each tool's declared `InputSchema` (required present, no unknown args, basic types) before a request is built. +- **Tool results are untrusted data.** Response bodies (media titles, file paths, etc.) can be attacker-influenced and are returned to the model verbatim. Treat all tool output as data, never as instructions; the consuming agent's system prompt should frame it as such. This is the standard prompt-injection caveat for any tool that surfaces external content. + +## v0 Tools + +Current v0 tools are explicit and narrow: + +| Tool | API route | +|---|---| +| `ersatztv_list_channels` | `GET /api/channels` | +| `ersatztv_get_channel` | `GET /api/channels/{id}` | +| `ersatztv_list_collections` | `GET /api/collections` | +| `ersatztv_get_collection` | `GET /api/collections/{id}` | +| `ersatztv_list_smart_collections` | `GET /api/smart-collections` | +| `ersatztv_get_smart_collection` | `GET /api/smart-collections/{id}` | +| `ersatztv_list_schedules` | `GET /api/schedules` | +| `ersatztv_get_schedule` | `GET /api/schedules/{id}` | +| `ersatztv_list_schedule_items` | `GET /api/schedules/{id}/items` | +| `ersatztv_get_playout` | `GET /api/playouts/{id}` | +| `ersatztv_list_ffmpeg_profiles` | `GET /api/ffmpeg/profiles` | +| `ersatztv_get_ffmpeg_profile` | `GET /api/ffmpeg/profiles/{id}` | +| `ersatztv_get_resolution_by_name` | `GET /api/ffmpeg/resolution/by-name/{name}` | +| `ersatztv_list_sessions` | `GET /api/sessions` | +| `ersatztv_get_version` | `GET /api/version` | + +Write and operational action tools are deferred until the API contract and tool ergonomics are reviewed for v0.1. + +## Deferred + +The MCP server deliberately does not include redesign-aware workflow tools yet: + +- create-channel-from-lineup workflow: deferred until #63 exists +- Channel Templates: deferred until #64 exists +- library browse/search with artwork: deferred until #65 exists +- image, logo, and watermark workflows: deferred until #66/#67 exist +- resume/bookmark playback tools: deferred until #68 exists + +These should wrap future backend endpoints once those contracts exist instead of recreating workflows inside MCP. diff --git a/docs/superpowers/plans/2026-07-02-ersatztv-mcp-v0-plan.md b/docs/superpowers/plans/2026-07-02-ersatztv-mcp-v0-plan.md new file mode 100644 index 000000000..69c554a8a --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-ersatztv-mcp-v0-plan.md @@ -0,0 +1,54 @@ +# ErsatzTV MCP v0 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a v0 MCP server that exposes safe read tools over the current stable ErsatzTV REST/OpenAPI surface. + +**Architecture:** Add a small `ErsatzTV.Mcp` console project that speaks MCP JSON-RPC over stdio and calls the configured ErsatzTV HTTP API through explicit tool definitions. Keep contracts narrow and OpenAPI-aligned, with no UI scraping, no SQLite access, and no redesign workflow tools. + +**Tech Stack:** .NET 10, `System.Text.Json`, `HttpClient`, NUnit/Shouldly tests, Central Package Management. + +--- + +### Task 1: MCP Tool Catalog + +**Files:** +- Create: `ErsatzTV.Mcp/ToolCatalog.cs` +- Test: `ErsatzTV.Mcp.Tests/ToolCatalogTests.cs` + +- [x] Write tests proving the catalog exposes read-first tools for channels, collections, smart collections, schedules/items, playouts, FFmpeg profiles/resolution, sessions, and version. +- [x] Implement explicit tool metadata with names, descriptions, input schemas, HTTP method, and path templates. +- [x] Verify deferred #63-#68 workflow tools are absent. + +### Task 2: API Client + +**Files:** +- Create: `ErsatzTV.Mcp/ErsatzTvApiClient.cs` +- Test: `ErsatzTV.Mcp.Tests/ErsatzTvApiClientTests.cs` + +- [x] Write tests for base URL normalization, path parameter substitution, query parameter encoding, API key header application, and non-success error payloads. +- [x] Implement a small HTTP client wrapper returning structured `ToolCallResult` content. + +### Task 3: MCP JSON-RPC Server + +**Files:** +- Create: `ErsatzTV.Mcp/McpServer.cs` +- Create: `ErsatzTV.Mcp/JsonRpcModels.cs` +- Modify: `ErsatzTV.Mcp/Program.cs` +- Test: `ErsatzTV.Mcp.Tests/McpServerTests.cs` + +- [x] Write tests for `initialize`, `tools/list`, `tools/call`, unknown tools, and notifications. +- [x] Implement stdio-friendly JSON-RPC handling with MCP protocol version `2024-11-05`. +- [x] Return tool call results as JSON text content. + +### Task 4: Project Wiring And Docs + +**Files:** +- Create: `ErsatzTV.Mcp/ErsatzTV.Mcp.csproj` +- Create: `ErsatzTV.Mcp.Tests/ErsatzTV.Mcp.Tests.csproj` +- Modify: `ErsatzTV.sln` +- Add: `docs/mcp.md` + +- [x] Wire projects into the solution. +- [x] Document configuration, usage, v0 tools, auth header behavior, and deferred redesign workflows. +- [x] Run restore, build, focused tests, and full tests.