Files
ersatztv/ErsatzTV.Mcp.Tests/ErsatzTvApiClientTests.cs
T
timothyandClaude Opus 4.8 a88240dcec
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
feat(#58): ErsatzTV.Mcp — read + cautious-write MCP server over /api/v1
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>
2026-07-21 00:42:00 +02:00

486 lines
20 KiB
C#

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);
}
}
}