Compare commits

...
5 Commits
Author SHA1 Message Date
timothy 65d88b5167 test(#289): pin BoundedLineReader cap/CRLF boundaries + document per-segment path assumption
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Non-blocking polish from the fix-commit re-review (verdict MERGEABLE):
- BuildPath: comment the assumption that each {param} is its own path segment
  (revalidate the whole path if a template ever concatenates adjacent params).
- Two additive BoundedLineReader tests: exactly-at-cap line returned intact
  (inclusive max), and \r\n carriage-return stripping. No logic change.

36 tests (was 34).

Refs #289
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:24:35 +02:00
timothy 4f68805d9a security(#289): review fixes — body-read timeout, transport-error response, bounded stdin, env clamps
Folds in findings from the independent reviews of the initial hardening diff
(a cold adversarial pass + a Codex pass — complementary catches).

Fork (HIGH/MED):
- Body-read timeout regression: ResponseHeadersRead moved the body read outside
  HttpClient.Timeout and it used CancellationToken.None, so a slow-drip upstream
  hung the single-threaded session. Now a per-request linked CTS (options
  .EffectiveRequestTimeout) covers headers + body; HttpClient.Timeout set to
  Infinite so one timer owns it. Verified live against a black-hole upstream:
  1s timeout → -32603 in ~1s, no hang.
- Transport/timeout exceptions escaped HandleAsync's catch filter → no response
  for the id → client hangs. Added a broad final catch → -32603 when hasId.

Codex (env/input robustness):
- ERSATZTV_REQUEST_TIMEOUT_SECONDS / ERSATZTV_MAX_RESPONSE_BYTES at int.MaxValue
  crashed at startup / overflowed `cap+1` to a negative alloc. ParseInt now
  clamps to [min,max]; the client also hard-ceils the cap (MaxAllowedResponseBytes).
- A ".."/"." path param collapsed the URL onto another route under Uri
  canonicalization — rejected in BuildPath.

Both reviewers (stdin OOM): ReadLineAsync buffered an unbounded line before any
guard. New BoundedLineReader drains+drops oversized lines (memory-bounded),
keeping subsequent lines aligned.

Nits: case-insensitive ParseBool fallback; UTF-8 boundary backoff so a mid-
codepoint truncation doesn't emit U+FFFD; renamed a misleading test.

34 tests (was 26); +8 covering the transport-error path, dot-segment rejection,
cap-overflow fallback, UTF-8 seam, and the bounded reader.

Refs #289 #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:20:10 +02:00
timothyandClaude Opus 4.8 55fc210385 security(#289): harden MCP server (read-only posture, JSON-DoS, response cap, arg validation)
From the #197 cold review's MCP-consumer pass. Hardens `ErsatzTV.Mcp` (PR #76)
before it can go live against a soon-remote-exposed API.

- HIGH verb-guard: runtime read-only *posture* (ERSATZTV_ALLOW_WRITES, default
  false) enforced in the executor — a wrong catalog entry can't mutate/delete,
  and future write tools (#58) slot in behind the opt-in. Not a hardcoded
  GET-only clamp, so the non-read-only final design stays reachable.
- HIGH JSON-crash DoS: guard JsonDocument.Parse in HandleAsync (-32700, id null),
  handle missing/non-string method (-32600) instead of throwing, and wrap the
  Program.Main read loop so one bad stdin line can never kill the session.
- MED unbounded response / LOW no-timeout: cap the buffered body
  (ERSATZTV_MAX_RESPONSE_BYTES, truncation marker) + explicit HttpClient.Timeout.
- MED args unvalidated: ToolArgumentValidator checks caller args against each
  tool's InputSchema (required present, no unknown args, basic types) before a
  request is built.
- MED prompt injection: documented untrusted-data posture in docs/mcp.md.
- LOW base-URL prefix: preserve a reverse-proxy path prefix in the URL join.

Also documents that ERSATZTV_API_KEY is now effectively required (Bundle A gates
all /api reads behind X-Api-Key). 14 new tests (26 total); live stdin smoke
confirms malformed/no-method/non-object lines no longer crash the loop.

Refs #289 #197 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:08:07 +02:00
timothy d1c04030af chore: retrigger CI (MySQL service port collision with concurrent run)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:58:29 +02:00
timothy 945d108334 feat(mcp): add read-only API server foundation refs #58
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:44 +02:00
18 changed files with 1493 additions and 0 deletions
@@ -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");
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -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<ArgumentException>(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<ArgumentException>(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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
? values.Single()
: null;
return Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent(response)
});
}
}
}
+149
View File
@@ -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<string>()
.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<JsonDocument> 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<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken) =>
Task.FromResult(new ToolCallResult(false, response));
}
private sealed class ThrowingToolExecutor(Exception exception) : IToolExecutor
{
public Task<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken) =>
throw exception;
}
}
@@ -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<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12,"extra":1}""").RootElement));
}
[Test]
public void Validate_Should_Reject_Missing_Required_Argument()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("{}").RootElement));
}
[Test]
public void Validate_Should_Reject_Wrong_Type()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":"twelve"}""").RootElement));
}
[Test]
public void Validate_Should_Reject_Non_Object_Arguments()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("[]").RootElement));
}
}
+63
View File
@@ -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();
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace ErsatzTV.Mcp;
/// <summary>
/// Reads newline-delimited lines from a <see cref="TextReader"/> 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.
/// </summary>
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<Line> 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());
}
}
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+154
View File
@@ -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<ToolCallResult> 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<string> 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();
}
+166
View File
@@ -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<string?> 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<object> 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;
}
}
+87
View File
@@ -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;
}
+76
View File
@@ -0,0 +1,76 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
/// <summary>
/// Lightweight validation of caller-supplied tool arguments against a tool's declared
/// <c>InputSchema</c>. Not a full JSON-Schema implementation — it enforces the shapes the
/// catalog actually emits (typed properties, a required list, <c>additionalProperties:false</c>)
/// so unknown/malformed arguments are rejected before an API request is built.
/// Throws <see cref="ArgumentException"/> (mapped to JSON-RPC -32602 by the server).
/// </summary>
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
};
}
+45
View File
@@ -0,0 +1,45 @@
namespace ErsatzTV.Mcp;
public static class ToolCatalog
{
public static IReadOnlyList<ToolDefinition> 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);
}
+40
View File
@@ -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<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken);
}
+42
View File
@@ -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());
}
}
+40
View File
@@ -32,6 +32,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|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.ActiveCfg = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+75
View File
@@ -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.
@@ -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.