Skip to content

Commit 8adee2d

Browse files
Copilotstephentoub
andcommitted
Fix server conformance tests for v0.1.11
- Update test_elicitation_sep1330_enums tool to test all 5 enum variants - Add DNS rebinding protection middleware to ConformanceServer Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 1847b83 commit 8adee2d

File tree

2 files changed

+129
-9
lines changed

2 files changed

+129
-9
lines changed

tests/ModelContextProtocol.ConformanceServer/Program.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@
33
using ConformanceServer.Tools;
44
using ModelContextProtocol.Protocol;
55
using System.Collections.Concurrent;
6+
using System.Net;
67
using System.Text.Json;
78

89
namespace ModelContextProtocol.ConformanceServer;
910

1011
public class Program
1112
{
13+
// Valid localhost values for DNS rebinding protection
14+
private static readonly HashSet<string> ValidLocalhostHosts = new(StringComparer.OrdinalIgnoreCase)
15+
{
16+
"localhost",
17+
"127.0.0.1",
18+
"[::1]",
19+
"::1"
20+
};
21+
1222
public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default)
1323
{
1424
var builder = WebApplication.CreateBuilder(args);
@@ -92,13 +102,88 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
92102

93103
var app = builder.Build();
94104

105+
// DNS rebinding protection middleware
106+
// Rejects requests with non-localhost Host/Origin headers for localhost servers
107+
// See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w
108+
app.Use(async (context, next) =>
109+
{
110+
// Check if this is a localhost server
111+
var localEndpoint = context.Connection.LocalIpAddress;
112+
bool isLocalhostServer = localEndpoint == null ||
113+
IPAddress.IsLoopback(localEndpoint) ||
114+
localEndpoint.Equals(IPAddress.IPv6Loopback);
115+
116+
if (isLocalhostServer)
117+
{
118+
// Validate Host header
119+
var host = context.Request.Host.Host;
120+
if (!IsValidLocalhostHost(host))
121+
{
122+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
123+
await context.Response.WriteAsync("Forbidden: Invalid Host header for localhost server");
124+
return;
125+
}
126+
127+
// Validate Origin header if present
128+
if (context.Request.Headers.TryGetValue("Origin", out var originValues))
129+
{
130+
var origin = originValues.FirstOrDefault();
131+
if (!string.IsNullOrEmpty(origin) && Uri.TryCreate(origin, UriKind.Absolute, out var originUri))
132+
{
133+
if (!IsValidLocalhostHost(originUri.Host))
134+
{
135+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
136+
await context.Response.WriteAsync("Forbidden: Invalid Origin header for localhost server");
137+
return;
138+
}
139+
}
140+
}
141+
}
142+
143+
await next();
144+
});
145+
95146
app.MapMcp();
96147

97148
app.MapGet("/health", () => TypedResults.Ok("Healthy"));
98149

99150
await app.RunAsync(cancellationToken);
100151
}
101152

153+
/// <summary>
154+
/// Checks if the host is a valid localhost value.
155+
/// Valid values: localhost, 127.0.0.1, [::1], ::1 (with optional port)
156+
/// </summary>
157+
private static bool IsValidLocalhostHost(string host)
158+
{
159+
if (string.IsNullOrEmpty(host))
160+
{
161+
return false;
162+
}
163+
164+
// Remove port if present (e.g., "localhost:3001" -> "localhost")
165+
var hostWithoutPort = host;
166+
if (host.StartsWith('['))
167+
{
168+
// IPv6 address with brackets, e.g., "[::1]:3001"
169+
var bracketEnd = host.IndexOf(']');
170+
if (bracketEnd > 0)
171+
{
172+
hostWithoutPort = host[..(bracketEnd + 1)];
173+
}
174+
}
175+
else
176+
{
177+
var colonIndex = host.LastIndexOf(':');
178+
if (colonIndex > 0)
179+
{
180+
hostWithoutPort = host[..colonIndex];
181+
}
182+
}
183+
184+
return ValidLocalhostHosts.Contains(hostWithoutPort);
185+
}
186+
102187
public static async Task Main(string[] args)
103188
{
104189
await MainAsync(args);

tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -328,23 +328,59 @@ public static async Task<string> ElicitationSep1330Enums(
328328
{
329329
try
330330
{
331+
#pragma warning disable MCP9001 // LegacyTitledEnumSchema is deprecated but required for conformance testing
331332
var schema = new ElicitRequestParams.RequestSchema
332333
{
333334
Properties =
334335
{
335-
["color"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema()
336+
// 1. Untitled single-select: { type: "string", enum: ["option1", "option2", "option3"] }
337+
["untitledSingle"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema()
336338
{
337-
Description = "Choose a color",
338-
Enum = ["red", "green", "blue"]
339+
Description = "Untitled single-select enum",
340+
Enum = ["option1", "option2", "option3"]
339341
},
340-
["size"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema()
342+
// 2. Titled single-select: { type: "string", oneOf: [{ const: "value1", title: "First Option" }, ...] }
343+
["titledSingle"] = new ElicitRequestParams.TitledSingleSelectEnumSchema()
341344
{
342-
Description = "Choose a size",
343-
Enum = ["small", "medium", "large"],
344-
Default = "medium"
345+
Description = "Titled single-select enum",
346+
OneOf = [
347+
new ElicitRequestParams.EnumSchemaOption { Const = "value1", Title = "First Option" },
348+
new ElicitRequestParams.EnumSchemaOption { Const = "value2", Title = "Second Option" },
349+
new ElicitRequestParams.EnumSchemaOption { Const = "value3", Title = "Third Option" }
350+
]
351+
},
352+
// 3. Legacy titled (deprecated): { type: "string", enum: ["opt1", "opt2", "opt3"], enumNames: ["Option One", "Option Two", "Option Three"] }
353+
["legacyEnum"] = new ElicitRequestParams.LegacyTitledEnumSchema()
354+
{
355+
Description = "Legacy titled enum (deprecated)",
356+
Enum = ["opt1", "opt2", "opt3"],
357+
EnumNames = ["Option One", "Option Two", "Option Three"]
358+
},
359+
// 4. Untitled multi-select: { type: "array", items: { type: "string", enum: ["option1", "option2", "option3"] } }
360+
["untitledMulti"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema()
361+
{
362+
Description = "Untitled multi-select enum",
363+
Items = new ElicitRequestParams.UntitledEnumItemsSchema
364+
{
365+
Enum = ["option1", "option2", "option3"]
366+
}
367+
},
368+
// 5. Titled multi-select: { type: "array", items: { anyOf: [{ const: "value1", title: "First Choice" }, ...] } }
369+
["titledMulti"] = new ElicitRequestParams.TitledMultiSelectEnumSchema()
370+
{
371+
Description = "Titled multi-select enum",
372+
Items = new ElicitRequestParams.TitledEnumItemsSchema
373+
{
374+
AnyOf = [
375+
new ElicitRequestParams.EnumSchemaOption { Const = "value1", Title = "First Choice" },
376+
new ElicitRequestParams.EnumSchemaOption { Const = "value2", Title = "Second Choice" },
377+
new ElicitRequestParams.EnumSchemaOption { Const = "value3", Title = "Third Choice" }
378+
]
379+
}
345380
}
346381
}
347382
};
383+
#pragma warning restore MCP9001
348384

349385
var result = await server.ElicitAsync(new ElicitRequestParams
350386
{
@@ -354,8 +390,7 @@ public static async Task<string> ElicitationSep1330Enums(
354390

355391
if (result.Action == "accept" && result.Content != null)
356392
{
357-
return $"Accepted with values: color={result.Content["color"].GetString()}, " +
358-
$"size={result.Content["size"].GetString()}";
393+
return $"Elicitation completed: action={result.Action}, content={result.Content}";
359394
}
360395
else
361396
{

0 commit comments

Comments
 (0)