Skip to content

Commit b2a4012

Browse files
tarekghTarek Mahmoud Sayed
andauthored
Re-enable http-custom-headers conformance scenario (modelcontextprotocol#1655) (modelcontextprotocol#1691)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent 3e7ca32 commit b2a4012

4 files changed

Lines changed: 168 additions & 10 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"private": true,
33
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
44
"dependencies": {
5-
"@modelcontextprotocol/conformance": "0.2.0-alpha.5",
5+
"@modelcontextprotocol/conformance": "0.2.0-alpha.8",
66
"@modelcontextprotocol/server-everything": "2026.1.26",
77
"@modelcontextprotocol/server-memory": "2026.1.26"
88
}

tests/Common/Utils/NodeHelpers.cs

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,19 @@ public static bool IsNodeInstalled()
191191
public static bool HasSep2243Scenarios()
192192
=> HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0));
193193

194+
/// <summary>
195+
/// Checks whether the installed conformance package contains a spec-conformant
196+
/// <c>http-custom-headers</c> scenario. Prereleases 0.2.0-alpha.5 through 0.2.0-alpha.7
197+
/// annotated a <c>number</c>-typed parameter with <c>x-mcp-header</c>, which SEP-2243
198+
/// forbids; a conformant client excludes that tool, so every positive check in the
199+
/// scenario fails. Conformance PR #371 fixed the scenario and shipped it in 0.2.0-alpha.8,
200+
/// so this gate requires at least that version. Unlike <see cref="HasSep2243Scenarios"/>,
201+
/// this comparison honors the semver prerelease so older 0.2.0 prereleases are skipped
202+
/// rather than failing spuriously.
203+
/// </summary>
204+
public static bool HasConformantCustomHeadersScenario()
205+
=> IsInstalledConformanceVersionAtLeast("0.2.0-alpha.8");
206+
194207
/// <summary>
195208
/// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance
196209
/// PR #275) is available, by reading the <em>installed</em> conformance package version
@@ -256,7 +269,138 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion
256269
}
257270

258271
/// <summary>
259-
/// Runs the conformance runner ("conformance &lt;arguments&gt;") in server mode and returns
272+
/// Returns <see langword="true"/> when the conformance package installed in node_modules
273+
/// has a semver precedence greater than or equal to <paramref name="minimumVersion"/>,
274+
/// honoring the prerelease component (e.g. "0.2.0-alpha.8"). Returns <see langword="false"/>
275+
/// when no version can be determined.
276+
/// </summary>
277+
private static bool IsInstalledConformanceVersionAtLeast(string minimumVersion)
278+
{
279+
var installed = GetInstalledConformanceVersionString();
280+
return installed is not null && CompareSemVer(installed, minimumVersion) >= 0;
281+
}
282+
283+
/// <summary>
284+
/// Reads the raw version string of the conformance package installed in node_modules,
285+
/// preserving any prerelease/build suffix. Returns <see langword="null"/> if it cannot be
286+
/// determined.
287+
/// </summary>
288+
private static string? GetInstalledConformanceVersionString()
289+
{
290+
try
291+
{
292+
var repoRoot = FindRepoRoot();
293+
var packageJsonPath = Path.Combine(
294+
repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json");
295+
296+
if (!File.Exists(packageJsonPath))
297+
{
298+
return null;
299+
}
300+
301+
using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath));
302+
if (json.RootElement.TryGetProperty("version", out var versionElement))
303+
{
304+
return versionElement.GetString();
305+
}
306+
307+
return null;
308+
}
309+
catch
310+
{
311+
return null;
312+
}
313+
}
314+
315+
/// <summary>
316+
/// Compares two semantic version strings by precedence, honoring the prerelease component
317+
/// per the SemVer 2.0.0 rules used here (numeric identifiers compare numerically, a version
318+
/// with a prerelease has lower precedence than the same version without one, and a shorter
319+
/// set of prerelease identifiers has lower precedence when all preceding ones are equal).
320+
/// Build metadata (after '+') is ignored. Returns a negative value when <paramref name="a"/>
321+
/// precedes <paramref name="b"/>, zero when equal, and a positive value otherwise.
322+
/// </summary>
323+
private static int CompareSemVer(string a, string b)
324+
{
325+
var (coreA, preA) = SplitSemVer(a);
326+
var (coreB, preB) = SplitSemVer(b);
327+
328+
var coreCompare = coreA.CompareTo(coreB);
329+
if (coreCompare != 0)
330+
{
331+
return coreCompare;
332+
}
333+
334+
// A version without a prerelease outranks one with a prerelease.
335+
if (preA.Length == 0 && preB.Length == 0)
336+
{
337+
return 0;
338+
}
339+
if (preA.Length == 0)
340+
{
341+
return 1;
342+
}
343+
if (preB.Length == 0)
344+
{
345+
return -1;
346+
}
347+
348+
var count = Math.Min(preA.Length, preB.Length);
349+
for (var i = 0; i < count; i++)
350+
{
351+
var idA = preA[i];
352+
var idB = preB[i];
353+
var numA = int.TryParse(idA, out var na);
354+
var numB = int.TryParse(idB, out var nb);
355+
356+
int cmp;
357+
if (numA && numB)
358+
{
359+
cmp = na.CompareTo(nb);
360+
}
361+
else if (numA)
362+
{
363+
// Numeric identifiers always have lower precedence than alphanumeric ones.
364+
cmp = -1;
365+
}
366+
else if (numB)
367+
{
368+
cmp = 1;
369+
}
370+
else
371+
{
372+
cmp = string.CompareOrdinal(idA, idB);
373+
}
374+
375+
if (cmp != 0)
376+
{
377+
return cmp;
378+
}
379+
}
380+
381+
return preA.Length.CompareTo(preB.Length);
382+
}
383+
384+
/// <summary>
385+
/// Splits a semver string into its numeric core (major.minor.patch) and its prerelease
386+
/// identifiers, ignoring any build metadata after '+'. Missing core components default to 0.
387+
/// </summary>
388+
private static (Version Core, string[] Prerelease) SplitSemVer(string version)
389+
{
390+
var withoutBuild = version.Split(new[] { '+' }, 2)[0];
391+
var parts = withoutBuild.Split(new[] { '-' }, 2);
392+
var prerelease = parts.Length > 1 && parts[1].Length > 0
393+
? parts[1].Split('.')
394+
: Array.Empty<string>();
395+
396+
var coreParts = parts[0].Split('.');
397+
int Part(int index) => index < coreParts.Length && int.TryParse(coreParts[index], out var v) ? v : 0;
398+
var core = new Version(Part(0), Part(1), Part(2));
399+
400+
return (core, prerelease);
401+
}
402+
403+
260404
/// whether it succeeded along with the captured stdout/stderr. Centralizes the process
261405
/// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback)
262406
/// shared by the server-side conformance tests.

tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class ClientConformanceTests
1717
// Public static property required for SkipUnless attribute
1818
public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled();
1919
public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios();
20+
public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario();
2021

2122
public ClientConformanceTests(ITestOutputHelper output)
2223
{
@@ -66,10 +67,6 @@ public async Task RunConformanceTest(string scenario)
6667
[Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))]
6768
[InlineData("http-standard-headers")]
6869
[InlineData("http-invalid-tool-headers")]
69-
// Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header,
70-
// which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers,
71-
// failing every positive check. Re-enable once a conformant conformance package ships (#1655).
72-
// [InlineData("http-custom-headers")]
7370
public async Task RunConformanceTest_Sep2243(string scenario)
7471
{
7572
// Run the conformance test suite
@@ -80,6 +77,23 @@ public async Task RunConformanceTest_Sep2243(string scenario)
8077
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
8178
}
8279

80+
// The http-custom-headers scenario needs a tighter gate than the other SEP-2243 scenarios:
81+
// conformance 0.2.0-alpha.5 through 0.2.0-alpha.7 shipped it with an x-mcp-header on a
82+
// number-typed parameter (forbidden by SEP-2243), which a conformant client excludes,
83+
// failing every positive check. It was fixed upstream in 0.2.0-alpha.8 (conformance #371),
84+
// so require at least that version to avoid spurious failures on older 0.2.0 prereleases.
85+
[Theory(Skip = "Conformant http-custom-headers scenario not available (requires conformance package >= 0.2.0-alpha.8).", SkipUnless = nameof(HasConformantCustomHeadersScenario))]
86+
[InlineData("http-custom-headers")]
87+
public async Task RunConformanceTest_Sep2243_CustomHeaders(string scenario)
88+
{
89+
// Run the conformance test suite
90+
var result = await RunClientConformanceScenario(scenario);
91+
92+
// Report the results
93+
Assert.True(result.Success,
94+
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
95+
}
96+
8397
private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario)
8498
{
8599
// Construct an absolute path to the conformance client executable

0 commit comments

Comments
 (0)