diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0fdb1d5ee6..342cdfc221 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -129,6 +129,8 @@
+
+
diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Elastic.Documentation.Mcp.Remote.csproj b/src/api/Elastic.Documentation.Mcp.Remote/Elastic.Documentation.Mcp.Remote.csproj
index f5a5b5bd5d..869bddc313 100644
--- a/src/api/Elastic.Documentation.Mcp.Remote/Elastic.Documentation.Mcp.Remote.csproj
+++ b/src/api/Elastic.Documentation.Mcp.Remote/Elastic.Documentation.Mcp.Remote.csproj
@@ -29,6 +29,9 @@
<_Parameter1>Mcp.Remote.IntegrationTests
+
+ <_Parameter1>Mcp.Remote.Tests
+
diff --git a/tests/Elastic.Documentation.Api.Tests/.snapshots/http/page-feedback.verified.responses b/tests/Elastic.Documentation.Api.Tests/.snapshots/http/page-feedback.verified.responses
new file mode 100644
index 0000000000..cca2e1993d
--- /dev/null
+++ b/tests/Elastic.Documentation.Api.Tests/.snapshots/http/page-feedback.verified.responses
@@ -0,0 +1,8 @@
+## valid feedback is stored: PUT /docs/_api/v1/page-feedback/{{$guid}}
+status: 204 No Content
+
+## a thumbs down needs a reason: PUT /docs/_api/v1/page-feedback/{{$guid}}
+status: 400 Bad Request
+
+## feedback is deleted: DELETE /docs/_api/v1/page-feedback/{{$guid}}
+status: 204 No Content
diff --git a/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj b/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj
index ebfcff86ca..5f45dd079c 100644
--- a/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj
+++ b/tests/Elastic.Documentation.Api.Tests/Elastic.Documentation.Api.Tests.csproj
@@ -12,6 +12,8 @@
+
+
diff --git a/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs b/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs
index cea8b059e4..ff127596fc 100644
--- a/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs
+++ b/tests/Elastic.Documentation.Api.Tests/PageFeedbackEndpointTests.cs
@@ -8,6 +8,10 @@
using Elastic.Documentation.Api.PageFeedback;
using Elastic.Documentation.Api.Tests.Fixtures;
using FakeItEasy;
+using Nullean.Snapshots;
+using Nullean.Snapshots.Http;
+using Nullean.Snapshots.Json;
+using Nullean.Snapshots.TUnit;
namespace Elastic.Documentation.Api.Tests;
@@ -30,12 +34,34 @@ public static void DisposeSharedFactory()
SharedFactory.Value.Dispose();
}
+ // The id is replaced by its own text, so a record stored under another id than the request's fails the snapshot.
+ private static SnapshotOptions ScrubFeedbackId(Guid feedbackId) =>
+ new SnapshotOptions().Scrub(text => text.Replace(feedbackId.ToString(), "{feedback-id}", StringComparison.OrdinalIgnoreCase));
+
private static HttpClient CreateClient(IPageFeedbackService feedbackService)
{
FeedbackService.Current = feedbackService;
return SharedFactory.Value.CreateClient();
}
+ // The requests and the responses they must get are in http/page-feedback.http. The service accepts everything, so
+ // what the file shows is the endpoint's own validation and status codes.
+ [Test]
+ [HttpFiles("http")]
+ public async Task Run(SnapshotFile file)
+ {
+ var feedbackService = A.Fake();
+ A.CallTo(() => feedbackService.UpsertFeedbackAsync(A._, A._)).Returns(Task.FromResult(true));
+ A.CallTo(() => feedbackService.DeleteFeedbackAsync(A._, A._)).Returns(Task.FromResult(true));
+ using var client = CreateClient(feedbackService);
+
+ await HttpFileTest.RunAsync(
+ file,
+ new HttpFileSnapshotOptions { Client = client },
+ TestContext.Current!.Execution.CancellationToken
+ );
+ }
+
[Test]
public async Task Put_ValidFeedback_RecordsFeedbackWithEuid()
{
@@ -54,13 +80,23 @@ public async Task Put_ValidFeedback_RecordsFeedbackWithEuid()
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
recorded.Should().NotBeNull();
- recorded.FeedbackId.Should().Be(feedbackId);
- recorded.PageUrl.Should().Be("/docs/test-page");
- recorded.Reaction.Should().Be(PageFeedbackReaction.ThumbsUp);
- recorded.Reasons.Should().ContainSingle(r => r == PageFeedbackReason.Accurate);
- recorded.ReasonSetVersion.Should().Be(2);
- recorded.Comment.Should().Be("Useful page");
- recorded.Euid.Should().Be("test-euid");
+ recorded.Should().MatchInlineJsonSnapshot(
+ """
+ {
+ "FeedbackId": "{feedback-id}",
+ "PageUrl": "/docs/test-page",
+ "PageTitle": "Test page",
+ "Reaction": "thumbsUp",
+ "Reasons": [
+ "accurate"
+ ],
+ "ReasonSetVersion": 2,
+ "Comment": "Useful page",
+ "Euid": "test-euid"
+ }
+ """,
+ options: ScrubFeedbackId(feedbackId)
+ );
}
[Test]
@@ -81,15 +117,28 @@ public async Task Put_ReactionOnly_RecordsFeedbackWithoutDetails()
"reaction": "thumbsDown"
}
""";
- using var request = CreateRequest(Guid.NewGuid(), payload);
+ var feedbackId = Guid.NewGuid();
+ using var request = CreateRequest(feedbackId, payload);
using var response = await client.SendAsync(request, TestContext.Current!.Execution.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
recorded.Should().NotBeNull();
- recorded.Reasons.Should().BeNull();
- recorded.ReasonSetVersion.Should().BeNull();
- recorded.Comment.Should().BeNull();
+ recorded.Should().MatchInlineJsonSnapshot(
+ """
+ {
+ "FeedbackId": "{feedback-id}",
+ "PageUrl": "/docs/test-page",
+ "PageTitle": "Test page",
+ "Reaction": "thumbsDown",
+ "Reasons": null,
+ "ReasonSetVersion": null,
+ "Comment": null,
+ "Euid": null
+ }
+ """,
+ options: ScrubFeedbackId(feedbackId)
+ );
}
[Test]
@@ -112,13 +161,31 @@ public async Task Put_MultipleReasons_RecordsAllReasons()
"reasonSetVersion": 2
}
""";
- using var request = CreateRequest(Guid.NewGuid(), payload);
+ var feedbackId = Guid.NewGuid();
+ using var request = CreateRequest(feedbackId, payload);
using var response = await client.SendAsync(request, TestContext.Current!.Execution.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
recorded.Should().NotBeNull();
- recorded.Reasons.Should().BeEquivalentTo([PageFeedbackReason.Inaccurate, PageFeedbackReason.OutOfDate]);
+ recorded.Should().MatchInlineJsonSnapshot(
+ """
+ {
+ "FeedbackId": "{feedback-id}",
+ "PageUrl": "/docs/test-page",
+ "PageTitle": "Test page",
+ "Reaction": "thumbsDown",
+ "Reasons": [
+ "inaccurate",
+ "outOfDate"
+ ],
+ "ReasonSetVersion": 2,
+ "Comment": null,
+ "Euid": null
+ }
+ """,
+ options: ScrubFeedbackId(feedbackId)
+ );
}
[Test]
diff --git a/tests/Elastic.Documentation.Api.Tests/http/page-feedback.http b/tests/Elastic.Documentation.Api.Tests/http/page-feedback.http
new file mode 100644
index 0000000000..2ccc6e7767
--- /dev/null
+++ b/tests/Elastic.Documentation.Api.Tests/http/page-feedback.http
@@ -0,0 +1,27 @@
+### valid feedback is stored
+PUT /docs/_api/v1/page-feedback/{{$guid}}
+Content-Type: application/json
+Cookie: euid=test-euid
+
+{
+ "pageUrl": "/docs/test-page",
+ "pageTitle": "Test page",
+ "reaction": "thumbsUp",
+ "reasons": ["accurate"],
+ "reasonSetVersion": 2,
+ "comment": " Useful page "
+}
+
+### a thumbs down needs a reason
+PUT /docs/_api/v1/page-feedback/{{$guid}}
+Content-Type: application/json
+
+{
+ "pageUrl": "/docs/test-page",
+ "pageTitle": "Test page",
+ "reaction": "thumbsDown",
+ "comment": "Missing a required reason"
+}
+
+### feedback is deleted
+DELETE /docs/_api/v1/page-feedback/{{$guid}}
diff --git a/tests/Mcp.Remote.Tests/.snapshots/http/mcp-handshake.verified.responses b/tests/Mcp.Remote.Tests/.snapshots/http/mcp-handshake.verified.responses
new file mode 100644
index 0000000000..db73575236
--- /dev/null
+++ b/tests/Mcp.Remote.Tests/.snapshots/http/mcp-handshake.verified.responses
@@ -0,0 +1,246 @@
+## initialize: POST /docs/_mcp
+status: 200 OK
+content-type: text/event-stream
+
+> event: message
+> data: {
+> "result": {
+> "protocolVersion": "2025-11-25",
+> "capabilities": {
+> "logging": {},
+> "tools": {}
+> },
+> "serverInfo": {
+> "name": "docs-mcp",
+> "version": "{server-version}"
+> },
+> "instructions": "Use this server to search, retrieve, and analyze Elastic product documentation published at elastic.co/docs. All content covers version 9 and later.\n\n\nUse the server when the user:\n- Wants to find, read, or verify Elastic documentation pages.\n- Needs to check whether a topic is already covered in Elastic documentation.\n- Asks about Elastic documentation structure, coherence, or inconsistencies across pages.\n- References Elastic product names such as Elasticsearch, Kibana, Fleet, APM, Logstash, Beats, Elastic Security, Elastic Observability, or Elastic Cloud.\n\n\n\n- Prefer search_docs over a general web search when looking up Elastic documentation content.\n- Use find_related_docs when exploring what documentation exists around a topic.\n- Use get_document_by_url to retrieve a specific page when the user provides or you already know the URL.\n- Use check_docs_coherence or find_docs_inconsistencies when reviewing or auditing documentation quality.\n"
+> },
+> "id": 1,
+> "jsonrpc": "2.0"
+> }
+>
+>
+
+## tools/list: POST /docs/_mcp
+status: 200 OK
+content-type: text/event-stream
+
+> event: message
+> data: {
+> "result": {
+> "tools": [
+> {
+> "name": "find_docs_inconsistencies",
+> "description": "Finds potential inconsistencies across Elastic documentation pages covering the same topic. Use when auditing docs quality, verifying that instructions don't contradict each other, or checking for overlapping content within a product area.",
+> "inputSchema": {
+> "type": "object",
+> "properties": {
+> "topic": {
+> "description": "Topic or concept to check for inconsistencies",
+> "type": "string"
+> },
+> "focusArea": {
+> "description": "Specific area to focus on (e.g., 'installation', 'configuration')",
+> "type": [
+> "string",
+> "null"
+> ],
+> "default": null
+> }
+> },
+> "required": [
+> "topic"
+> ]
+> },
+> "annotations": {
+> "destructiveHint": false,
+> "idempotentHint": true,
+> "openWorldHint": false,
+> "readOnlyHint": true
+> },
+> "execution": {
+> "taskSupport": "optional"
+> }
+> },
+> {
+> "name": "check_docs_coherence",
+> "description": "Checks how coherently a topic is covered across all Elastic documentation. Use when reviewing documentation quality, auditing coverage of a feature or concept, or checking whether a topic is documented consistently across products and sections.",
+> "inputSchema": {
+> "type": "object",
+> "properties": {
+> "topic": {
+> "description": "Topic or concept to check coherence for",
+> "type": "string"
+> },
+> "limit": {
+> "description": "Maximum number of documents to analyze (default: 20)",
+> "type": "integer",
+> "default": 20
+> }
+> },
+> "required": [
+> "topic"
+> ]
+> },
+> "annotations": {
+> "destructiveHint": false,
+> "idempotentHint": true,
+> "openWorldHint": false,
+> "readOnlyHint": true
+> },
+> "execution": {
+> "taskSupport": "optional"
+> }
+> },
+> {
+> "name": "find_related_docs",
+> "description": "Finds Elastic documentation pages related to a given topic. Use when exploring what documentation exists around a subject, building context for writing, or discovering related content the user should be aware of.",
+> "inputSchema": {
+> "type": "object",
+> "properties": {
+> "topic": {
+> "description": "Topic or search terms to find related documents for",
+> "type": "string"
+> },
+> "limit": {
+> "description": "Maximum number of related documents to return (default: 10)",
+> "type": "integer",
+> "default": 10
+> },
+> "productFilter": {
+> "description": "Filter by product ID (e.g., 'elasticsearch', 'kibana')",
+> "type": [
+> "string",
+> "null"
+> ],
+> "default": null
+> }
+> },
+> "required": [
+> "topic"
+> ]
+> },
+> "annotations": {
+> "destructiveHint": false,
+> "idempotentHint": true,
+> "openWorldHint": false,
+> "readOnlyHint": true
+> },
+> "execution": {
+> "taskSupport": "optional"
+> }
+> },
+> {
+> "name": "get_document_by_url",
+> "description": "Retrieves a specific Elastic documentation page by its URL. Use when the user provides a documentation URL, references a known page, or you need the full content and metadata of a specific doc. Returns title, AI summaries, headings, navigation context, and the full body only when includeBody is true. Pass includeBody=true to read the page content; otherwise body is null.",
+> "inputSchema": {
+> "type": "object",
+> "properties": {
+> "url": {
+> "description": "The URL of the document. Accepts a full URL (e.g. 'https://www.elastic.co/docs/deploy-manage/api-keys') or a path (e.g. '/docs/deploy-manage/api-keys'). Query strings, fragments, and trailing slashes are ignored.",
+> "type": "string"
+> },
+> "includeBody": {
+> "description": "Include the full page body (default: false, so body is null). Set true whenever you need to read the page content.",
+> "type": "boolean",
+> "default": false
+> }
+> },
+> "required": [
+> "url"
+> ]
+> },
+> "annotations": {
+> "destructiveHint": false,
+> "idempotentHint": true,
+> "openWorldHint": false,
+> "readOnlyHint": true
+> },
+> "execution": {
+> "taskSupport": "optional"
+> }
+> },
+> {
+> "name": "search_docs",
+> "description": "Searches all published Elastic documentation by meaning. Use when the user asks about Elastic product features, needs to find existing docs pages, verify published content, or research what documentation exists on a topic. Returns relevant documents with AI summaries, relevance scores, and navigation context.",
+> "inputSchema": {
+> "type": "object",
+> "properties": {
+> "query": {
+> "description": "The search query - can be a question or keywords",
+> "type": "string"
+> },
+> "pageNumber": {
+> "description": "Page number (1-based, default: 1)",
+> "type": "integer",
+> "default": 1
+> },
+> "pageSize": {
+> "description": "Number of results per page (default: 10, max: 50)",
+> "type": "integer",
+> "default": 10
+> },
+> "productFilter": {
+> "description": "Filter by product ID (e.g., 'elasticsearch', 'kibana')",
+> "type": [
+> "string",
+> "null"
+> ],
+> "default": null
+> },
+> "sectionFilter": {
+> "description": "Filter by navigation section (e.g., 'reference', 'getting-started')",
+> "type": [
+> "string",
+> "null"
+> ],
+> "default": null
+> }
+> },
+> "required": [
+> "query"
+> ]
+> },
+> "annotations": {
+> "destructiveHint": false,
+> "idempotentHint": true,
+> "openWorldHint": false,
+> "readOnlyHint": true
+> },
+> "execution": {
+> "taskSupport": "optional"
+> }
+> },
+> {
+> "name": "analyze_document_structure",
+> "description": "Analyzes the structure of a Elastic documentation page. Use when evaluating page quality, checking heading hierarchy, or assessing AI enrichment status. Returns heading count, link count, parent pages, and whether AI summaries are present.",
+> "inputSchema": {
+> "type": "object",
+> "properties": {
+> "url": {
+> "description": "The URL of the document to analyze. Accepts a full URL (e.g. 'https://www.elastic.co/docs/deploy-manage/api-keys') or a path (e.g. '/docs/deploy-manage/api-keys'). Query strings, fragments, and trailing slashes are ignored.",
+> "type": "string"
+> }
+> },
+> "required": [
+> "url"
+> ]
+> },
+> "annotations": {
+> "destructiveHint": false,
+> "idempotentHint": true,
+> "openWorldHint": false,
+> "readOnlyHint": true
+> },
+> "execution": {
+> "taskSupport": "optional"
+> }
+> }
+> ]
+> },
+> "id": 2,
+> "jsonrpc": "2.0"
+> }
+>
+>
diff --git a/tests-integration/Mcp.Remote.IntegrationTests/DocumentUrlNormalizerTests.cs b/tests/Mcp.Remote.Tests/DocumentUrlNormalizerTests.cs
similarity index 97%
rename from tests-integration/Mcp.Remote.IntegrationTests/DocumentUrlNormalizerTests.cs
rename to tests/Mcp.Remote.Tests/DocumentUrlNormalizerTests.cs
index 5d303f0348..87d9854843 100644
--- a/tests-integration/Mcp.Remote.IntegrationTests/DocumentUrlNormalizerTests.cs
+++ b/tests/Mcp.Remote.Tests/DocumentUrlNormalizerTests.cs
@@ -5,7 +5,7 @@
using AwesomeAssertions;
using Elastic.Documentation.Mcp.Remote.Gateways;
-namespace Mcp.Remote.IntegrationTests;
+namespace Mcp.Remote.Tests;
///
/// Unit tests for .
diff --git a/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj b/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj
index 63312acbe6..0f22d678cf 100644
--- a/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj
+++ b/tests/Mcp.Remote.Tests/Mcp.Remote.Tests.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/tests/Mcp.Remote.Tests/McpHandshakeTests.cs b/tests/Mcp.Remote.Tests/McpHandshakeTests.cs
index a11520dd74..01623ce387 100644
--- a/tests/Mcp.Remote.Tests/McpHandshakeTests.cs
+++ b/tests/Mcp.Remote.Tests/McpHandshakeTests.cs
@@ -2,13 +2,13 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information
-using System.Net;
using System.Reflection;
-using System.Text;
-using System.Text.Json;
using AwesomeAssertions;
using Elastic.Documentation.Mcp.Remote;
using Microsoft.AspNetCore.Mvc.Testing;
+using Nullean.Snapshots;
+using Nullean.Snapshots.Http;
+using Nullean.Snapshots.TUnit;
namespace Mcp.Remote.Tests;
@@ -16,12 +16,14 @@ namespace Mcp.Remote.Tests;
/// End-to-end tests for the MCP initialize handshake. Verifies that the serverInfo.name,
/// serverInfo.version, and protocolVersion fields set in Program.cs are actually returned
/// by the server's initialize response — a regression in the wiring would produce wrong
-/// metadata while all unit tests still pass.
+/// metadata while all unit tests still pass. Each http/*.http file is one test; its responses
+/// are in .snapshots/http/*.verified.responses.
///
public class McpHandshakeTests
{
[Test]
- public async Task Initialize_PublicProfile_ReturnsCorrectServerInfoAndProtocolVersion()
+ [HttpFiles("http")]
+ public async Task Run(SnapshotFile file)
{
// Program.cs reads MCP_SERVER_PROFILE via Environment.GetEnvironmentVariable before
// any DI override can run, so we set and restore the actual process variable to keep
@@ -33,56 +35,25 @@ public async Task Initialize_PublicProfile_ReturnsCorrectServerInfoAndProtocolVe
using var factory = new WebApplicationFactory();
using var client = factory.CreateClient();
- // The client deliberately sends an older protocol version ("2024-11-05") rather than
- // the one the server pins to ("2025-11-25"). If options.ProtocolVersion regresses to
- // null the SDK echoes the client value, so this assertion would fail — proving the
- // pinning is actually enforced.
- const string initRequest = /*lang=json,strict*/
- """
- {
- "jsonrpc": "2.0",
- "id": 1,
- "method": "initialize",
- "params": {
- "protocolVersion": "2024-11-05",
- "capabilities": {},
- "clientInfo": { "name": "test-client", "version": "1.0.0" }
- }
- }
- """;
-
- using var content = new StringContent(initRequest, Encoding.UTF8, "application/json");
- // MCP HTTP transport requires both JSON and SSE in the Accept header; the server
- // responds with SSE framing (text/event-stream). Extract the JSON from the data: line.
- using var request = new HttpRequestMessage(HttpMethod.Post, "/docs/_mcp") { Content = content };
- request.Headers.TryAddWithoutValidation("Accept", "application/json, text/event-stream");
- using var response = await client.SendAsync(request, TestContext.Current!.Execution.CancellationToken);
-
- response.StatusCode.Should().Be(HttpStatusCode.OK);
-
- var rawBody = await response.Content.ReadAsStringAsync(TestContext.Current!.Execution.CancellationToken);
- // SSE format: lines starting with "data: " carry the JSON payload.
- var jsonLine = rawBody.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(
- l => l.StartsWith("data:", StringComparison.Ordinal)
+ var transcript = await HttpFileSnapshot.RunAsync(
+ file.Path,
+ new HttpFileSnapshotOptions { Client = client },
+ TestContext.Current!.Execution.CancellationToken
);
- jsonLine.Should().NotBeNull("response body should contain an SSE data line");
- var json = (jsonLine ?? "data:")["data:".Length..].Trim();
- using var doc = JsonDocument.Parse(json);
- var result = doc.RootElement.GetProperty("result");
- result.GetProperty("protocolVersion").GetString().Should().Be("2025-11-25");
-
- // Compute the expected version the same way Program.cs does so a regression to the
- // default assembly version (1.0.0.0) or the fallback ("0.0.0") fails this assertion.
+ // Computed the same way Program.cs does, so a regression to the default assembly version (1.0.0.0)
+ // or the fallback ("0.0.0") leaves a different value in the snapshot instead of the placeholder.
var expectedVersion = typeof(Program)
.Assembly
.GetCustomAttributes()
.FirstOrDefault()?.InformationalVersion
?? "0.0.0";
- var serverInfo = result.GetProperty("serverInfo");
- serverInfo.GetProperty("name").GetString().Should().Be(McpServerProfile.Public.ServiceName);
- serverInfo.GetProperty("version").GetString().Should().Be(expectedVersion);
+ transcript.Should().MatchHttpFileSnapshot(
+ new SnapshotOptions().Scrub(SseScrubbers.PrettyPrintDataLines).Scrub(
+ text => text.Replace(expectedVersion, "{server-version}", StringComparison.Ordinal)
+ )
+ );
}
finally
{
diff --git a/tests/Mcp.Remote.Tests/SseScrubbers.cs b/tests/Mcp.Remote.Tests/SseScrubbers.cs
new file mode 100644
index 0000000000..09d1abc7a0
--- /dev/null
+++ b/tests/Mcp.Remote.Tests/SseScrubbers.cs
@@ -0,0 +1,52 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace Mcp.Remote.Tests;
+
+///
+/// The HTTP snapshot format writes a text/event-stream body as quoted text, so the JSON-RPC message of a
+/// data: line would be one unreadable line. This scrubber pretty-prints it, which keeps a diff readable.
+/// It runs on the rendered responses text, where every body line starts with > .
+///
+internal static partial class SseScrubbers
+{
+ private static readonly JsonSerializerOptions Pretty = new()
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ [GeneratedRegex(@"^> data: (?.*)$", RegexOptions.Multiline)]
+ private static partial Regex DataLine();
+
+ public static string PrettyPrintDataLines(string text) =>
+ DataLine().Replace(text, match =>
+ {
+ JsonNode? node;
+ try
+ {
+ node = JsonNode.Parse(match.Groups["json"].Value);
+ }
+ catch (JsonException)
+ {
+ return match.Value;
+ }
+
+ var lines = (node?.ToJsonString(Pretty) ?? "null").Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
+ var result = new StringBuilder();
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (i > 0)
+ _ = result.Append('\n');
+ _ = result.Append(i == 0 ? "> data: " : "> ").Append(lines[i]);
+ }
+ return result.ToString();
+ });
+}
diff --git a/tests/Mcp.Remote.Tests/http/mcp-handshake.http b/tests/Mcp.Remote.Tests/http/mcp-handshake.http
new file mode 100644
index 0000000000..c1b7ba131c
--- /dev/null
+++ b/tests/Mcp.Remote.Tests/http/mcp-handshake.http
@@ -0,0 +1,24 @@
+### initialize
+# The client deliberately asks for an older protocol version than the one the server pins to. If the pinning
+# regresses, the SDK echoes the client's version and the snapshot shows it.
+POST /docs/_mcp
+Content-Type: application/json
+Accept: application/json, text/event-stream
+
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "clientInfo": { "name": "test-client", "version": "1.0.0" }
+ }
+}
+
+### tools/list
+POST /docs/_mcp
+Content-Type: application/json
+Accept: application/json, text/event-stream
+
+{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }