Skip to content

Commit 2a60ff2

Browse files
authored
Merge branch 'main' into copilot/fix-comment-issue-1338
2 parents f70499b + 1a6c0dd commit 2a60ff2

10 files changed

Lines changed: 211 additions & 14 deletions

File tree

docs/list-of-diagnostics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T
2323

2424
| Diagnostic ID | Description |
2525
| :------------ | :---------- |
26-
| `MCPEXP001` | MCP task-related APIs are experimental. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results. See [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) for details. |
26+
| `MCPEXP001` | MCP experimental APIs including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). |

src/Common/Experimentals.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,25 @@ internal static class Experimentals
3535
/// URL for the experimental MCP Tasks feature.
3636
/// </summary>
3737
public const string Tasks_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
38+
39+
/// <summary>
40+
/// Diagnostic ID for the experimental MCP Extensions feature.
41+
/// </summary>
42+
/// <remarks>
43+
/// This uses the same diagnostic ID as <see cref="Tasks_DiagnosticId"/> because both
44+
/// Tasks and Extensions are covered by the same MCPEXP001 diagnostic for experimental
45+
/// MCP features. Having separate constants improves code clarity while maintaining a
46+
/// single diagnostic suppression point.
47+
/// </remarks>
48+
public const string Extensions_DiagnosticId = "MCPEXP001";
49+
50+
/// <summary>
51+
/// Message for the experimental MCP Extensions feature.
52+
/// </summary>
53+
public const string Extensions_Message = "The Extensions feature is part of a future MCP specification version that has not yet been ratified and is subject to change.";
54+
55+
/// <summary>
56+
/// URL for the experimental MCP Extensions feature.
57+
/// </summary>
58+
public const string Extensions_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
3859
}

src/Directory.Build.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<PackageProjectUrl>https://modelcontextprotocol.github.io/csharp-sdk</PackageProjectUrl>
66
<RepositoryUrl>https://github.com/modelcontextprotocol/csharp-sdk</RepositoryUrl>
77
<RepositoryType>git</RepositoryType>
8-
<VersionPrefix>1.0.0</VersionPrefix>
9-
<VersionSuffix>rc.1</VersionSuffix>
8+
<VersionPrefix>0.9.0</VersionPrefix>
9+
<VersionSuffix>preview.2</VersionSuffix>
1010
<Authors>ModelContextProtocol</Authors>
1111
<Copyright>© Anthropic and Contributors.</Copyright>
1212
<PackageTags>ModelContextProtocol;mcp;ai;llm</PackageTags>

src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,31 @@ public McpTasksCapability? Tasks
9393
[JsonInclude]
9494
[JsonPropertyName("tasks")]
9595
internal McpTasksCapability? TasksCore { get; set; }
96+
97+
/// <summary>
98+
/// Gets or sets optional MCP extensions that the client supports.
99+
/// </summary>
100+
/// <remarks>
101+
/// <para>
102+
/// Keys are extension identifiers in reverse domain notation with an extension name
103+
/// (e.g., <c>"io.modelcontextprotocol/oauth-client-credentials"</c>), and values are
104+
/// per-extension settings objects. An empty object indicates support with no additional settings.
105+
/// </para>
106+
/// <para>
107+
/// Extensions provide a framework for extending the Model Context Protocol while maintaining
108+
/// interoperability. Clients advertise extension support via this field during the initialization handshake.
109+
/// </para>
110+
/// </remarks>
111+
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
112+
[JsonIgnore]
113+
public IDictionary<string, object>? Extensions
114+
{
115+
get => ExtensionsCore;
116+
set => ExtensionsCore = value;
117+
}
118+
119+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
120+
[JsonInclude]
121+
[JsonPropertyName("extensions")]
122+
internal IDictionary<string, object>? ExtensionsCore { get; set; }
96123
}

src/ModelContextProtocol.Core/Protocol/Root.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
using System.Diagnostics.CodeAnalysis;
2-
using System.Text.Json;
2+
using System.Text.Json.Nodes;
33
using System.Text.Json.Serialization;
44

55
namespace ModelContextProtocol.Protocol;
@@ -35,5 +35,5 @@ public sealed class Root
3535
/// This is reserved by the protocol for future use.
3636
/// </remarks>
3737
[JsonPropertyName("_meta")]
38-
public JsonElement? Meta { get; set; }
38+
public JsonObject? Meta { get; set; }
3939
}

