Skip to content

Commit afd68e9

Browse files
krubenokCopilot
andcommitted
Harden MCP Apps elicitation metadata
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 989f36e commit afd68e9

3 files changed

Lines changed: 108 additions & 19 deletions

File tree

src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.Validation.cs

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -380,32 +380,78 @@ private static bool IsValidEmail(string value)
380380

381381
private static bool IsValidDateTime(string value)
382382
{
383-
if (value.Length < 20 || value[10] is not ('T' or 't'))
383+
if (value.Length < 20 ||
384+
value[4] != '-' ||
385+
value[7] != '-' ||
386+
value[10] is not ('T' or 't') ||
387+
value[13] != ':' ||
388+
value[16] != ':')
384389
{
385390
return false;
386391
}
387392

388-
bool hasUtcDesignator = value[value.Length - 1] is 'Z' or 'z';
389-
bool hasOffset =
390-
value.Length >= 25 &&
391-
value[value.Length - 6] is '+' or '-' &&
392-
value[value.Length - 3] == ':';
393-
if (!hasUtcDesignator && !hasOffset)
393+
for (int i = 0; i < 19; i++)
394394
{
395-
return false;
395+
if (i is not (4 or 7 or 10 or 13 or 16) && !IsAsciiDigit(value[i]))
396+
{
397+
return false;
398+
}
396399
}
397400

398-
string normalized = value
399-
.Replace('t', 'T')
400-
.Replace('z', 'Z');
401-
return DateTimeOffset.TryParseExact(
402-
normalized,
403-
"yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK",
401+
string dateAndTime = value.Substring(0, 10) + 'T' + value.Substring(11, 8);
402+
if (!DateTime.TryParseExact(
403+
dateAndTime,
404+
"yyyy-MM-dd'T'HH:mm:ss",
404405
CultureInfo.InvariantCulture,
405406
DateTimeStyles.None,
406-
out _);
407+
out _))
408+
{
409+
return false;
410+
}
411+
412+
int timezoneIndex = 19;
413+
if (value[timezoneIndex] == '.')
414+
{
415+
timezoneIndex++;
416+
int fractionStart = timezoneIndex;
417+
while (timezoneIndex < value.Length && IsAsciiDigit(value[timezoneIndex]))
418+
{
419+
timezoneIndex++;
420+
}
421+
422+
if (timezoneIndex == fractionStart)
423+
{
424+
return false;
425+
}
426+
}
427+
428+
if (timezoneIndex == value.Length - 1 && value[timezoneIndex] is 'Z' or 'z')
429+
{
430+
return true;
431+
}
432+
433+
if (value.Length - timezoneIndex != 6 ||
434+
value[timezoneIndex] is not ('+' or '-') ||
435+
value[timezoneIndex + 3] != ':' ||
436+
!IsAsciiDigit(value[timezoneIndex + 1]) ||
437+
!IsAsciiDigit(value[timezoneIndex + 2]) ||
438+
!IsAsciiDigit(value[timezoneIndex + 4]) ||
439+
!IsAsciiDigit(value[timezoneIndex + 5]))
440+
{
441+
return false;
442+
}
443+
444+
int offsetHours =
445+
(value[timezoneIndex + 1] - '0') * 10 + value[timezoneIndex + 2] - '0';
446+
int offsetMinutes =
447+
(value[timezoneIndex + 4] - '0') * 10 + value[timezoneIndex + 5] - '0';
448+
return offsetHours <= 14 &&
449+
offsetMinutes <= 59 &&
450+
(offsetHours != 14 || offsetMinutes == 0);
407451
}
408452

