From 33a1dbc2676a1cf6d5fe632321d792a2b203f012 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Wed, 17 Jun 2026 12:57:18 -0400 Subject: [PATCH] test(ai): over-the-wire MCP integration tests + close CI gap (AI-053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New hermetic project tests/TextStack.Ai.Mcp.Tests: the real MCP SDK client (McpClient → HttpClientTransport, streamable HTTP) drives the real BuildHttp MapMcp("/mcp") host on a loopback port — exercising the actual JSON-RPC wire framing + the scoped per-request HttpContextTokenProvider that the unit tests (fake HttpMessageHandler) skip. A recording StubBackend stands in for the API so each test asserts both the mapped MCP result AND the exact upstream request (method/path/Bearer/body) the bridge issued. 15 tests: 7 per-tool + ask_book spoiler-gate + initialize + tools/list(==7) + negatives (no-bearer → auth-required IsError + ZERO upstream hits; invalid args → no call; unreachable upstream → clean IsError not protocol fault) + the one-session e2e (single client: list → get_chapter → save_highlight → ask, same bearer) + a stdio subprocess smoke (spawns the shipped DLL; skips if absent). Hermetic — loopback only, no Docker/API/net. Closes a real CI gap: the AI-047..050 MCP UNIT tests were built but NEVER run by an explicit dotnet test step. ci.yml backend job now runs both tests/TextStack.Ai.Mcp.Tests and tests/TextStack.UnitTests. QA: hardened the save_highlight body assertions; fixed a prod cosmetic — HttpContextTokenProvider no longer double-prefixes "authentication required — " (the catalog wrapper already adds it). 15 integration + 202 MCP unit (629 UnitTests) green; StudyBuddy set-equality intact (the test assembly references only the MCP bridge, no ITool). Completes Phase 8. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 11 + CHANGELOG.md | 8 + .../Auth/HttpContextTokenProvider.cs | 4 +- .../McpOverTheWireTests.cs | 340 ++++++++++++++++++ .../McpServerHarness.cs | 114 ++++++ .../McpStdioSmokeTests.cs | 90 +++++ tests/TextStack.Ai.Mcp.Tests/StubBackend.cs | 309 ++++++++++++++++ .../TextStack.Ai.Mcp.Tests.csproj | 33 ++ textstack.sln | 15 + 9 files changed, 923 insertions(+), 1 deletion(-) create mode 100644 tests/TextStack.Ai.Mcp.Tests/McpOverTheWireTests.cs create mode 100644 tests/TextStack.Ai.Mcp.Tests/McpServerHarness.cs create mode 100644 tests/TextStack.Ai.Mcp.Tests/McpStdioSmokeTests.cs create mode 100644 tests/TextStack.Ai.Mcp.Tests/StubBackend.cs create mode 100644 tests/TextStack.Ai.Mcp.Tests/TextStack.Ai.Mcp.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ec410a0..b8d32f8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,17 @@ jobs: - name: Search tests run: dotnet test tests/TextStack.Search.Tests --no-build + # MCP over-the-wire integration suite (AI-053) — hermetic (loopback only, no + # Postgres/Docker). In the solution, so --no-build reuses the Build step above. + - name: MCP integration tests + run: dotnet test tests/TextStack.Ai.Mcp.Tests --no-build + + # Unit tests (incl. the AI-047..050 MCP unit suites) are NOT in textstack.sln, + # so the Build step doesn't compile them — run WITHOUT --no-build. This closes a + # real CI gap: these were never executed by an explicit dotnet test step before. + - name: Unit tests + run: dotnet test tests/TextStack.UnitTests + frontend: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea78605..3170d61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Phase 8 — MCP server over-the-wire integration tests (AI-053) (2026-06-17) + +Closes the last test gap in the MCP stack: the AI-047..050 unit tests use fake `HttpMessageHandler`s and never exercise the protocol / `McpHosts.BuildHttp` / the SDK wire framing. AI-053 drives the **REAL** MCP server through the **REAL** wire protocol (`initialize` → `tools/list` → `tools/call`) for all 7 tools + a one-session e2e, hermetically (no Docker, no live API, no external network). + +- **Harness** — in-process **loopback** host (NOT WAF in-memory): the real `BuildHttp` `WebApplication` (`MapMcp("/mcp")`, streamable HTTP, `Stateless=true`) is started on a 127.0.0.1 ephemeral port and driven by the real `ModelContextProtocol.Client.McpClient` over an `HttpClientTransport` (`HttpTransportMode.StreamableHttp`, `AdditionalHeaders["Authorization"]="Bearer "` for user-scoped tools). The bridge's `TextStackApiClient` is pointed (via `McpBridgeOptions.ApiBaseUrl`) at a **recording stub backend** — a second loopback `WebApplication` with minimal-API endpoints returning canned camelCase JSON matching the client's local DTOs (Dracula hit), which records the last request per route (method, path+query, Authorization, Host, body) for outbound-call assertions. Loopback chosen over `WebApplicationFactory` to mirror the existing `BuildHttp_StartsWebApplication_HealthReturns200` test and avoid TestServer streamable-HTTP quirks — still fully hermetic. +- **Tests** (`tests/TextStack.Ai.Mcp.Tests/`, new xUnit.v3 project, references ONLY `TextStack.Ai.Mcp` → zero `ITool` in the assembly, StudyBuddy set-equality safe by construction): per-tool over the wire (search_books, get_book mapped editionId, get_chapter, list_my_highlights Bearer, list_my_vocabulary Bearer, save_highlight WRITE with synthesized anchor in body, ask_book Bearer) asserting both the mapped MCP result AND that the stub saw the right upstream call; the ask_book `Insufficient` spoiler-gate variant (clean non-error text); protocol (`initialize`→serverInfo name=="textstack", `tools/list`→exactly 7); negatives over the wire (user-scoped without bearer → `IsError` "authentication required" + ZERO upstream hits; missing required arg → tool `IsError` + no upstream call; unreachable upstream → clean "upstream unavailable" `IsError`, NOT a JSON-RPC protocol fault); a one-session e2e (single `McpClient`/one initialize → tools/list → get_chapter → save_highlight → ask_book); and a **stdio subprocess smoke** (`StdioClientTransport` spawning the built `TextStack.Ai.Mcp.dll` with env config → initialize + tools/list returns 7, **skippable** if the DLL isn't built). 15 tests, all green, no network. +- **CI gap closed** (`.github/workflows/ci.yml` `backend` job). The MCP unit tests in `TextStack.UnitTests` were NEVER run by an explicit `dotnet test` step (only Search.Tests + IntegrationTests ran). Added two hermetic steps: `dotnet test tests/TextStack.Ai.Mcp.Tests --no-build` (new project, in the solution so the Build step compiles it) and `dotnet test tests/TextStack.UnitTests` (NOT in the solution — runs without `--no-build` — so the AI-047..050 MCP unit suites + everything in UnitTests actually execute in CI). New project added to `textstack.sln`. + ### Phase 8 — enable remote MCP in the prod deploy (AI-049 follow-up) (2026-06-17) Makes the remote MCP endpoint actually go live. The `mcp-server` Docker service is profile-gated (`profiles:[mcp]`) so CI's bare `compose up` never builds/runs it — which also meant the prod deploy didn't bring it up. Fixed: `deploy.yml` now runs `docker compose --profile mcp ...` so prod opts the service in (CI still excludes it). The nginx `/mcp` block already syncs via the existing "Sync nginx config" step. Added a **Health check MCP server** deploy step (fail-loud): asserts `mcp-server` `/health` is up AND a real MCP `initialize` POST through nginx `/mcp` returns 200. After this merges + deploys, `https://textstack.app/mcp` is the remote streamable-HTTP MCP endpoint (for ChatGPT / Cursor / remote Claude), authenticated per-request via the device-flow JWT. Verified the http transport locally (`/health`→200, `POST /mcp` initialize→`text/event-stream` 200). diff --git a/backend/src/Ai/TextStack.Ai.Mcp/Auth/HttpContextTokenProvider.cs b/backend/src/Ai/TextStack.Ai.Mcp/Auth/HttpContextTokenProvider.cs index 5c380db4..fadddc7e 100644 --- a/backend/src/Ai/TextStack.Ai.Mcp/Auth/HttpContextTokenProvider.cs +++ b/backend/src/Ai/TextStack.Ai.Mcp/Auth/HttpContextTokenProvider.cs @@ -23,8 +23,10 @@ public sealed class HttpContextTokenProvider : IMcpTokenProvider { private const string BearerScheme = "Bearer "; + // Detail only — the catalog wrapper already prefixes "authentication required — ", + // so we must NOT repeat it here (avoids "authentication required — authentication required — …"). private static readonly TokenResult.Failed NoBearer = new( - "authentication required — set Authorization: Bearer in your MCP client"); + "set Authorization: Bearer in your MCP client"); private readonly IHttpContextAccessor _accessor; diff --git a/tests/TextStack.Ai.Mcp.Tests/McpOverTheWireTests.cs b/tests/TextStack.Ai.Mcp.Tests/McpOverTheWireTests.cs new file mode 100644 index 00000000..d59a3bc7 --- /dev/null +++ b/tests/TextStack.Ai.Mcp.Tests/McpOverTheWireTests.cs @@ -0,0 +1,340 @@ +using System.Text.Json; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace TextStack.Ai.Mcp.Tests; + +/// +/// AI-053 — over-the-wire MCP integration tests. The REAL +/// drives the REAL BuildHttp host (MapMcp("/mcp"), streamable HTTP, +/// SDK JSON-RPC framing) through the full protocol — initialize → tools/list → +/// tools/call — for all 7 tools, a one-session e2e, the negative paths, and the +/// spoiler gate. Backed by a recording ; the tests assert +/// BOTH the mapped MCP result AND that the bridge issued the right upstream call +/// (method / path+query / Bearer). +/// +/// Hermetic: loopback only (no Docker, no live API, no external network). References +/// only the bridge under test → zero ITool in this assembly (StudyBuddy set-equality +/// safe by construction). The AI-047..050 unit tests use fake HttpMessageHandlers and +/// never exercise the SDK wire — this suite closes that gap. +/// +public class McpOverTheWireTests : IAsyncLifetime +{ + private McpServerHarness _harness = null!; + + public async ValueTask InitializeAsync() => + _harness = await McpServerHarness.StartAsync(TestContext.Current.CancellationToken); + + public async ValueTask DisposeAsync() => await _harness.DisposeAsync(); + + private CancellationToken Ct => TestContext.Current.CancellationToken; + + // ── helpers ────────────────────────────────────────────────────────────────── + + private static Dictionary Args(params (string Key, object? Value)[] pairs) + { + var d = new Dictionary(StringComparer.Ordinal); + foreach (var (k, v) in pairs) + d[k] = v; + return d; + } + + private static string TextOf(CallToolResult result) => ((TextContentBlock)result.Content[0]).Text; + + private static JsonElement Json(CallToolResult result) => JsonDocument.Parse(TextOf(result)).RootElement; + + private async Task CallAsync(McpClient client, string tool, Dictionary args) => + await client.CallToolAsync(tool, args!, cancellationToken: Ct); + + // ── 1. search_books (public, no Bearer) ─────────────────────────────────────── + + [Fact] + public async Task SearchBooks_OverWire_ReturnsDraculaHit_AndStubSawPublicGet() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + var result = await CallAsync(client, "search_books", Args(("query", "dracula"), ("limit", 5))); + + Assert.NotEqual(true, result.IsError); + var first = Assert.Single(Json(result).GetProperty("results").EnumerateArray()); + Assert.Equal("Dracula", first.GetProperty("title").GetString()); + Assert.Equal("Bram Stoker", first.GetProperty("author").GetString()); + Assert.Equal("dracula", first.GetProperty("editionSlug").GetString()); + + var req = _harness.Stub.Last("search"); + Assert.NotNull(req); + Assert.Equal("GET", req!.Method); + Assert.Equal("/search?q=dracula&limit=5", req.PathAndQuery); + Assert.Null(req.Authorization); // public → no bearer + Assert.Equal("textstack.app", req.Host); + } + + // ── 2. get_book (public, maps editionId) ────────────────────────────────────── + + [Fact] + public async Task GetBook_OverWire_MapsEditionId_AndStubSawPublicGet() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + var result = await CallAsync(client, "get_book", Args(("slug", "dracula"))); + + Assert.NotEqual(true, result.IsError); + var root = Json(result); + Assert.Equal(StubBackend.GoodEdition, root.GetProperty("editionId").GetString()); + Assert.Equal("Dracula", root.GetProperty("title").GetString()); + Assert.Equal("Bram Stoker", root.GetProperty("authors")[0].GetString()); + Assert.Equal(2, root.GetProperty("chapters").GetArrayLength()); + + var req = _harness.Stub.Last("get_book"); + Assert.Equal("GET", req!.Method); + Assert.Equal("/books/dracula", req.PathAndQuery); + Assert.Null(req.Authorization); + } + + // ── 3. get_chapter (public, HTML stripped) ──────────────────────────────────── + + [Fact] + public async Task GetChapter_OverWire_StripsHtml_AndStubSawPublicGet() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + var result = await CallAsync(client, "get_chapter", Args(("slug", "dracula"), ("chapterSlug", "ch-1"))); + + Assert.NotEqual(true, result.IsError); + var root = Json(result); + Assert.Equal(1, root.GetProperty("chapterNumber").GetInt32()); + Assert.Equal("ch-2", root.GetProperty("nextSlug").GetString()); + // Tags stripped, — decoded, whitespace collapsed. (Inline-element + // boundaries become spaces — pin the bridge's HtmlText.StripAndCap output.) + Assert.Equal("3 May. Bistritz . — Left Munich at 8:35 P.M.", root.GetProperty("text").GetString()); + + var req = _harness.Stub.Last("get_chapter"); + Assert.Equal("/books/dracula/chapters/ch-1", req!.PathAndQuery); + Assert.Null(req.Authorization); + } + + // ── 4. list_my_highlights (Bearer) ──────────────────────────────────────────── + + [Fact] + public async Task ListMyHighlights_OverWire_ForwardsBearer_MapsFields() + { + await using var client = await _harness.ConnectAsync(McpServerHarness.TestJwt, Ct); + + var result = await CallAsync(client, "list_my_highlights", Args(("editionId", StubBackend.GoodEdition))); + + Assert.NotEqual(true, result.IsError); + var first = Assert.Single(Json(result).GetProperty("highlights").EnumerateArray()); + Assert.Equal("the dead travel fast", first.GetProperty("selectedText").GetString()); + + var req = _harness.Stub.Last("list_my_highlights"); + Assert.Equal("GET", req!.Method); + Assert.Equal($"/me/highlights/{StubBackend.GoodEdition}", req.PathAndQuery); + Assert.Equal($"Bearer {McpServerHarness.TestJwt}", req.Authorization); + } + + // ── 5. list_my_vocabulary (Bearer) ──────────────────────────────────────────── + + [Fact] + public async Task ListMyVocabulary_OverWire_ForwardsBearer_MapsPage() + { + await using var client = await _harness.ConnectAsync(McpServerHarness.TestJwt, Ct); + + var result = await CallAsync(client, "list_my_vocabulary", Args()); + + Assert.NotEqual(true, result.IsError); + var root = Json(result); + Assert.Equal(1, root.GetProperty("total").GetInt32()); + Assert.Equal("crepuscular", root.GetProperty("items")[0].GetProperty("word").GetString()); + + var req = _harness.Stub.Last("list_my_vocabulary"); + Assert.Equal("/me/vocabulary/words", req!.PathAndQuery); + Assert.Equal($"Bearer {McpServerHarness.TestJwt}", req.Authorization); + } + + // ── 6. save_highlight (Bearer, WRITE, synthesized anchor in body) ───────────── + + [Fact] + public async Task SaveHighlight_OverWire_ForwardsBearer_PostsSynthesizedAnchor_Returns201() + { + await using var client = await _harness.ConnectAsync(McpServerHarness.TestJwt, Ct); + + var result = await CallAsync(client, "save_highlight", Args( + ("editionId", StubBackend.GoodEdition), + ("chapterId", StubBackend.ChapterId), + ("selectedText", "Listen to them, the children of the night"), + ("color", "green"), + ("noteText", "famous line"))); + + Assert.NotEqual(true, result.IsError); + var root = Json(result); + Assert.Equal(StubBackend.NewHighlightId, root.GetProperty("id").GetString()); + Assert.True(root.GetProperty("saved").GetBoolean()); + + var req = _harness.Stub.Last("save_highlight"); + Assert.Equal("POST", req!.Method); + Assert.Equal("/me/highlights", req.PathAndQuery); + Assert.Equal($"Bearer {McpServerHarness.TestJwt}", req.Authorization); + + // The bridge forwarded every agent-provided field on the POST body… + var sent = JsonDocument.Parse(req.Body).RootElement; + Assert.Equal(StubBackend.GoodEdition, sent.GetProperty("editionId").GetString()); + Assert.Equal(StubBackend.ChapterId, sent.GetProperty("chapterId").GetString()); + Assert.Equal("green", sent.GetProperty("color").GetString()); + Assert.Equal("Listen to them, the children of the night", sent.GetProperty("selectedText").GetString()); + Assert.Equal("famous line", sent.GetProperty("noteText").GetString()); + // …and synthesized a W3C text-quote anchor server-side (no DOM client): exact = + // the selection, source = "mcp", and chapterId carried into the anchor too. + var anchor = JsonDocument.Parse(sent.GetProperty("anchorJson").GetString()!).RootElement; + Assert.Equal("Listen to them, the children of the night", anchor.GetProperty("exact").GetString()); + Assert.Equal("mcp", anchor.GetProperty("source").GetString()); + Assert.Equal(StubBackend.ChapterId, anchor.GetProperty("chapterId").GetString()); + } + + // ── 7. ask_book (Bearer) + spoiler-gate variant ────────────────────────────── + + [Fact] + public async Task AskBook_OverWire_ForwardsBearer_MapsAnswerAndCitations() + { + await using var client = await _harness.ConnectAsync(McpServerHarness.TestJwt, Ct); + + var result = await CallAsync(client, "ask_book", Args( + ("editionId", StubBackend.GoodEdition), + ("question", "where does Jonathan Harker travel?"), + ("k", 5))); + + Assert.NotEqual(true, result.IsError); + var root = Json(result); + Assert.Contains("Transylvania", root.GetProperty("answer").GetString()); + var cite = Assert.Single(root.GetProperty("citations").EnumerateArray()); + Assert.Equal(1, cite.GetProperty("marker").GetInt32()); + + var req = _harness.Stub.Last("ask_book"); + Assert.Equal("POST", req!.Method); + Assert.Equal($"/books/{StubBackend.GoodEdition}/ask", req.PathAndQuery); + Assert.Equal($"Bearer {McpServerHarness.TestJwt}", req.Authorization); + var sent = JsonDocument.Parse(req.Body).RootElement; + Assert.Equal("where does Jonathan Harker travel?", sent.GetProperty("question").GetString()); + Assert.Equal(5, sent.GetProperty("k").GetInt32()); + } + + [Fact] + public async Task AskBook_SpoilerGate_OverWire_ReturnsCleanText_NotError() + { + await using var client = await _harness.ConnectAsync(McpServerHarness.TestJwt, Ct); + + var result = await CallAsync(client, "ask_book", Args( + ("editionId", StubBackend.SpoilerEdition), + ("question", "how does the book end?"))); + + // Insufficient is expected, not an error — clean relayable text. + Assert.NotEqual(true, result.IsError); + Assert.Contains("haven't read far enough", TextOf(result)); + } + + // ── 8. protocol: initialize → serverInfo ────────────────────────────────────── + + [Fact] + public async Task Initialize_OverWire_ServerInfoNameIsTextstack() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + Assert.Equal("textstack", client.ServerInfo.Name); + } + + // ── 9. protocol: tools/list → exactly 7 ─────────────────────────────────────── + + [Fact] + public async Task ListTools_OverWire_ReturnsExactlySevenExpectedTools() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + var tools = await client.ListToolsAsync(cancellationToken: Ct); + + var names = tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray(); + Assert.Equal( + ["ask_book", "get_book", "get_chapter", "list_my_highlights", "list_my_vocabulary", "save_highlight", "search_books"], + names); + } + + // ── 10. negative: user-scoped WITHOUT bearer → IsError, zero upstream hits ───── + + [Fact] + public async Task UserScopedTool_NoBearer_OverWire_AuthRequired_StubGotZeroHits() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + var result = await CallAsync(client, "list_my_highlights", Args(("editionId", StubBackend.GoodEdition))); + + Assert.True(result.IsError); + Assert.Contains("authentication required", TextOf(result)); + // The bridge never issued the upstream call (no token → fail-clean up front). + Assert.Equal(0, _harness.Stub.TotalRequests); + } + + // ── 11. negative: invalid args (missing required) → IsError, no upstream call ── + + [Fact] + public async Task SearchBooks_MissingQuery_OverWire_ToolError_NoUpstreamCall() + { + await using var client = await _harness.ConnectAsync(bearer: null, Ct); + + var result = await CallAsync(client, "search_books", Args()); + + Assert.True(result.IsError); + Assert.Equal(0, _harness.Stub.TotalRequests); + } + + // ── 12. negative: upstream non-JSON 200 → clean IsError, NOT a protocol fault ── + + [Fact] + public async Task GetBook_UpstreamUnreachable_OverWire_CleanToolError_NotProtocolFault() + { + // Point a fresh harness's bridge at an unreachable upstream → the transport + // failure surfaces as a clean "upstream unavailable" tool IsError (the bridge's + // shared wrapper), NOT a JSON-RPC -32603 protocol fault that would break the + // SDK call. Proves the wire protocol stays intact on backend failure. + await using var broken = await McpServerHarness.StartWithUnreachableUpstreamAsync(Ct); + await using var client = await broken.ConnectAsync(bearer: null, Ct); + + var result = await client.CallToolAsync( + "get_book", new Dictionary { ["slug"] = "dracula" }!, cancellationToken: Ct); + + Assert.True(result.IsError); + Assert.Contains("get_book failed", TextOf(result)); + // Must NOT leak internals to the model. + Assert.DoesNotContain("Exception", TextOf(result)); + } + + // ── 13. one-session e2e: ONE initialize → list → get_chapter → save → ask ───── + + [Fact] + public async Task OneSession_OverWire_ListSaveAsk_AllSucceed() + { + // A single client (one initialize handshake) exercises the DoD path: + // "list chapters, save highlight, ask, one session". + await using var client = await _harness.ConnectAsync(McpServerHarness.TestJwt, Ct); + + var tools = await client.ListToolsAsync(cancellationToken: Ct); + Assert.Equal(7, tools.Count); + + var chapter = await CallAsync(client, "get_chapter", Args(("slug", "dracula"), ("chapterSlug", "ch-1"))); + Assert.NotEqual(true, chapter.IsError); + + var saved = await CallAsync(client, "save_highlight", Args( + ("editionId", StubBackend.GoodEdition), + ("chapterId", StubBackend.ChapterId), + ("selectedText", "the dead travel fast"))); + Assert.NotEqual(true, saved.IsError); + Assert.True(Json(saved).GetProperty("saved").GetBoolean()); + + var answer = await CallAsync(client, "ask_book", Args( + ("editionId", StubBackend.GoodEdition), + ("question", "what is Jonathan Harker doing?"))); + Assert.NotEqual(true, answer.IsError); + Assert.Contains("Transylvania", Json(answer).GetProperty("answer").GetString()); + + // All three user-scoped calls forwarded the same session bearer. + Assert.Equal($"Bearer {McpServerHarness.TestJwt}", _harness.Stub.Last("save_highlight")!.Authorization); + Assert.Equal($"Bearer {McpServerHarness.TestJwt}", _harness.Stub.Last("ask_book")!.Authorization); + } +} diff --git a/tests/TextStack.Ai.Mcp.Tests/McpServerHarness.cs b/tests/TextStack.Ai.Mcp.Tests/McpServerHarness.cs new file mode 100644 index 00000000..752fcaae --- /dev/null +++ b/tests/TextStack.Ai.Mcp.Tests/McpServerHarness.cs @@ -0,0 +1,114 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using ModelContextProtocol.Client; +using TextStack.Ai.Mcp; + +namespace TextStack.Ai.Mcp.Tests; + +/// +/// Boots the REAL MCP BuildHttp host (the same WebApplication + +/// MapMcp("/mcp") the production container runs) on a loopback port, pointed +/// at a . Tests then drive it with the REAL +/// ModelContextProtocol.Client.McpClient over an HttpClientTransport +/// (streamable HTTP) — exercising the full wire protocol: initialize → tools/list → +/// tools/call, with the SDK's JSON-RPC framing, not a fake handler. +/// +/// Loopback (not WAF in-memory) is the chosen harness: it mirrors the existing +/// BuildHttp_StartsWebApplication_HealthReturns200 unit test and sidesteps any +/// TestServer streamable-HTTP quirks while staying fully hermetic (127.0.0.1 only, +/// no Docker, no live API, no external network). +/// +/// The bridge's bearer comes from HttpContextTokenProvider in http mode, so +/// authorized tool calls forward the client's Authorization: Bearer header all +/// the way to the stub — exactly how the production remote transport behaves. +/// +public sealed class McpServerHarness : IAsyncDisposable +{ + public const string TestJwt = "test-jwt.header.payload"; + + private readonly WebApplication _mcp; + private readonly StubBackend _stub; + + public StubBackend Stub => _stub; + public string McpEndpoint { get; } + + private McpServerHarness(WebApplication mcp, StubBackend stub, string mcpEndpoint) + { + _mcp = mcp; + _stub = stub; + McpEndpoint = mcpEndpoint; + } + + public static async Task StartAsync(CancellationToken ct) + { + var stub = await StubBackend.StartAsync(ct); + return await StartInternalAsync(stub, stub.BaseUrl, ct); + } + + /// + /// Harness whose bridge points at an UNREACHABLE upstream (a closed loopback + /// port). Used to prove transport failures surface as a clean tool IsError + /// over the wire, not a JSON-RPC protocol fault. Still hermetic (no network). + /// + public static async Task StartWithUnreachableUpstreamAsync(CancellationToken ct) + { + // A still-running stub so Dispose is uniform, but the bridge is pointed at a + // DIFFERENT, never-listening loopback port → connection refused. + var stub = await StubBackend.StartAsync(ct); + var deadUrl = $"http://127.0.0.1:{FreeTcpPort()}"; // nothing listening here + return await StartInternalAsync(stub, deadUrl, ct); + } + + private static async Task StartInternalAsync(StubBackend stub, string apiBaseUrl, CancellationToken ct) + { + var mcpPort = FreeTcpPort(); + var options = new McpBridgeOptions + { + ApiBaseUrl = apiBaseUrl, + SiteHost = "textstack.app", + Transport = McpTransport.Http, + }; + // BuildHttp wires HttpContextTokenProvider (per-request bearer from the + // Authorization header) — http mode never uses a static token here. + var mcp = McpHosts.BuildHttp(options, args: [$"--urls=http://127.0.0.1:{mcpPort}"]); + await mcp.StartAsync(ct); + + return new McpServerHarness(mcp, stub, $"http://127.0.0.1:{mcpPort}/mcp"); + } + + /// + /// A real over streamable HTTP. + /// (when set) is sent as Authorization: Bearer on every request — the + /// header the bridge's HttpContextTokenProvider reads to authorize the + /// user-scoped tools. CreateAsync runs the initialize handshake. + /// + public async Task ConnectAsync(string? bearer, CancellationToken ct) + { + var transportOptions = new HttpClientTransportOptions + { + Endpoint = new Uri(McpEndpoint), + TransportMode = HttpTransportMode.StreamableHttp, + }; + if (bearer is not null) + transportOptions.AdditionalHeaders = new Dictionary { ["Authorization"] = $"Bearer {bearer}" }; + + var transport = new HttpClientTransport(transportOptions); + return await McpClient.CreateAsync(transport, cancellationToken: ct); + } + + private static int FreeTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public async ValueTask DisposeAsync() + { + await _mcp.StopAsync(); + await _mcp.DisposeAsync(); + await _stub.DisposeAsync(); + } +} diff --git a/tests/TextStack.Ai.Mcp.Tests/McpStdioSmokeTests.cs b/tests/TextStack.Ai.Mcp.Tests/McpStdioSmokeTests.cs new file mode 100644 index 00000000..8b7381be --- /dev/null +++ b/tests/TextStack.Ai.Mcp.Tests/McpStdioSmokeTests.cs @@ -0,0 +1,90 @@ +using ModelContextProtocol.Client; + +namespace TextStack.Ai.Mcp.Tests; + +/// +/// AI-053 stdio smoke — spawns the BUILT MCP server DLL as a subprocess over the +/// REAL (the default transport, byte-identical to +/// the pre-049 server) and drives initialize + tools/list through the wire. This is +/// the one path the in-process HTTP suite can't cover: actual process launch, stdin/ +/// stdout JSON-RPC framing, env-var config. +/// +/// Hermetic: TEXTSTACK_API_URL points at an unreachable loopback port (no network), +/// and tools/list never calls upstream, so no backend is needed. TEXTSTACK_MCP_TOKEN +/// is set so the stdio host takes the static-token branch (no device flow / no token +/// cache file touched). +/// +/// SKIPPABLE: if the server DLL isn't built (e.g. a partial CI checkout), the test +/// skips rather than failing — it must not break CI when the artifact is absent. +/// +public class McpStdioSmokeTests +{ + [Fact] + public async Task Stdio_SubprocessServer_InitializeAndListToolsReturnsSeven() + { + var ct = TestContext.Current.CancellationToken; + + var dll = LocateServerDll(); + if (dll is null) + { + Assert.Skip("TextStack.Ai.Mcp.dll not found next to the test build output — skipping stdio smoke."); + return; + } + + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "textstack-mcp-stdio-smoke", + Command = "dotnet", + Arguments = [dll], + EnvironmentVariables = new Dictionary + { + // Static-token branch → no device flow, no token cache file. + ["TEXTSTACK_MCP_TOKEN"] = "stdio-smoke-token", + // Unreachable upstream — tools/list never calls it, but pin it so a + // stray call can't escape to the real public API. + ["TEXTSTACK_API_URL"] = "http://127.0.0.1:1", + // Force stdio (the default, but explicit for clarity). + ["MCP_TRANSPORT"] = "stdio", + }, + }); + + await using var client = await McpClient.CreateAsync(transport, cancellationToken: ct); + + Assert.Equal("textstack", client.ServerInfo.Name); + + var tools = await client.ListToolsAsync(cancellationToken: ct); + Assert.Equal(7, tools.Count); + } + + // The MCP project is a ProjectReference, so its DLL is built into the test + // output's referenced-assemblies — but we want the server's OWN published-style + // entry DLL (it has runtimeconfig). Resolve it from the server project's bin dir + // matching THIS test's build config, falling back across configs. + private static string? LocateServerDll() + { + // tests/TextStack.Ai.Mcp.Tests/bin//net10.0/ → repo-relative server bin. + var testDir = AppContext.BaseDirectory; + var tfm = new DirectoryInfo(testDir).Name; // net10.0 + var cfg = new DirectoryInfo(testDir).Parent!.Name; // Debug | Release + + // Walk up to the repo root (the dir that contains "backend"). + var dir = new DirectoryInfo(testDir); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "backend"))) + dir = dir.Parent; + if (dir is null) + return null; + + var serverBin = Path.Combine( + dir.FullName, "backend", "src", "Ai", "TextStack.Ai.Mcp", "bin"); + + // Prefer the matching config; otherwise take whichever config is built. + foreach (var candidateCfg in new[] { cfg, "Release", "Debug" }) + { + var path = Path.Combine(serverBin, candidateCfg, tfm, "TextStack.Ai.Mcp.dll"); + if (File.Exists(path)) + return path; + } + + return null; + } +} diff --git a/tests/TextStack.Ai.Mcp.Tests/StubBackend.cs b/tests/TextStack.Ai.Mcp.Tests/StubBackend.cs new file mode 100644 index 00000000..2b40ca43 --- /dev/null +++ b/tests/TextStack.Ai.Mcp.Tests/StubBackend.cs @@ -0,0 +1,309 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace TextStack.Ai.Mcp.Tests; + +/// +/// A hermetic in-process stub of the TextStack public API — the exact routes the +/// MCP bridge's TextStackApiClient calls, returning canned camelCase JSON +/// matching the client's local DTOs. It RECORDS the last request seen per route +/// (method, path+query, Authorization, Host, body) so the over-the-wire tests can +/// assert the bridge issued the right upstream call. +/// +/// Hosted on a loopback port (no Docker, no live API, no external network); the +/// real BuildHttp MCP host is pointed at it via McpBridgeOptions.ApiBaseUrl. +/// +public sealed class StubBackend : IAsyncDisposable +{ + // Two canned editions: the "good" one answers ask_book; the "spoiler" one + // returns Insufficient=true (the spoiler-gate variant). + public const string GoodEdition = "33333333-3333-3333-3333-333333333333"; + public const string SpoilerEdition = "55555555-5555-5555-5555-555555555555"; + public const string ChapterId = "44444444-4444-4444-4444-444444444444"; + public const string NewHighlightId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + + private readonly WebApplication _app; + private readonly Dictionary _records = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + public string BaseUrl { get; } + + /// A snapshot of a single recorded upstream request. + public sealed record RecordedRequest( + string Method, string PathAndQuery, string? Authorization, string? Host, string Body); + + /// Total upstream requests this stub has served (any route). + public int TotalRequests + { + get { lock (_gate) return _hits; } + } + + private int _hits; + + private StubBackend(WebApplication app, string baseUrl) + { + _app = app; + BaseUrl = baseUrl; + } + + /// Last request recorded under , or null. + public RecordedRequest? Last(string key) + { + lock (_gate) + return _records.TryGetValue(key, out var r) ? r : null; + } + + public static async Task StartAsync(CancellationToken ct) + { + var port = FreeTcpPort(); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); // keep test output clean + var app = builder.Build(); + app.Urls.Add($"http://127.0.0.1:{port}"); + + var stub = new StubBackend(app, $"http://127.0.0.1:{port}"); + stub.MapRoutes(); + await app.StartAsync(ct); + return stub; + } + + private void MapRoutes() + { + // GET /search → search page (Dracula hit). + _app.MapGet("/search", async ctx => + { + await RecordAsync("search", ctx); + await WriteJsonAsync(ctx, SearchBody); + }); + + // GET /books/{slug} → BookDetail incl. id (=editionId) + chapters. + _app.MapGet("/books/{slug}", async ctx => + { + await RecordAsync("get_book", ctx); + await WriteJsonAsync(ctx, BookDetailBody); + }); + + // GET /books/{slug}/chapters/{chapterSlug} → ChapterDto (html, prev/next). + _app.MapGet("/books/{slug}/chapters/{chapterSlug}", async ctx => + { + await RecordAsync("get_chapter", ctx); + await WriteJsonAsync(ctx, ChapterBody); + }); + + // GET /me/highlights/{editionId} → 401 if no bearer, else canned list. + _app.MapGet("/me/highlights/{editionId}", async ctx => + { + await RecordAsync("list_my_highlights", ctx); + if (!HasBearer(ctx)) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + await WriteJsonAsync(ctx, HighlightsBody); + }); + + // GET /me/vocabulary/words → 401 if no bearer, else canned page. + _app.MapGet("/me/vocabulary/words", async ctx => + { + await RecordAsync("list_my_vocabulary", ctx); + if (!HasBearer(ctx)) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + await WriteJsonAsync(ctx, VocabularyBody); + }); + + // POST /me/highlights → 401 if no bearer, else 201 HighlightDto. + _app.MapPost("/me/highlights", async ctx => + { + await RecordAsync("save_highlight", ctx); + if (!HasBearer(ctx)) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + await WriteJsonAsync(ctx, CreatedHighlightBody, StatusCodes.Status201Created); + }); + + // POST /books/{editionId}/ask → 401 if no bearer; spoiler edition → Insufficient. + _app.MapPost("/books/{editionId}/ask", async ctx => + { + await RecordAsync("ask_book", ctx); + if (!HasBearer(ctx)) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + var editionId = (string?)ctx.Request.RouteValues["editionId"]; + await WriteJsonAsync(ctx, + string.Equals(editionId, SpoilerEdition, StringComparison.OrdinalIgnoreCase) + ? AskInsufficientBody + : AskBody); + }); + } + + private static bool HasBearer(HttpContext ctx) + { + var header = ctx.Request.Headers.Authorization.ToString(); + return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + && header["Bearer ".Length..].Trim().Length > 0; + } + + private async Task RecordAsync(string key, HttpContext ctx) + { + ctx.Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(ctx.Request.Body, leaveOpen: true)) + body = await reader.ReadToEndAsync(); + ctx.Request.Body.Position = 0; + + var record = new RecordedRequest( + ctx.Request.Method, + ctx.Request.Path + ctx.Request.QueryString, + ctx.Request.Headers.Authorization.Count > 0 ? ctx.Request.Headers.Authorization.ToString() : null, + ctx.Request.Host.HasValue ? ctx.Request.Host.Value : null, + body); + + lock (_gate) + { + _records[key] = record; + _hits++; + } + } + + private static async Task WriteJsonAsync(HttpContext ctx, string json, int status = StatusCodes.Status200OK) + { + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync(json); + } + + private static int FreeTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + // ── canned camelCase bodies (mirror TextStackApiClient's local DTOs) ───────── + + private const string SearchBody = + """ + { + "total": 1, + "items": [ + { + "chapterId": "11111111-1111-1111-1111-111111111111", + "chapterSlug": "ch-1", + "chapterTitle": "I", + "chapterNumber": 1, + "edition": { + "id": "33333333-3333-3333-3333-333333333333", + "slug": "dracula", + "title": "Dracula", + "language": "en", + "authors": "Bram Stoker", + "coverPath": null + }, + "highlights": ["the Count Dracula stirred"] + } + ] + } + """; + + private const string BookDetailBody = + """ + { + "id": "33333333-3333-3333-3333-333333333333", + "slug": "dracula", + "title": "Dracula", + "language": "en", + "description": "A vampire tale.", + "authors": [{ "id": "1", "slug": "bs", "name": "Bram Stoker", "role": "author" }], + "genres": [{ "id": "2", "slug": "horror", "name": "Horror" }], + "chapters": [ + { "id": "44444444-4444-4444-4444-444444444444", "chapterNumber": 1, "slug": "ch-1", "title": "Jonathan Harker's Journal", "wordCount": 4200 }, + { "id": "66666666-6666-6666-6666-666666666666", "chapterNumber": 2, "slug": "ch-2", "title": "Jonathan Harker's Journal Continued", "wordCount": 3900 } + ] + } + """; + + private const string ChapterBody = + """ + { + "id": "44444444-4444-4444-4444-444444444444", + "chapterNumber": 1, + "slug": "ch-1", + "title": "Jonathan Harker's Journal", + "html": "

3 May. Bistritz. — Left Munich at 8:35 P.M.

", + "wordCount": 11, + "edition": { "id": "33333333-3333-3333-3333-333333333333", "slug": "dracula", "title": "Dracula", "language": "en" }, + "prev": null, + "next": { "slug": "ch-2", "title": "Jonathan Harker's Journal Continued" } + } + """; + + private const string HighlightsBody = + """ + [ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "editionId": "33333333-3333-3333-3333-333333333333", + "chapterId": "44444444-4444-4444-4444-444444444444", + "userBookId": null, "userChapterId": null, + "anchorJson": "{}", "color": "yellow", + "selectedText": "the dead travel fast", "noteText": "ominous", + "version": 1, + "createdAt": "2026-01-01T00:00:00+00:00", + "updatedAt": "2026-01-01T00:00:00+00:00" + } + ] + """; + + private const string VocabularyBody = + """ + { + "total": 1, + "items": [ + { + "id": "1", "word": "crepuscular", "language": "en", + "translation": "сутінковий", "definition": "of twilight", + "editionId": null, "chapterId": null, "userBookId": null, + "sentence": null, "bookTitle": "Dracula", "hint": null, + "stage": 2, "intervalDays": 3, "consecutiveCorrect": 1, + "nextReviewAt": "2026-02-01T00:00:00+00:00", "lastReviewedAt": null, + "totalReviews": 4, "correctReviews": 3, + "createdAt": "2026-01-01T00:00:00+00:00", "updatedAt": "2026-01-01T00:00:00+00:00" + } + ] + } + """; + + private const string CreatedHighlightBody = + """ + { + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "editionId": "33333333-3333-3333-3333-333333333333", + "chapterId": "44444444-4444-4444-4444-444444444444", + "userBookId": null, "userChapterId": null, + "anchorJson": "{}", "color": "green", + "selectedText": "Listen to them, the children of the night", + "noteText": "famous line", + "version": 1, + "createdAt": "2026-01-01T00:00:00+00:00", + "updatedAt": "2026-01-01T00:00:00+00:00" + } + """; + + private const string AskBody = + """ + { + "answer": "Jonathan Harker travels to Transylvania to meet Count Dracula. [1]", + "citations": [ + { "marker": 1, "chunkId": "c", "chapterId": "44444444-4444-4444-4444-444444444444", "chapterOrd": 1, "charStart": 0, "charEnd": 9, "preview": "Left Munich at 8:35 P.M." } + ], + "lastReadOrd": 2, + "insufficient": false + } + """; + + private const string AskInsufficientBody = + """ + { "answer": "", "citations": [], "lastReadOrd": 0, "insufficient": true } + """; +} diff --git a/tests/TextStack.Ai.Mcp.Tests/TextStack.Ai.Mcp.Tests.csproj b/tests/TextStack.Ai.Mcp.Tests/TextStack.Ai.Mcp.Tests.csproj new file mode 100644 index 00000000..3e1f71bf --- /dev/null +++ b/tests/TextStack.Ai.Mcp.Tests/TextStack.Ai.Mcp.Tests.csproj @@ -0,0 +1,33 @@ + + + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/textstack.sln b/textstack.sln index f14d1dca..0d17ca21 100644 --- a/textstack.sln +++ b/textstack.sln @@ -131,6 +131,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextStack.Search", "backend EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextStack.Search.Tests", "tests\TextStack.Search.Tests\TextStack.Search.Tests.csproj", "{1E630E63-0D04-451F-BBE9-F21BE653058B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextStack.Ai.Mcp.Tests", "tests\TextStack.Ai.Mcp.Tests\TextStack.Ai.Mcp.Tests.csproj", "{168C9A13-5505-496F-878C-886D5A171490}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Search", "Search", "{6A827D88-0146-7088-8CA6-9A6651443F61}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TextStack.Search.Meilisearch", "backend\src\Search\TextStack.Search.Meilisearch\TextStack.Search.Meilisearch.csproj", "{DED3C195-CA9A-4A69-BFB4-5126D065C847}" @@ -309,6 +311,18 @@ Global {1E630E63-0D04-451F-BBE9-F21BE653058B}.Release|x64.Build.0 = Release|Any CPU {1E630E63-0D04-451F-BBE9-F21BE653058B}.Release|x86.ActiveCfg = Release|Any CPU {1E630E63-0D04-451F-BBE9-F21BE653058B}.Release|x86.Build.0 = Release|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Debug|Any CPU.Build.0 = Debug|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Debug|x64.ActiveCfg = Debug|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Debug|x64.Build.0 = Debug|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Debug|x86.ActiveCfg = Debug|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Debug|x86.Build.0 = Debug|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Release|Any CPU.ActiveCfg = Release|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Release|Any CPU.Build.0 = Release|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Release|x64.ActiveCfg = Release|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Release|x64.Build.0 = Release|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Release|x86.ActiveCfg = Release|Any CPU + {168C9A13-5505-496F-878C-886D5A171490}.Release|x86.Build.0 = Release|Any CPU {DED3C195-CA9A-4A69-BFB4-5126D065C847}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DED3C195-CA9A-4A69-BFB4-5126D065C847}.Debug|Any CPU.Build.0 = Debug|Any CPU {DED3C195-CA9A-4A69-BFB4-5126D065C847}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -490,6 +504,7 @@ Global {7D8B2C66-D0F9-40B0-95B3-EF93F1E90608} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {E10BF2FD-EB2F-45CC-832F-F8BA600C2A8D} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} {1E630E63-0D04-451F-BBE9-F21BE653058B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {168C9A13-5505-496F-878C-886D5A171490} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {6A827D88-0146-7088-8CA6-9A6651443F61} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745} {DED3C195-CA9A-4A69-BFB4-5126D065C847} = {6A827D88-0146-7088-8CA6-9A6651443F61} {8103A826-C930-DA0B-4810-DADE36F3F54E} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}