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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user