453+
private static bool IsAsciiDigit(char value) => value is >= '0' and <= '9';
454+
409455
private static int GetUnicodeScalarLength(string value)
410456
{
411457
int length = 0;

src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,9 @@ public static ElicitRequestParams SetAppUi(ElicitRequestParams request, string r
104104
ValidateArguments(request, resourceUri);
105105

106106
request.Meta ??= [];
107-
request.Meta["ui"] = JsonSerializer.SerializeToNode(
108-
new McpAppElicitationMeta { ResourceUri = resourceUri },
109-
McpAppsJsonContext.Default.McpAppElicitationMeta
110-
);
107+
var ui = request.Meta["ui"] as JsonObject ?? [];
108+
ui["resourceUri"] = resourceUri;
109+
request.Meta["ui"] = ui;
111110
return request;
112111
}
113112

tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,38 @@ public void SetAppUi_RoundTripsMetadataWithoutChangingCoreRequest()
6262
);
6363
}
6464

65+
[Fact]
66+
public void SetAppUi_PreservesExistingUiFieldsAndUpdatesResourceUri()
67+
{
68+
var request = CreateRequest();
69+
request.Meta = new JsonObject
70+
{
71+
["ui"] = new JsonObject
72+
{
73+
["resourceUri"] = "ui://example/old.html",
74+
["custom"] = new JsonObject { ["enabled"] = true },
75+
},
76+
};
77+
78+
McpAppElicitation.SetAppUi(request, "ui://example/new.html");
79+
80+
var ui = Assert.IsType<JsonObject>(request.Meta["ui"]);
81+
Assert.Equal("ui://example/new.html", ui["resourceUri"]?.GetValue<string>());
82+
Assert.True(ui["custom"]?["enabled"]?.GetValue<bool>());
83+
}
84+
85+
[Fact]
86+
public void SetAppUi_ReplacesMalformedUiMetadata()
87+
{
88+
var request = CreateRequest();
89+
request.Meta = new JsonObject { ["ui"] = "not-an-object" };
90+
91+
McpAppElicitation.SetAppUi(request, "ui://example/view.html");
92+
93+
var ui = Assert.IsType<JsonObject>(request.Meta["ui"]);
94+
Assert.Equal("ui://example/view.html", ui["resourceUri"]?.GetValue<string>());
95+
}
96+
6597
[Fact]
6698
public void SetAppUi_RoundTripsThroughMrtrInputRequest()
6799
{
@@ -413,6 +445,14 @@ public void ValidateResult_LengthNumericAndSelectionBounds_ReturnErrors()
413445
[InlineData("relative/path", "uri")]
414446
[InlineData("2026-02-30", "date")]
415447
[InlineData("2026-08-03 11:22:30Z", "date-time")]
448+
[InlineData("2026-02-30T11:22:30Z", "date-time")]
449+
[InlineData("2026-08-03T24:00:00Z", "date-time")]
450+
[InlineData("2026-08-03T11:22:30", "date-time")]
451+
[InlineData("2026-08-03T11:22:30.Z", "date-time")]
452+
[InlineData("2026-08-03T11:22:30.123456789", "date-time")]
453+
[InlineData("2026-08-03T11:22:30+0700", "date-time")]
454+
[InlineData("2026-08-03T11:22:30+07:60", "date-time")]
455+
[InlineData("2026-08-03T11:22:30+14:01", "date-time")]
416456
public void ValidateResult_InvalidStringFormat_ReturnsError(string value, string format)
417457
{
418458
var request = new ElicitRequestParams
@@ -452,7 +492,11 @@ public void ValidateResult_InvalidStringFormat_ReturnsError(string value, string
452492
[InlineData("https://example.com/path", "uri")]
453493
[InlineData("2026-08-03", "date")]
454494
[InlineData("2026-08-03T11:22:30Z", "date-time")]
495+
[InlineData("2026-08-03t11:22:30z", "date-time")]
496+
[InlineData("2026-08-03T11:22:30+05:30", "date-time")]
455497
[InlineData("2026-08-03T11:22:30.462-07:00", "date-time")]
498+
[InlineData("2026-08-03T11:22:30.123456789Z", "date-time")]
499+
[InlineData("2026-08-03T11:22:30.123456789+14:00", "date-time")]
456500
public void ValidateResult_ValidStringFormat_IsAccepted(string value, string format)
457501
{
458502
var request = new ElicitRequestParams

0 commit comments

Comments
 (0)