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