src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,31 @@ public McpTasksCapability? Tasks
9292
[JsonInclude]
9393
[JsonPropertyName("tasks")]
9494
internal McpTasksCapability? TasksCore { get; set; }
95+
96+
/// <summary>
97+
/// Gets or sets optional MCP extensions that the server supports.
98+
/// </summary>
99+
/// <remarks>
100+
/// <para>
101+
/// Keys are extension identifiers in reverse domain notation with an extension name
102+
/// (e.g., <c>"io.modelcontextprotocol/apps"</c>), and values are per-extension settings
103+
/// objects. An empty object indicates support with no additional settings.
104+
/// </para>
105+
/// <para>
106+
/// Extensions provide a framework for extending the Model Context Protocol while maintaining
107+
/// interoperability. Servers advertise extension support via this field during the initialization handshake.
108+
/// </para>
109+
/// </remarks>
110+
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
111+
[JsonIgnore]
112+
public IDictionary<string, object>? Extensions
113+
{
114+
get => ExtensionsCore;
115+
set => ExtensionsCore = value;
116+
}
117+
118+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
119+
[JsonInclude]
120+
[JsonPropertyName("extensions")]
121+
internal IDictionary<string, object>? ExtensionsCore { get; set; }
95122
}

tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,13 @@ public async Task RunConformanceTest(string scenario)
132132

133133
/// <summary>
134134
/// Checks if the conformance test output indicates that all checks passed with only
135-
/// warnings (no actual failures). The conformance runner exits with code 1 for warnings,
136-
/// but warnings represent acceptable behavior (e.g., timing tolerances in CI environments).
135+
/// warnings or known CI-timing failures. The conformance runner exits with code 1 for
136+
/// warnings/failures, but some represent acceptable behavior in CI environments:
137+
/// - Warnings (e.g., slightly late reconnects) are always acceptable.
138+
/// - "Reconnected very late" failures are acceptable when the actual delay is within a
139+
/// reasonable bound, as CI machines may introduce network/scheduling latency that pushes
140+
/// the observed reconnect time past the conformance test's strict threshold even though
141+
/// the client correctly honored the retry field.
137142
/// </summary>
138143
private static bool HasOnlyWarnings(string output, string error)
139144
{
@@ -142,9 +147,39 @@ private static bool HasOnlyWarnings(string output, string error)
142147
// If there are 0 failures but warnings > 0, the test behavior is acceptable.
143148
var combined = output + error;
144149
var match = Regex.Match(combined, @"(?<failed>\d+) failed, (?<warnings>\d+) warnings");
145-
return match.Success
146-
&& match.Groups["failed"].Value == "0"
150+
if (!match.Success)
151+
{
152+
return false;
153+
}
154+
155+
if (match.Groups["failed"].Value == "0"
147156
&& int.TryParse(match.Groups["warnings"].Value, out var warnings)
148-
&& warnings > 0;
157+
&& warnings > 0)
158+
{
159+
return true;
160+
}
161+
162+
// Also accept cases where all failures are "reconnected very late" timing failures.
163+
// These occur in CI when OS/network overhead between the server closing the SSE stream
164+
// and the client detecting it pushes the total reconnect time past the conformance
165+
// test's VERY_LATE_MULTIPLIER threshold (2x the retry value), even though the client
166+
// correctly waited the retry interval after detecting the stream close.
167+
// We require the actual delay to be < 10x the expected retry value to avoid masking
168+
// genuine bugs where the client ignores the retry field entirely.
169+
if (int.TryParse(match.Groups["failed"].Value, out var failed) && failed > 0)
170+
{
171+
var lateReconnectMatches = Regex.Matches(combined, @"Client reconnected very late \((\d+)ms instead of (\d+)ms\)");
172+
if (lateReconnectMatches.Count == failed
173+
&& lateReconnectMatches.Cast<Match>().All(m =>
174+
int.TryParse(m.Groups[1].Value, out var actual)
175+
&& int.TryParse(m.Groups[2].Value, out var expected)
176+
&& expected > 0
177+
&& actual < expected * 10))
178+
{
179+
return true;
180+
}
181+
}
182+
183+
return false;
149184
}
150185
}

tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ public static void ClientCapabilities_SerializationRoundTrip_PreservesAllPropert
2121
Form = new FormElicitationCapability(),
2222
Url = new UrlElicitationCapability()
2323
},
24-
Tasks = new McpTasksCapability()
24+
Tasks = new McpTasksCapability(),
25+
Extensions = new Dictionary<string, object>
26+
{
27+
["io.modelcontextprotocol/test"] = new object()
28+
}
2529
};
2630

2731
string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
@@ -37,6 +41,8 @@ public static void ClientCapabilities_SerializationRoundTrip_PreservesAllPropert
3741
Assert.NotNull(deserialized.Elicitation.Form);
3842
Assert.NotNull(deserialized.Elicitation.Url);
3943
Assert.NotNull(deserialized.Tasks);
44+
Assert.NotNull(deserialized.Extensions);
45+
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/test"));
4046
}
4147

