feat(#58): ErsatzTV.Mcp — read + cautious-write MCP server over /api/v1
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 17s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Fresh stdio JSON-RPC MCP server wrapping the frozen /api/v1 surface,
superseding the closed read-only PR #76. 26 read tools (six families +
search/all-items & search/artists discovery) and cautious-write CRUD:
collections (incl. idempotent membership adds for #487), smart collections,
schedules, playouts, channels (create/update/delete/reset), and a
Jellyfin-focused media-source sync/scan slice. Writes gated behind
ERSATZTV_ALLOW_WRITES (default false, runtime-enforced).

Security baseline carried forward from PR #76/#289: read-only backstop,
JSON-RPC DoS guards + bounded stdin reader, per-request CTS over
headers+body, response-size cap, arg validation vs InputSchema,
reverse-proxy prefix preservation. Machine-key auth (X-Api-Key,
CSRF-exempt). If-Match/ETag round-trip for the one replace-all PUT that
honors it.

Cold-review fixes folded in:
- HIGH: reject control chars (CR/LF) in the ifMatch value before it reaches
  TryAddWithoutValidation — SocketsHttpHandler writes it verbatim, so a
  crafted value could smuggle headers onto the X-Api-Key request.
- Cache the empty-args JsonDocument (no per-call pooled-doc leak).
- Accept explicit JSON null for optional fields so a nullable API field
  (e.g. dailyRebuildTime) can be cleared as documented.

Deferred (documented): the ~40-field replace-list writes and redesign
workflow tools (#63-#68).

Docs: docs/mcp.md, docs/README.md index, docs/decisions.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:42:00 +02:00
co-authored by Claude Opus 4.8
parent 34d76095a2
commit a88240dcec
19 changed files with 2399 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,485 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class ErsatzTvApiClientTests
{
private static ToolDefinition GetChannel() => new(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/v1/channels/{id}",
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true)));
[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(
GetChannel(),
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/v1/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/v1/ffmpeg/resolution/by-name/{name}",
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: true))),
JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/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(
GetChannel(),
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/v1/channels/{id}",
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: 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("", HttpStatusCode.NoContent);
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/v1/channels/{id}",
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/channels/12"));
}
[Test]
public async Task CallToolAsync_Should_Serialize_Non_Path_Args_As_Json_Body_For_Post()
{
CapturingHandler handler = new("""{"id":9,"name":"Kids"}""", HttpStatusCode.Created);
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_create_collection",
"Create collection",
HttpMethod.Post,
"/api/v1/collections",
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Name", Required: true))),
JsonDocument.Parse("""{"name":"Kids"}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections"));
handler.ContentType.ShouldBe("application/json");
handler.Body.ShouldBe("""{"name":"Kids"}""");
}
[Test]
public async Task CallToolAsync_Should_Keep_Path_Args_Out_Of_The_Body_And_Preserve_Array_Values()
{
CapturingHandler handler = new("", HttpStatusCode.NoContent);
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_add_collection_items",
"Add items",
HttpMethod.Post,
"/api/v1/collections/{id}/items",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Collection id", Required: true),
new SchemaProperty("artistIds", "array", "Artist ids", Required: false, ItemType: "integer"))),
JsonDocument.Parse("""{"id":20,"artistIds":[1,2,3]}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/v1/collections/20/items"));
// The path id must not leak into the body; array values are preserved verbatim.
handler.Body.ShouldBe("""{"artistIds":[1,2,3]}""");
}
[Test]
public async Task CallToolAsync_Should_Send_IfMatch_Header_And_Keep_It_Out_Of_The_Body()
{
CapturingHandler handler = new("[]");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_update_collection_custom_order",
"Reorder",
HttpMethod.Put,
"/api/v1/collections/{id}/custom-order",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Collection id", Required: true),
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
JsonDocument.Parse("""{"id":20,"mediaItemIds":[3,1,2],"ifMatch":"\"5\""}""").RootElement,
CancellationToken.None);
handler.IfMatch.ShouldBe("\"5\"");
// ifMatch is a header, not a body field; the path id is also excluded.
handler.Body.ShouldBe("""{"mediaItemIds":[3,1,2]}""");
}
[Test]
public async Task CallToolAsync_Should_Pass_Through_IfMatch_Wildcard_Force_Write()
{
CapturingHandler handler = new("[]");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_update_collection_custom_order",
"Reorder",
HttpMethod.Put,
"/api/v1/collections/{id}/custom-order",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Collection id", Required: true),
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
JsonDocument.Parse("""{"id":20,"mediaItemIds":[1],"ifMatch":"*"}""").RootElement,
CancellationToken.None);
handler.IfMatch.ShouldBe("*");
}
[Test]
public async Task CallToolAsync_Should_Reject_IfMatch_With_Control_Characters()
{
CapturingHandler handler = new("[]");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
// A CR/LF in ifMatch would smuggle a second header via TryAddWithoutValidation — must be
// rejected before the request is sent (SocketsHttpHandler does not strip it).
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
new ToolDefinition(
"ersatztv_update_collection_custom_order",
"Reorder",
HttpMethod.Put,
"/api/v1/collections/{id}/custom-order",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Collection id", Required: true),
new SchemaProperty("mediaItemIds", "array", "Order", Required: true, ItemType: "integer"),
new SchemaProperty("ifMatch", "string", "ETag", Required: false))),
JsonDocument.Parse("{\"id\":20,\"mediaItemIds\":[1],\"ifMatch\":\"\\\"5\\\"\\r\\nX-Evil: 1\"}").RootElement,
CancellationToken.None));
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Serialize_Explicit_Null_Body_Field()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
// An explicit null on an optional field is preserved in the body (clears a nullable API field).
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_update_playout",
"Update playout",
HttpMethod.Put,
"/api/v1/playouts/{id}",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Playout id", Required: true),
new SchemaProperty("dailyRebuildTime", "string", "Daily rebuild time; null clears", Required: false))),
JsonDocument.Parse("""{"id":1,"dailyRebuildTime":null}""").RootElement,
CancellationToken.None);
handler.Body.ShouldBe("""{"dailyRebuildTime":null}""");
}
[Test]
public async Task CallToolAsync_Should_Append_Declared_Query_Parameters_On_Get()
{
CapturingHandler handler = new("[]");
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_search_all_items",
"Search",
HttpMethod.Get,
"/api/v1/search/all-items",
ToolInputSchemas.Object(
new SchemaProperty("query", "string", "Query", Required: false),
new SchemaProperty("pageNum", "integer", "Page", Required: false)),
new HashSet<string>(StringComparer.Ordinal) { "query", "pageNum" }),
JsonDocument.Parse("""{"query":"genre:jazz","pageNum":2}""").RootElement,
CancellationToken.None);
handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/search/all-items?query=genre%3Ajazz&pageNum=2");
}
[Test]
public async Task CallToolAsync_Should_Put_Query_Params_In_Query_Not_Body_For_Post()
{
CapturingHandler handler = new("", HttpStatusCode.Accepted);
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k", AllowWrites: true));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_scan_library",
"Scan",
HttpMethod.Post,
"/api/v1/libraries/{id}/scan",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Library id", Required: true),
new SchemaProperty("deep", "boolean", "Deep", Required: false)),
new HashSet<string>(StringComparer.Ordinal) { "deep" }),
JsonDocument.Parse("""{"id":3,"deep":true}""").RootElement,
CancellationToken.None);
handler.RequestUri!.PathAndQuery.ShouldBe("/api/v1/libraries/3/scan?deep=true");
// deep is a query param; there is no JSON body.
handler.Body.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Surface_Response_ETag()
{
CapturingHandler handler = new("""{"items":[]}""", HttpStatusCode.OK, etag: "\"3\"");
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), "k"));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_collection_items",
"Items",
HttpMethod.Get,
"/api/v1/collections/{id}/items",
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Collection id", Required: true))),
JsonDocument.Parse("""{"id":20}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
result.Text.ShouldContain("[etag: \"3\"]");
}
[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/v1/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(
GetChannel(),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/v1/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(
GetChannel(),
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/v1/ffmpeg/resolution/by-name/{name}",
ToolInputSchemas.Object(new SchemaProperty("name", "string", "Resolution name", Required: 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/v1/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/v1/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, string? etag = null)
: HttpMessageHandler
{
public Uri? RequestUri { get; private set; }
public string? ApiKey { get; private set; }
public string? Body { get; private set; }
public string? ContentType { get; private set; }
public string? IfMatch { 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;
IfMatch = request.Headers.TryGetValues("If-Match", out IEnumerable<string>? ifMatch)
? ifMatch.Single()
: null;
if (request.Content is not null)
{
Body = request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult();
ContentType = request.Content.Headers.ContentType?.MediaType;
}
var message = new HttpResponseMessage(statusCode)
{
Content = new StringContent(response)
};
if (etag is not null)
{
message.Headers.ETag = new EntityTagHeaderValue(etag);
}
return Task.FromResult(message);
}
}
}
+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,101 @@
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/v1/channels/{id}",
ToolInputSchemas.Object(new SchemaProperty("id", "integer", "Channel id", Required: true)));
private static ToolDefinition ArrayTool() => new(
"ersatztv_add_collection_items",
"Add items",
HttpMethod.Post,
"/api/v1/collections/{id}/items",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Collection id", Required: true),
new SchemaProperty("artistIds", "array", "Artist ids", Required: false, ItemType: "integer")));
[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/v1/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));
}
[Test]
public void Validate_Should_Accept_Array_Argument()
{
Should.NotThrow(() =>
ToolArgumentValidator.Validate(ArrayTool(), JsonDocument.Parse("""{"id":1,"artistIds":[3,4,5]}""").RootElement));
}
[Test]
public void Validate_Should_Reject_Non_Array_For_Array_Argument()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(ArrayTool(), JsonDocument.Parse("""{"id":1,"artistIds":7}""").RootElement));
}
[Test]
public void Validate_Should_Accept_Explicit_Null_For_Optional_Property()
{
ToolDefinition tool = new(
"ersatztv_update_playout",
"Update playout",
HttpMethod.Put,
"/api/v1/playouts/{id}",
ToolInputSchemas.Object(
new SchemaProperty("id", "integer", "Playout id", Required: true),
new SchemaProperty("dailyRebuildTime", "string", "null clears", Required: false)));
// An explicit JSON null clears a nullable API field; the validator must not reject it on type.
Should.NotThrow(() =>
ToolArgumentValidator.Validate(tool, JsonDocument.Parse("""{"id":1,"dailyRebuildTime":null}""").RootElement));
}
}
+214
View File
@@ -0,0 +1,214 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class ToolCatalogTests
{
[Test]
public void All_Should_Expose_Read_Tools_For_The_Six_Families_And_Discovery()
{
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_get_collection_items");
names.ShouldContain("ersatztv_list_smart_collections");
names.ShouldContain("ersatztv_list_schedules");
names.ShouldContain("ersatztv_get_schedule_items");
names.ShouldContain("ersatztv_list_playouts");
names.ShouldContain("ersatztv_get_playout");
names.ShouldContain("ersatztv_list_ffmpeg_profiles");
names.ShouldContain("ersatztv_get_version");
names.ShouldContain("ersatztv_list_media_sources");
// Discovery reads for populating collections (#487).
names.ShouldContain("ersatztv_search_all_items");
names.ShouldContain("ersatztv_search_artists");
}
[Test]
public void All_Should_Expose_Cautious_Write_Tools()
{
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
names.ShouldContain("ersatztv_create_collection");
names.ShouldContain("ersatztv_update_collection");
names.ShouldContain("ersatztv_delete_collection");
names.ShouldContain("ersatztv_add_collection_items");
names.ShouldContain("ersatztv_remove_collection_item");
names.ShouldContain("ersatztv_update_collection_custom_order");
names.ShouldContain("ersatztv_create_smart_collection");
names.ShouldContain("ersatztv_create_schedule");
names.ShouldContain("ersatztv_create_playout");
names.ShouldContain("ersatztv_delete_playout");
names.ShouldContain("ersatztv_create_channel");
names.ShouldContain("ersatztv_update_channel");
names.ShouldContain("ersatztv_delete_channel");
names.ShouldContain("ersatztv_enable_jellyfin_library_sync");
names.ShouldContain("ersatztv_scan_library");
}
[Test]
public void Create_And_Update_Channel_Should_Share_The_Body_Fields_And_Require_Essentials()
{
ToolDefinition create = ToolCatalog.Find("ersatztv_create_channel").ShouldNotBeNull();
ToolDefinition update = ToolCatalog.Find("ersatztv_update_channel").ShouldNotBeNull();
create.HttpMethod.ShouldBe(HttpMethod.Post);
update.HttpMethod.ShouldBe(HttpMethod.Put);
update.PathTemplate.ShouldBe("/api/v1/channels/{id}");
JsonElement createProps = create.InputSchema.RootElement.GetProperty("properties");
createProps.TryGetProperty("name", out _).ShouldBeTrue();
createProps.TryGetProperty("ffmpegProfileId", out _).ShouldBeTrue();
createProps.TryGetProperty("streamingMode", out _).ShouldBeTrue();
var createRequired = create.InputSchema.RootElement.GetProperty("required")
.EnumerateArray().Select(e => e.GetString()).ToHashSet();
createRequired.ShouldContain("name");
createRequired.ShouldContain("number");
createRequired.ShouldContain("ffmpegProfileId");
// Enums must NOT be forced required (they have server-side defaults).
createRequired.ShouldNotContain("streamingMode");
// Update carries the same body fields plus the route id.
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
update.InputSchema.RootElement.GetProperty("properties").TryGetProperty("showInEpg", out _).ShouldBeTrue();
}
[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_Not_Expose_Deferred_Large_Dto_Writes()
{
// Deferred as too-large for a cautious v0.1 (documented in docs/mcp.md): the ~40-field
// replace-list writes.
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
names.ShouldNotContain("ersatztv_replace_schedule_items");
names.ShouldNotContain("ersatztv_replace_playout_templates");
}
[Test]
public void Every_Tool_Path_Should_Be_Versioned()
{
foreach (ToolDefinition tool in ToolCatalog.All)
{
tool.PathTemplate.ShouldStartWith("/api/v1/", customMessage: tool.Name);
}
}
[Test]
public void Tool_Names_Should_Be_Unique()
{
ToolCatalog.All
.GroupBy(t => t.Name, StringComparer.Ordinal)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ShouldBeEmpty();
}
[Test]
public void Every_Path_Parameter_Should_Be_A_Required_Declared_Property()
{
foreach (ToolDefinition tool in ToolCatalog.All)
{
JsonElement schema = tool.InputSchema.RootElement;
bool hasProps = schema.TryGetProperty("properties", out JsonElement properties);
var required = schema.TryGetProperty("required", out JsonElement req)
? req.EnumerateArray().Select(e => e.GetString()).ToHashSet()
: new HashSet<string?>();
foreach (Match match in Regex.Matches(tool.PathTemplate, @"\{([^}]+)\}"))
{
string name = match.Groups[1].Value;
hasProps.ShouldBeTrue($"{tool.Name}: path param {name} needs a properties block");
properties.TryGetProperty(name, out _).ShouldBeTrue($"{tool.Name}: path param {name} not declared");
required.ShouldContain(name, $"{tool.Name}: path param {name} must be required");
}
}
}
[Test]
public void Every_Query_Parameter_Should_Be_A_Declared_Property()
{
foreach (ToolDefinition tool in ToolCatalog.All.Where(t => t.QueryParameters is { Count: > 0 }))
{
JsonElement properties = tool.InputSchema.RootElement.GetProperty("properties");
foreach (string name in tool.QueryParameters!)
{
properties.TryGetProperty(name, out _).ShouldBeTrue($"{tool.Name}: query param {name} not declared");
}
}
}
[Test]
public void Every_Non_Empty_Schema_Should_Forbid_Additional_Properties()
{
foreach (ToolDefinition tool in ToolCatalog.All)
{
JsonElement schema = tool.InputSchema.RootElement;
schema.GetProperty("additionalProperties").ValueKind.ShouldBe(JsonValueKind.False, tool.Name);
}
}
[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/v1/channels/{id}");
tool.InputSchema.RootElement.GetProperty("required").EnumerateArray().Single().GetString().ShouldBe("id");
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
}
[Test]
public void Add_Collection_Items_Tool_Should_Post_With_Array_Buckets()
{
ToolDefinition tool = ToolCatalog.Find("ersatztv_add_collection_items").ShouldNotBeNull();
tool.HttpMethod.ShouldBe(HttpMethod.Post);
tool.PathTemplate.ShouldBe("/api/v1/collections/{id}/items");
JsonElement artistIds = tool.InputSchema.RootElement.GetProperty("properties").GetProperty("artistIds");
artistIds.GetProperty("type").GetString().ShouldBe("array");
artistIds.GetProperty("items").GetProperty("type").GetString().ShouldBe("integer");
}
[Test]
public void Custom_Order_Tool_Should_Declare_IfMatch_Header_Argument()
{
ToolDefinition tool = ToolCatalog.Find("ersatztv_update_collection_custom_order").ShouldNotBeNull();
tool.HttpMethod.ShouldBe(HttpMethod.Put);
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("ifMatch", out _).ShouldBeTrue();
// ifMatch is a header, not a query parameter.
(tool.QueryParameters ?? new HashSet<string>()).ShouldNotContain("ifMatch");
}
[Test]
public void Scan_Library_Tool_Should_Register_Deep_As_A_Query_Parameter()
{
ToolDefinition tool = ToolCatalog.Find("ersatztv_scan_library").ShouldNotBeNull();
tool.HttpMethod.ShouldBe(HttpMethod.Post);
tool.QueryParameters.ShouldNotBeNull();
tool.QueryParameters!.ShouldContain("deep");
}
}
+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>
+264
View File
@@ -0,0 +1,264 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ErsatzTV.Mcp;
public sealed partial class ErsatzTvApiClient(HttpClient httpClient, ErsatzTvApiClientOptions options) : IToolExecutor
{
// Reserved argument name: carried as the RFC 7232 If-Match request header (never the path/body/query).
// A write tool declares it as an optional string so a caller can round-trip an ETag from a prior read.
public const string IfMatchArgument = "ifMatch";
private static readonly IReadOnlySet<string> EmptyNameSet = new HashSet<string>(StringComparer.Ordinal);
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 seam behind the
// cautious-write 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);
HashSet<string> pathParameters = PathParameterNames(tool.PathTemplate);
IReadOnlySet<string> queryParameters = tool.QueryParameters ?? EmptyNameSet;
string path = BuildPath(tool.PathTemplate, arguments, pathParameters);
// Each declared argument routes to exactly one place: path {param}, an explicit query
// parameter, the reserved If-Match header, or (write verbs only) the JSON request body.
// additionalProperties:false in the schema means only declared args ever arrive here.
bool hasBody = tool.HttpMethod == HttpMethod.Post
|| tool.HttpMethod == HttpMethod.Put
|| tool.HttpMethod == HttpMethod.Patch;
var queryArgs = arguments.EnumerateObject()
.Where(p => queryParameters.Contains(p.Name))
.ToList();
var bodyArgs = arguments.EnumerateObject()
.Where(p => !pathParameters.Contains(p.Name)
&& !queryParameters.Contains(p.Name)
&& !string.Equals(p.Name, IfMatchArgument, StringComparison.Ordinal))
.ToList();
using var request = new HttpRequestMessage(
tool.HttpMethod,
CombineUri(options.BaseUrl, AppendQuery(path, queryArgs)));
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
request.Headers.Add("X-Api-Key", options.ApiKey);
}
if (arguments.TryGetProperty(IfMatchArgument, out JsonElement ifMatch)
&& ifMatch.ValueKind == JsonValueKind.String
&& ifMatch.GetString() is { Length: > 0 } ifMatchValue)
{
// SECURITY: reject control characters (CR/LF above all). TryAddWithoutValidation writes
// the value to the wire verbatim — SocketsHttpHandler does NOT strip CR/LF — so a value
// like "5"\r\nX-Evil: 1 would smuggle extra headers onto a request that carries the
// machine X-Api-Key. The arg is model-controlled, so this must be guarded here; the
// server still parses the RFC 7232 grammar (quoted tag / list / "*") and returns 400/412.
// TryAddWithoutValidation (not Add) is still required so a valid quoted opaque tag passes
// HttpClient's otherwise-stricter parsing.
if (ifMatchValue.Any(char.IsControl))
{
throw new ArgumentException("Invalid 'ifMatch' value: control characters are not allowed.");
}
request.Headers.TryAddWithoutValidation("If-Match", ifMatchValue);
}
if (hasBody && bodyArgs.Count > 0)
{
request.Content = new StringContent(SerializeBody(bodyArgs), Encoding.UTF8, "application/json");
}
// 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);
// Surface the aggregate ETag (versioned roots emit it on GET and on a successful replace PUT)
// so an agent can round-trip it as `ifMatch` on a subsequent write. Header-only by contract.
string etagSuffix = response.Headers.ETag is { } etag ? $"\n[etag: {etag}]" : string.Empty;
if (response.IsSuccessStatusCode)
{
return new ToolCallResult(false, body + etagSuffix);
}
string message = $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}";
return new ToolCallResult(true, message + etagSuffix);
}
private static string SerializeBody(IReadOnlyList<JsonProperty> payload)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
foreach (JsonProperty property in payload)
{
property.WriteTo(writer);
}
writer.WriteEndObject();
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static string AppendQuery(string path, IReadOnlyList<JsonProperty> payload)
{
if (payload.Count == 0)
{
return path;
}
var query = new StringBuilder(path);
char separator = '?';
foreach (JsonProperty property in payload)
{
string value = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.GetRawText();
query.Append(separator)
.Append(Uri.EscapeDataString(property.Name))
.Append('=')
.Append(Uri.EscapeDataString(value));
separator = '&';
}
return query.ToString();
}
// 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 relativeUri)
{
// relativeUri is a root-relative "/api/..." path (optionally with a query). 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 + relativeUri);
}
private static HashSet<string> PathParameterNames(string pathTemplate)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (Match match in PathParameterRegex().Matches(pathTemplate))
{
names.Add(match.Groups[1].Value);
}
return names;
}
private static string BuildPath(string pathTemplate, JsonElement arguments, HashSet<string> pathParameters)
{
string path = pathTemplate;
foreach (JsonProperty property in arguments.EnumerateObject())
{
if (!pathParameters.Contains(property.Name))
{
continue;
}
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 (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();
}
+171
View File
@@ -0,0 +1,171 @@
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;
// Process-lifetime empty-arguments document, reused for tool calls that omit "arguments" — so a
// no-arg call doesn't leak a pooled JsonDocument per invocation.
private static readonly JsonDocument EmptyArgsDocument = JsonDocument.Parse("{}");
private static readonly JsonElement EmptyArgs = EmptyArgsDocument.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
: EmptyArgs;
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;
}
+87
View File
@@ -0,0 +1,87 @@
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)
{
// JSON null is accepted for any declared property: it clears an optional/nullable API field
// (e.g. UpdatePlayoutDetailsRequest.DailyRebuildTime). Required-presence is checked separately;
// the server remains the authority on required-non-null (a null there returns 422).
if (value.ValueKind == JsonValueKind.Null)
{
return true;
}
return 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
};
}
}
+316
View File
@@ -0,0 +1,316 @@
namespace ErsatzTV.Mcp;
/// <summary>
/// The explicit, narrow set of tools this MCP server exposes over the frozen ErsatzTV
/// <c>/api/v1</c> surface. Read tools are always available; write tools execute only when the
/// operator sets <c>ERSATZTV_ALLOW_WRITES=true</c> (enforced at runtime in <see cref="ErsatzTvApiClient"/>).
///
/// Deliberately deferred from this v0.1 (documented in docs/mcp.md): the ~40-field schedule-item
/// and playout alternate-schedule/template replace-list writes, and the redesign-aware workflow
/// tools (#63#68).
/// </summary>
public static class ToolCatalog
{
public static IReadOnlyList<ToolDefinition> All { get; } =
[
// ---- Reads ----
Get("ersatztv_list_channels", "List channels.", "/api/v1/channels"),
Get("ersatztv_get_channel", "Get a channel by id.", "/api/v1/channels/{id}", IdPath("Channel id.")),
Get("ersatztv_list_collections", "List collections.", "/api/v1/collections"),
Get("ersatztv_get_collection", "Get a collection by id.", "/api/v1/collections/{id}", IdPath("Collection id.")),
Get(
"ersatztv_get_collection_items",
"Get the items in a manual collection (paged). Emits the collection ETag for optimistic-concurrency reorder.",
"/api/v1/collections/{id}/items",
[IdPath("Collection id.")],
Page()),
Get("ersatztv_list_smart_collections", "List smart collections.", "/api/v1/smart-collections"),
Get("ersatztv_get_smart_collection", "Get a smart collection by id.", "/api/v1/smart-collections/{id}", IdPath("Smart collection id.")),
Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
Get("ersatztv_list_playouts", "List playouts.", "/api/v1/playouts"),
Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
Get("ersatztv_get_playout_items", "Get upcoming items (and unscheduled gaps) for a playout.", "/api/v1/playouts/{id}/items", IdPath("Playout id.")),
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
Get(
"ersatztv_get_resolution_by_name",
"Get an FFmpeg resolution by name.",
"/api/v1/ffmpeg/resolution/by-name/{name}",
Str("name", "Resolution name.", required: true, arg: In.Path)),
Get("ersatztv_list_sessions", "List active HLS sessions.", "/api/v1/sessions"),
Get("ersatztv_get_version", "Get API and app version.", "/api/v1/version"),
Get("ersatztv_list_media_sources", "Get all media sources with their libraries.", "/api/v1/media-sources"),
Get("ersatztv_get_jellyfin_libraries", "Get a Jellyfin source's libraries.", "/api/v1/media-sources/jellyfin/{id}/libraries", IdPath("Jellyfin media source id.")),
Get("ersatztv_list_local_libraries", "Get all local libraries.", "/api/v1/libraries/local"),
Get("ersatztv_get_library_scan_status", "Get active library scan status.", "/api/v1/libraries/scan-status"),
Get(
"ersatztv_search",
"Search library items across all media kinds (paged, hydrated results).",
"/api/v1/search",
[],
[Str("query", "Lucene query string.", arg: In.Query), .. Page()]),
Get(
"ersatztv_search_all_items",
"Search library items across all media kinds and return raw id lists — use to discover media ids to add to a collection.",
"/api/v1/search/all-items",
[],
[Str("query", "Lucene query string.", arg: In.Query), .. Page()]),
Get(
"ersatztv_search_artists",
"Search artists by name; returns matching artist ids.",
"/api/v1/search/artists",
[],
[Str("query", "Artist name query.", arg: In.Query)]),
// ---- Writes (require ERSATZTV_ALLOW_WRITES=true) ----
Post(
"ersatztv_create_collection",
"Create an empty manual collection.",
"/api/v1/collections",
Str("name", "Collection name.", required: true)),
Put(
"ersatztv_update_collection",
"Rename a collection and/or toggle its custom playback order.",
"/api/v1/collections/{id}",
IdPath("Collection id."),
Str("name", "Collection name.", required: true),
Bool("useCustomPlaybackOrder", "Whether the collection uses a custom playback order.")),
Delete("ersatztv_delete_collection", "Delete a collection.", "/api/v1/collections/{id}", IdPath("Collection id.")),
Post(
"ersatztv_add_collection_items",
"Add media items to a manual collection. Send only the buckets you need; re-adding an already-present item is an idempotent no-op. All referenced ids must exist or the whole batch is rejected (422).",
"/api/v1/collections/{id}/items",
IdPath("Collection id."),
IntArray("movieIds", "Movie ids to add."),
IntArray("showIds", "Show ids to add."),
IntArray("seasonIds", "Season ids to add."),
IntArray("episodeIds", "Episode ids to add."),
IntArray("artistIds", "Artist ids to add."),
IntArray("musicVideoIds", "Music video ids to add."),
IntArray("otherVideoIds", "Other-video ids to add."),
IntArray("songIds", "Song ids to add."),
IntArray("imageIds", "Image ids to add."),
IntArray("remoteStreamIds", "Remote-stream ids to add.")),
Delete(
"ersatztv_remove_collection_item",
"Remove a single media item from a collection.",
"/api/v1/collections/{id}/items/{mediaItemId}",
IdPath("Collection id."),
Int("mediaItemId", "Media item id to remove.", required: true, arg: In.Path)),
Put(
"ersatztv_update_collection_custom_order",
"Replace a collection's custom playback order with the given media-item id order. Honors If-Match (read the ETag from ersatztv_get_collection_items first); omit ifMatch to force-write.",
"/api/v1/collections/{id}/custom-order",
IdPath("Collection id."),
IntArray("mediaItemIds", "Media item ids in the desired playback order.", required: true),
IfMatch),
Post(
"ersatztv_create_smart_collection",
"Create a smart collection backed by a search query.",
"/api/v1/smart-collections",
Str("name", "Smart collection name.", required: true),
Str("query", "Lucene query defining membership.", required: true)),
Put(
"ersatztv_update_smart_collection",
"Update a smart collection's name and query.",
"/api/v1/smart-collections/{id}",
IdPath("Smart collection id."),
Str("name", "Smart collection name.", required: true),
Str("query", "Lucene query defining membership.", required: true)),
Delete("ersatztv_delete_smart_collection", "Delete a smart collection.", "/api/v1/smart-collections/{id}", IdPath("Smart collection id.")),
Post(
"ersatztv_create_schedule",
"Create a program schedule.",
"/api/v1/schedules",
[Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
Put(
"ersatztv_update_schedule",
"Update a program schedule's settings.",
"/api/v1/schedules/{id}",
[IdPath("Schedule id."), Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
Delete("ersatztv_delete_schedule", "Delete a program schedule.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
Post(
"ersatztv_create_playout",
"Create a playout for a channel. Classic requires programScheduleId; Sequential/Scripted/ExternalJson require scheduleFile.",
"/api/v1/playouts",
Int("channelId", "Channel id.", required: true),
Str("scheduleKind", "One of: Classic, Block, Sequential, Scripted, ExternalJson.", required: true),
Int("programScheduleId", "Program schedule id (Classic only)."),
Str("scheduleFile", "Schedule file path (Sequential/Scripted/ExternalJson only).")),
Put(
"ersatztv_update_playout",
"Update a playout's daily rebuild time and/or schedule file. dailyRebuildTime is always applied (null clears the daily reset).",
"/api/v1/playouts/{id}",
IdPath("Playout id."),
Str("dailyRebuildTime", "Daily rebuild time as an ISO 8601 duration/timespan (e.g. \"04:00:00\"); null clears it."),
Str("scheduleFile", "Schedule file path (Sequential/Scripted/ExternalJson only); omit to leave unchanged.")),
Delete("ersatztv_delete_playout", "Delete a playout.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
Post(
"ersatztv_create_channel",
"Create a channel. Enum fields take the enum name; GET an existing channel (ersatztv_get_channel) to see valid values and sensible defaults before creating.",
"/api/v1/channels",
ChannelFields()),
Put(
"ersatztv_update_channel",
"Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values).",
"/api/v1/channels/{id}",
[IdPath("Channel id."), .. ChannelFields()]),
Post(
"ersatztv_reset_channel_playout",
"Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
"/api/v1/channels/{id}/playout/reset",
[IdPath("Channel id.")],
[Str("mode", "Optional playout build mode; omit for the default. GET a playout to see valid values.", arg: In.Query)]),
Delete("ersatztv_delete_channel", "Delete a channel.", "/api/v1/channels/{id}", IdPath("Channel id.")),
Put(
"ersatztv_enable_jellyfin_library_sync",
"Replace a Jellyfin source's library sync preferences. The body must be the COMPLETE set of the source's libraries; enabling shouldSyncItems also enqueues a scan. A row absent from the request is left untouched.",
"/api/v1/media-sources/jellyfin/{id}/libraries",
IdPath("Jellyfin media source id."),
ObjArray("libraries", "Complete set of the source's libraries; each element { id: number, shouldSyncItems: boolean }.", required: true)),
Post(
"ersatztv_refresh_jellyfin_libraries",
"Refresh the list of a Jellyfin source's libraries from the server (202 Accepted).",
"/api/v1/media-sources/jellyfin/{id}/refresh-libraries",
IdPath("Jellyfin media source id.")),
Post(
"ersatztv_scan_jellyfin_collections",
"Scan a Jellyfin source's collections (202 Accepted; 409 if already scanning).",
"/api/v1/media-sources/jellyfin/{id}/scan-collections",
[IdPath("Jellyfin media source id.")],
[Bool("deep", "Whether to perform a deep scan.", arg: In.Query)]),
Post(
"ersatztv_scan_library",
"Trigger a scan of a library (202 queued; 404 not found; 409 already scanning; 422 sync disabled).",
"/api/v1/libraries/{id}/scan",
[IdPath("Library id.")],
[Bool("deep", "Whether to perform a deep scan.", arg: In.Query)])
];
public static ToolDefinition? Find(string name) =>
All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal));
// ---- Argument location + builders ----
private enum In
{
Path,
Query,
Body,
Header
}
private sealed record Arg(string Name, string Type, string Description, bool Required, In Location, string? ItemType = null);
// A property (not a static field): the `All` initializer runs before a static field declared
// later would be assigned, which would pass a null Arg here. A property is evaluated on access.
private static Arg IfMatch => new(
ErsatzTvApiClient.IfMatchArgument,
"string",
"Optional RFC 7232 ETag from a prior read (e.g. \"3\") for optimistic concurrency; omit to force-write.",
Required: false,
In.Header);
private static Arg IdPath(string description) => new("id", "integer", description, Required: true, In.Path);
private static Arg Str(string name, string description, bool required = false, In arg = In.Body) =>
new(name, "string", description, required, arg);
private static Arg Int(string name, string description, bool required = false, In arg = In.Body) =>
new(name, "integer", description, required, arg);
private static Arg Bool(string name, string description, In arg = In.Body) =>
new(name, "boolean", description, Required: false, arg);
private static Arg IntArray(string name, string description, bool required = false) =>
new(name, "array", description, required, In.Body, ItemType: "integer");
private static Arg ObjArray(string name, string description, bool required = false) =>
new(name, "array", description, required, In.Body, ItemType: "object");
private static Arg[] Page() =>
[
Int("pageNum", "1-based page number (optional).", arg: In.Query),
Int("pageSize", "Page size (optional).", arg: In.Query)
];
// The channel create/update body (CreateChannelRequest / UpdateChannelRequest — the id comes from
// the route on update). Only name/number/ffmpegProfileId are marked required; the rest have
// server-side defaults. Enum fields are typed "string" (the enum name) — the API validates them.
private static Arg[] ChannelFields() =>
[
Str("name", "Channel name.", required: true),
Str("number", "Channel number (e.g. \"5\" or \"5.1\").", required: true),
Str("group", "Channel group."),
Str("categories", "Comma-separated categories."),
Int("ffmpegProfileId", "FFmpeg profile id.", required: true),
new Arg("slugSeconds", "number", "Optional slug/pad seconds.", Required: false, In.Body),
new Arg("logo", "object", "Channel logo as an ArtworkContentTypeModel object.", Required: false, In.Body),
Str("streamSelectorMode", "ChannelStreamSelectorMode enum name."),
Str("streamSelector", "Stream selector value (when streamSelectorMode uses one)."),
Str("preferredAudioLanguageCode", "Preferred audio language code."),
Str("preferredAudioTitle", "Preferred audio title."),
Str("playoutSource", "ChannelPlayoutSource enum name."),
Str("playoutMode", "ChannelPlayoutMode enum name."),
Int("mirrorSourceChannelId", "Channel id to mirror (when playoutSource is a mirror)."),
Str("playoutOffset", "Playout offset as a timespan (e.g. \"01:00:00\")."),
Str("streamingMode", "StreamingMode enum name."),
Int("watermarkId", "Watermark id."),
Int("fallbackFillerId", "Fallback filler id."),
Str("preferredSubtitleLanguageCode", "Preferred subtitle language code."),
Str("subtitleMode", "ChannelSubtitleMode enum name."),
Str("musicVideoCreditsMode", "ChannelMusicVideoCreditsMode enum name."),
Str("musicVideoCreditsTemplate", "Music video credits template name."),
Str("songVideoMode", "ChannelSongVideoMode enum name."),
Str("transcodeMode", "ChannelTranscodeMode enum name."),
Str("idleBehavior", "ChannelIdleBehavior enum name."),
Bool("isEnabled", "Whether the channel is enabled."),
Bool("showInEpg", "Whether the channel appears in the EPG/guide.")
];
private static Arg[] ScheduleFlags() =>
[
Bool("keepMultiPartEpisodesTogether", "Keep multi-part episodes together."),
Bool("treatCollectionsAsShows", "Treat collections as shows."),
Bool("shuffleScheduleItems", "Shuffle schedule items."),
Bool("randomStartPoint", "Use a random start point."),
Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values).")
];
// ---- Tool factories ----
private static ToolDefinition Get(string name, string description, string path, params Arg[] args) =>
Tool(name, description, HttpMethod.Get, path, args);
private static ToolDefinition Get(string name, string description, string path, Arg[] pathArgs, Arg[] otherArgs) =>
Tool(name, description, HttpMethod.Get, path, [.. pathArgs, .. otherArgs]);
private static ToolDefinition Post(string name, string description, string path, params Arg[] args) =>
Tool(name, description, HttpMethod.Post, path, args);
private static ToolDefinition Post(string name, string description, string path, Arg[] pathArgs, Arg[] otherArgs) =>
Tool(name, description, HttpMethod.Post, path, [.. pathArgs, .. otherArgs]);
private static ToolDefinition Put(string name, string description, string path, params Arg[] args) =>
Tool(name, description, HttpMethod.Put, path, args);
private static ToolDefinition Delete(string name, string description, string path, params Arg[] args) =>
Tool(name, description, HttpMethod.Delete, path, args);
private static ToolDefinition Tool(string name, string description, HttpMethod method, string path, Arg[] args)
{
System.Text.Json.JsonDocument schema = args.Length == 0
? ToolInputSchemas.Empty
: ToolInputSchemas.Object(
args.Select(a => new SchemaProperty(a.Name, a.Type, a.Description, a.Required, a.ItemType)).ToArray());
var queryParameters = args
.Where(a => a.Location == In.Query)
.Select(a => a.Name)
.ToHashSet(StringComparer.Ordinal);
return new ToolDefinition(name, description, method, path, schema, queryParameters);
}
}
+41
View File
@@ -0,0 +1,41 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
public sealed record ToolDefinition(
string Name,
string Description,
HttpMethod HttpMethod,
string PathTemplate,
JsonDocument InputSchema,
IReadOnlySet<string>? QueryParameters = null);
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);
}
+60
View File
@@ -0,0 +1,60 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
/// <summary>
/// A single tool input-schema property. <paramref name="ItemType"/> is only consulted when
/// <paramref name="Type"/> is <c>"array"</c> (it becomes the array's <c>items.type</c>).
/// </summary>
public sealed record SchemaProperty(
string Name,
string Type,
string Description,
bool Required,
string? ItemType = null);
public static class ToolInputSchemas
{
public static JsonDocument Empty { get; } = JsonDocument.Parse(
"""
{"type":"object","properties":{},"additionalProperties":false}
""");
public static JsonDocument Object(params SchemaProperty[] properties)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteString("type", "object");
writer.WriteStartObject("properties");
foreach (SchemaProperty property in properties)
{
writer.WriteStartObject(property.Name);
writer.WriteString("type", property.Type);
if (property is { Type: "array", ItemType: { } itemType })
{
writer.WriteStartObject("items");
writer.WriteString("type", itemType);
writer.WriteEndObject();
}
writer.WriteString("description", property.Description);
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.WriteStartArray("required");
foreach (SchemaProperty property in properties.Where(p => p.Required))
{
writer.WriteStringValue(property.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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp", "ErsatzTV.Mcp\ErsatzTV.Mcp.csproj", "{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp.Tests", "ErsatzTV.Mcp.Tests\ErsatzTV.Mcp.Tests.csproj", "{65F2FAF2-705F-4837-A92B-BB66182B163E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -315,6 +319,42 @@ Global
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug|x64.ActiveCfg = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug|x64.Build.0 = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug|x86.ActiveCfg = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug|x86.Build.0 = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Release|Any CPU.Build.0 = Release|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Release|x64.ActiveCfg = Release|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Release|x64.Build.0 = Release|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Release|x86.ActiveCfg = Release|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Release|x86.Build.0 = Release|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{2D0F528F-1260-45E5-BA0E-2BAFBCAE6AE2}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug|x64.ActiveCfg = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug|x64.Build.0 = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug|x86.ActiveCfg = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug|x86.Build.0 = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Release|Any CPU.Build.0 = Release|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Release|x64.ActiveCfg = Release|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Release|x64.Build.0 = Release|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Release|x86.ActiveCfg = Release|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Release|x86.Build.0 = Release|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{65F2FAF2-705F-4837-A92B-BB66182B163E}.Debug No Sync|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+2
View File
@@ -31,6 +31,8 @@ Also present in `docs/`:
- **`docs/rest-api.md`** — REST API design doc for ersatztv#2 (goals, conventions, per-slice plan).
Largely superseded day-to-day by `docs/api-conventions.md`; read this for the original rationale.
- **`docs/mcp.md`** — the `ErsatzTV.Mcp` stdio JSON-RPC MCP server (#58): how it wraps `/api/v1` as
read + cautious-write tools, its config/env vars, auth, security posture, and the tool catalog.
- **`docs/channels.md`** — Channel entity field reference.
- **`docs/m3u-xmltv.md`** — M3U/XMLTV generation overview (`ChannelPlaylist`, `GetChannelGuideHandler`).
- **`docs/fork-strategy.md`** — divergence policy vs upstream ErsatzTV.
+29
View File
@@ -2521,6 +2521,7 @@ The three gated levels now share one `ChannelLogoWatermarkOptions` helper so the
again — the duplication is what let the defect exist in triplicate. Covered by
`WatermarkSelectorChannelLogoTests`, which pins the external-URL fix, both preserved regressions
(cached local path, missing local file ignored) and the generated-fallback scope guard.
## 2026-07-20 — HLS cold start is fixed with `-readrate_initial_burst`, not by raising the work-ahead limit (#350)
- **`-readrate` throttles from the first read, so it sets a floor on time-to-first-segment.** The
@@ -2564,3 +2565,31 @@ again — the duplication is what let the defect exist in triplicate. Covered by
had simply been empty). Detection parses `ffmpeg -h long`, so an older binary silently keeps
today's behavior instead of failing to start — the same fail-safe posture as the other capability
gates, and cheaper to reason about than the version-string parsing in `NvidiaHardwareCapabilities`.
## 2026-07-20 — MCP server (`ErsatzTV.Mcp`) built fresh over frozen `/api/v1`: read + cautious writes (#58)
The MCP server (issue #58, gated on #286 route-freeze + #197 security review, both closed) is built
**fresh** as `ErsatzTV.Mcp` — a stdio JSON-RPC server wrapping `/api/v1` — superseding the closed
read-only PR #76 rather than rebasing it. Full doc: `docs/mcp.md`. Decisions frozen:
- **Explicit, narrow tools over a generic HTTP passthrough.** Each tool maps to one OpenAPI-backed
endpoint; there is no "call any URL" tool. Each declared argument routes to exactly one place —
path `{param}`, an explicit query parameter, the reserved `ifMatch` header, or (write verbs only)
the JSON body — and `additionalProperties:false` rejects anything undeclared before a request is built.
- **Read-only is the default, enforced at runtime, not just by catalog shape.** The executor refuses
any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true`, so one wrong catalog entry cannot mutate. This
is the PR #76/#289 posture carried forward verbatim, alongside the JSON-RPC DoS guards
(`-32700/-32600/-32602/-32603`, bounded stdin line reader), the per-request CTS covering headers
**and** the streamed body (the `ResponseHeadersRead` gotcha, #289), the response-size cap, and the
reverse-proxy prefix preservation.
- **Machine-key auth, CSRF-exempt.** The server sends `X-Api-Key` on every request and no `X-CSRF`
header (§9: key-authed requests are CSRF-exempt). `ERSATZTV_API_KEY` is effectively required.
- **`If-Match` is opt-in per call, and ETags are surfaced, not managed.** Only the replace-all PUTs
honor `If-Match` (here `update_collection_custom_order`); a response `ETag` header is appended to the
tool result as `[etag: "N"]` so an agent can round-trip it. All other writes force-write (§7a), so
no ETag handshake is needed — notably `add_collection_items`, whose re-add is an idempotent no-op.
- **Channel create/update is exposed** (28-field DTO): only `name`/`number`/`ffmpegProfileId` are
required, enums take the enum name and are API-validated, and `get_channel` makes the shape/current
values discoverable. **Deferred as too-large for a cautious v0.1** (not a contract gap): the ~40-field
schedule-item / playout replace-list writes. Redesign workflow tools (#63#68) stay deferred until
their backend endpoints exist. No `/api` endpoint was added, so no OpenAPI regen.
+176
View File
@@ -0,0 +1,176 @@
# ErsatzTV MCP Server
`ErsatzTV.Mcp` is a stdio JSON-RPC [MCP](https://modelcontextprotocol.io) server that wraps the
frozen ErsatzTV/ChicoryTV `/api/v1` REST surface as explicit, narrow tools for AI agents. It exposes
**read** tools by default and **cautious-write** tools behind an opt-in (issue #58).
It maps each tool to an OpenAPI-backed endpoint in `ErsatzTV/wwwroot/openapi/v1.json`. It does **not**
scrape the web UI and does **not** read or write SQLite directly.
> This is a fresh build against the versioned `/api/v1` contract (mounted by #286), superseding the
> read-only v0 foundation in the closed PR #76. The security baseline below is carried forward from
> PR #76 / #289 verbatim.
## Running
```bash
dotnet build ErsatzTV.Mcp/ErsatzTV.Mcp.csproj
```
Configure an MCP client to start the server over stdio:
```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 request. Effectively **required** (see Authentication). |
| `ERSATZTV_ALLOW_WRITES` | `false` | Write posture. While `false`, the executor refuses any non-GET tool before it reaches the API. Set `true` to enable the write tools below. |
| `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 (covers headers **and** the streamed body). |
## Authentication
ErsatzTV's `/api` surface is gated by a fail-closed session-or-key filter (`api-conventions.md` §9).
The MCP server is a **machine client**, so it authenticates with **`X-Api-Key`** on every request:
- **Every write** (POST/PUT/PATCH/DELETE) requires the key. There is no "open" write mode.
- **Reads** require the key too under the default `Api:RequireKeyForReads=true`.
- Key-authed requests are **CSRF-exempt** (the `X-CSRF` header the browser session path needs does not
apply to the machine key), so the MCP server sends no CSRF header.
So **`ERSATZTV_API_KEY` is effectively required**; without it tool calls return `401`. The key is the
server machine key — surfaced read-only by the SPA's machine-key screen
(`GET /api/v1/auth/machine-key`) or persisted at `/config/api.key`.
## Security posture
- **Read-only by default, runtime-enforced.** Even if a catalog entry were wrong, the executor refuses
any non-GET tool unless `ERSATZTV_ALLOW_WRITES=true` — a single bad entry cannot mutate or delete.
- **Malformed input never crashes the session.** Invalid JSON → JSON-RPC `-32700` (id `null`); a
malformed request object → `-32600`; a bad tool call → `-32602`; a transport/timeout failure →
`-32603` for the id (a compliant client never hangs). The `Program.Main` read loop also catches any
unexpected per-line error.
- **Bounded input and output.** A hostile client cannot exhaust memory with a giant unterminated line
(`BoundedLineReader` caps + drains it), and API bodies are read up to `ERSATZTV_MAX_RESPONSE_BYTES`
and truncated (on a UTF-8 code-point boundary). Every request has a timeout covering headers **and**
the streamed body.
- **Arguments are validated** against each tool's declared `InputSchema` (required present, no unknown
args — `additionalProperties:false` — basic types) before any request is built. Path params reject
`.`/`..` so a value can't canonicalize onto a different route.
- **Tool results are untrusted data.** Response bodies (media titles, file paths, error text) 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.
## How tools map to the API
Each declared argument routes to exactly one place:
- **path** — a `{param}` in the path template (URL-encoded; `.`/`..` rejected).
- **query** — an argument listed in the tool's query-parameter set (URL-encoded onto the query string,
for any verb).
- **`ifMatch`** — the reserved header argument, carried as the RFC 7232 `If-Match` request header (see
Optimistic concurrency). A value containing control characters (CR/LF) is rejected before the request
is sent, so it cannot smuggle additional headers onto the API-key-bearing request.
- **body** — for write verbs (POST/PUT/PATCH), every remaining argument is serialized as the JSON
request body (`application/json`).
When a response carries an `ETag` header (versioned aggregates emit it on GET and on a successful
replace PUT), the tool result appends a `\n[etag: "N"]` marker so an agent can round-trip it as
`ifMatch` on a subsequent write.
### Optimistic concurrency (`api-conventions.md` §7a)
Only the **replace-all aggregate PUTs** honor `If-Match` — here that is
`ersatztv_update_collection_custom_order`. Read the ETag from the matching GET
(`ersatztv_get_collection_items`), pass it back as `ifMatch` (e.g. `"3"`); a stale tag → `412`, a
grammar violation → `400`, `"*"` or omitting it force-writes. All other writes ignore `If-Match` and
force-write, so no ETag handshake is needed for them.
## Read tools
| Tool | API route |
|---|---|
| `ersatztv_list_channels` | `GET /api/v1/channels` |
| `ersatztv_get_channel` | `GET /api/v1/channels/{id}` |
| `ersatztv_list_collections` | `GET /api/v1/collections` |
| `ersatztv_get_collection` | `GET /api/v1/collections/{id}` |
| `ersatztv_get_collection_items` | `GET /api/v1/collections/{id}/items` (paged; emits ETag) |
| `ersatztv_list_smart_collections` | `GET /api/v1/smart-collections` |
| `ersatztv_get_smart_collection` | `GET /api/v1/smart-collections/{id}` |
| `ersatztv_list_schedules` | `GET /api/v1/schedules` |
| `ersatztv_get_schedule` | `GET /api/v1/schedules/{id}` |
| `ersatztv_get_schedule_items` | `GET /api/v1/schedules/{id}/items` (emits ETag) |
| `ersatztv_list_playouts` | `GET /api/v1/playouts` |
| `ersatztv_get_playout` | `GET /api/v1/playouts/{id}` |
| `ersatztv_get_playout_items` | `GET /api/v1/playouts/{id}/items` |
| `ersatztv_list_ffmpeg_profiles` | `GET /api/v1/ffmpeg/profiles` |
| `ersatztv_get_ffmpeg_profile` | `GET /api/v1/ffmpeg/profiles/{id}` |
| `ersatztv_get_resolution_by_name` | `GET /api/v1/ffmpeg/resolution/by-name/{name}` |
| `ersatztv_list_sessions` | `GET /api/v1/sessions` |
| `ersatztv_get_version` | `GET /api/v1/version` |
| `ersatztv_list_media_sources` | `GET /api/v1/media-sources` |
| `ersatztv_get_jellyfin_libraries` | `GET /api/v1/media-sources/jellyfin/{id}/libraries` |
| `ersatztv_list_local_libraries` | `GET /api/v1/libraries/local` |
| `ersatztv_get_library_scan_status` | `GET /api/v1/libraries/scan-status` |
| `ersatztv_search` | `GET /api/v1/search` |
| `ersatztv_search_all_items` | `GET /api/v1/search/all-items` (raw id lists) |
| `ersatztv_search_artists` | `GET /api/v1/search/artists` |
## Write tools (require `ERSATZTV_ALLOW_WRITES=true`)
| Tool | API route |
|---|---|
| `ersatztv_create_collection` | `POST /api/v1/collections` |
| `ersatztv_update_collection` | `PUT /api/v1/collections/{id}` |
| `ersatztv_delete_collection` | `DELETE /api/v1/collections/{id}` |
| `ersatztv_add_collection_items` | `POST /api/v1/collections/{id}/items` (idempotent; existence-checked) |
| `ersatztv_remove_collection_item` | `DELETE /api/v1/collections/{id}/items/{mediaItemId}` |
| `ersatztv_update_collection_custom_order` | `PUT /api/v1/collections/{id}/custom-order` (honors `If-Match`) |
| `ersatztv_create_smart_collection` | `POST /api/v1/smart-collections` |
| `ersatztv_update_smart_collection` | `PUT /api/v1/smart-collections/{id}` |
| `ersatztv_delete_smart_collection` | `DELETE /api/v1/smart-collections/{id}` |
| `ersatztv_create_schedule` | `POST /api/v1/schedules` |
| `ersatztv_update_schedule` | `PUT /api/v1/schedules/{id}` |
| `ersatztv_delete_schedule` | `DELETE /api/v1/schedules/{id}` |
| `ersatztv_create_playout` | `POST /api/v1/playouts` |
| `ersatztv_update_playout` | `PUT /api/v1/playouts/{id}` |
| `ersatztv_delete_playout` | `DELETE /api/v1/playouts/{id}` |
| `ersatztv_create_channel` | `POST /api/v1/channels` |
| `ersatztv_update_channel` | `PUT /api/v1/channels/{id}` |
| `ersatztv_reset_channel_playout` | `POST /api/v1/channels/{id}/playout/reset` |
| `ersatztv_delete_channel` | `DELETE /api/v1/channels/{id}` |
| `ersatztv_enable_jellyfin_library_sync` | `PUT /api/v1/media-sources/jellyfin/{id}/libraries` |
| `ersatztv_refresh_jellyfin_libraries` | `POST /api/v1/media-sources/jellyfin/{id}/refresh-libraries` |
| `ersatztv_scan_jellyfin_collections` | `POST /api/v1/media-sources/jellyfin/{id}/scan-collections` |
| `ersatztv_scan_library` | `POST /api/v1/libraries/{id}/scan` |
### Populating a collection (the #487 acceptance case)
`ersatztv_add_collection_items` funnels every media kind through one endpoint — send only the id
buckets you need (`artistIds`, `musicVideoIds`, `songIds`, `movieIds`, …). Discover ids with
`ersatztv_search_all_items` (returns raw id lists for a Lucene query) or `ersatztv_search_artists`.
Re-adding an already-present item is an **idempotent no-op** (no duplicate rows, still `204`); if any
referenced id does not exist the whole batch is rejected (`422`). So the flow is: search → add ids →
re-run to confirm idempotence.
## Deferred
Channel create/update (`ersatztv_create_channel` / `ersatztv_update_channel`) wrap a 28-field DTO with
nine enum fields. Only `name`/`number`/`ffmpegProfileId` are required; the rest have server-side
defaults, and the enum fields take the enum **name** (the API validates them). Discover an existing
channel's shape and current enum values with `ersatztv_get_channel` before creating/updating.
Deliberately **not** exposed in this cautious first write pass:
- **The replace-list writes with large item DTOs** — schedule items (`PUT .../schedules/{id}/items`,
~40 fields per item) and playout alternate-schedules/templates. The simple
`update_collection_custom_order` replace is exposed as the `If-Match` exemplar.
- **Redesign-aware workflow tools** — create-channel-from-lineup (#63), Channel Templates (#64),
library browse/artwork (#65), image/logo/watermark (#66/#67), resume/bookmark (#68). These should
wrap the composite backend endpoints once those contracts exist, not recreate workflows in MCP.