4248
[Fact]
@@ -53,5 +59,42 @@ public static void ClientCapabilities_SerializationRoundTrip_WithMinimalProperti
5359
Assert.Null(deserialized.Sampling);
5460
Assert.Null(deserialized.Elicitation);
5561
Assert.Null(deserialized.Tasks);
62+
Assert.Null(deserialized.Extensions);
63+
}
64+
65+
[Fact]
66+
public static void ClientCapabilities_Extensions_DeserializesFromJson()
67+
{
68+
string json = """
69+
{
70+
"extensions": {
71+
"io.modelcontextprotocol/oauth-client-credentials": {},
72+
"io.modelcontextprotocol/test-extension": {
73+
"setting1": "value1",
74+
"setting2": 42
75+
}
76+
}
77+
}
78+
""";
79+
80+
var deserialized = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);
81+
82+
Assert.NotNull(deserialized);
83+
Assert.NotNull(deserialized.Extensions);
84+
Assert.Equal(2, deserialized.Extensions.Count);
85+
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/oauth-client-credentials"));
86+
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/test-extension"));
87+
}
88+
89+
[Fact]
90+
public static void ClientCapabilities_Extensions_EmptyObjectDeserializesAsEmptyDictionary()
91+
{
92+
string json = """{"extensions": {}}""";
93+
94+
var deserialized = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);
95+
96+
Assert.NotNull(deserialized);
97+
Assert.NotNull(deserialized.Extensions);
98+
Assert.Empty(deserialized.Extensions);
5699
}
57100
}

tests/ModelContextProtocol.Tests/Protocol/RootTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using ModelContextProtocol.Protocol;
22
using System.Text.Json;
3+
using System.Text.Json.Nodes;
34

45
namespace ModelContextProtocol.Tests.Protocol;
56

@@ -12,7 +13,7 @@ public static void Root_SerializationRoundTrip_PreservesAllProperties()
1213
{
1314
Uri = "file:///home/user/project",
1415
Name = "My Project",
15-
Meta = JsonDocument.Parse("""{"custom":"data"}""").RootElement.Clone()
16+
Meta = new JsonObject { ["custom"] = "data" }
1617
};
1718

1819
string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
@@ -22,7 +23,7 @@ public static void Root_SerializationRoundTrip_PreservesAllProperties()
2223
Assert.Equal("file:///home/user/project", deserialized.Uri);
2324
Assert.Equal("My Project", deserialized.Name);
2425
Assert.NotNull(deserialized.Meta);
25-
Assert.Equal("data", deserialized.Meta.Value.GetProperty("custom").GetString());
26+
Assert.Equal("data", (string?)deserialized.Meta["custom"]);
2627
}
2728

2829
[Fact]

tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ public static void ServerCapabilities_SerializationRoundTrip_PreservesAllPropert
1515
Resources = new ResourcesCapability { Subscribe = true, ListChanged = true },
1616
Tools = new ToolsCapability { ListChanged = false },
1717
Completions = new CompletionsCapability(),
18-
Tasks = new McpTasksCapability()
18+
Tasks = new McpTasksCapability(),
19+
Extensions = new Dictionary<string, object>
20+
{
21+
["io.modelcontextprotocol/apps"] = new object()
22+
}
1923
};
2024

2125
string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
@@ -32,6 +36,8 @@ public static void ServerCapabilities_SerializationRoundTrip_PreservesAllPropert
3236
Assert.False(deserialized.Tools.ListChanged);
3337
Assert.NotNull(deserialized.Completions);
3438
Assert.NotNull(deserialized.Tasks);
39+
Assert.NotNull(deserialized.Extensions);
40+
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/apps"));
3541
}
3642

3743
[Fact]
@@ -50,5 +56,42 @@ public static void ServerCapabilities_SerializationRoundTrip_WithMinimalProperti
5056
Assert.Null(deserialized.Tools);
5157
Assert.Null(deserialized.Completions);
5258
Assert.Null(deserialized.Tasks);
59+
Assert.Null(deserialized.Extensions);
60+
}
61+
62+
[Fact]
63+
public static void ServerCapabilities_Extensions_DeserializesFromJson()
64+
{
65+
string json = """
66+
{
67+
"extensions": {
68+
"io.modelcontextprotocol/apps": {},
69+
"io.modelcontextprotocol/custom": {
70+
"option": 42,
71+
"enabled": true
72+
}
73+
}
74+
}
75+
""";
76+
77+
var deserialized = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);
78+
79+
Assert.NotNull(deserialized);
80+
Assert.NotNull(deserialized.Extensions);
81+
Assert.Equal(2, deserialized.Extensions.Count);
82+
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/apps"));
83+
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/custom"));
84+
}
85+
86+
[Fact]
87+
public static void ServerCapabilities_Extensions_EmptyObjectDeserializesAsEmptyDictionary()
88+
{
89+
string json = """{"extensions": {}}""";
90+
91+
var deserialized = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);
92+
93+
Assert.NotNull(deserialized);
94+
Assert.NotNull(deserialized.Extensions);
95+
Assert.Empty(deserialized.Extensions);
5396
}
5497
}

0 commit comments

Comments
 (0)