Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/list-of-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T

| Diagnostic ID | Description |
| :------------ | :---------- |
| `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. |
| `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)). |
21 changes: 21 additions & 0 deletions src/Common/Experimentals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,25 @@ internal static class Experimentals
/// URL for the experimental MCP Tasks feature.
/// </summary>
public const string Tasks_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";

/// <summary>
/// Diagnostic ID for the experimental MCP Extensions feature.
/// </summary>
/// <remarks>
/// This uses the same diagnostic ID as <see cref="Tasks_DiagnosticId"/> because both
/// Tasks and Extensions are covered by the same MCPEXP001 diagnostic for experimental
/// MCP features. Having separate constants improves code clarity while maintaining a
/// single diagnostic suppression point.
/// </remarks>
public const string Extensions_DiagnosticId = "MCPEXP001";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public const string Extensions_DiagnosticId = "MCPEXP001";
public const string Extensions_DiagnosticId = "MCPEXP002";

Shouldn't we use a different diagnostic ID if we're going to have a different URL/message? There are more usages we'll have to fix. Are we going to wait for #1260 to get merged first before merging this? @MackinnonBuck

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same url, just a different message. It's fine to have different messages for the dame diagnostic. I also don't want to proliferate diagnostics, or makes consumption super annoying.


/// <summary>
/// Message for the experimental MCP Extensions feature.
/// </summary>
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.";

/// <summary>
/// URL for the experimental MCP Extensions feature.
/// </summary>
public const string Extensions_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
}
20 changes: 20 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
Expand Down Expand Up @@ -82,4 +83,23 @@ public sealed class ClientCapabilities
/// </remarks>
[JsonPropertyName("tasks")]
public McpTasksCapability? Tasks { get; set; }

/// <summary>
/// Gets or sets optional MCP extensions that the client supports.
/// </summary>
/// <remarks>
/// <para>
/// Keys are extension identifiers in reverse domain notation with an extension name
/// (e.g., <c>"io.modelcontextprotocol/oauth-client-credentials"</c>), and values are
/// per-extension settings objects. An empty object indicates support with no additional settings.
/// </para>
/// <para>
/// Extensions provide a framework for extending the Model Context Protocol while maintaining
/// interoperability. Both clients and servers advertise extension support via this field during
/// the initialization handshake.
/// </para>
/// </remarks>
[JsonPropertyName("extensions")]
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] // SEP-2133: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
public IDictionary<string, object>? Extensions { get; set; }
}
20 changes: 20 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;

Expand Down Expand Up @@ -81,4 +82,23 @@ public sealed class ServerCapabilities
/// </remarks>
[JsonPropertyName("tasks")]
public McpTasksCapability? Tasks { get; set; }

/// <summary>
/// Gets or sets optional MCP extensions that the server supports.
/// </summary>
/// <remarks>
/// <para>
/// Keys are extension identifiers in reverse domain notation with an extension name
/// (e.g., <c>"io.modelcontextprotocol/apps"</c>), and values are per-extension settings
/// objects. An empty object indicates support with no additional settings.
/// </para>
/// <para>
/// Extensions provide a framework for extending the Model Context Protocol while maintaining
/// interoperability. Both clients and servers advertise extension support via this field during
/// the initialization handshake.
/// </para>
/// </remarks>
[JsonPropertyName("extensions")]
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] // SEP-2133: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
public IDictionary<string, object>? Extensions { get; set; }
}
138 changes: 138 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ClientCapabilitiesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;

namespace ModelContextProtocol.Tests.Protocol;

public static class ClientCapabilitiesTests
{
[Fact]
public static void ExtensionsProperty_SerializationRoundTrip()
{
// Arrange - Use raw JSON instead of objects for source generation compatibility
string json = """
{
"extensions": {
"io.modelcontextprotocol/oauth-client-credentials": {},
"io.modelcontextprotocol/test-extension": {
"setting1": "value1",
"setting2": 42
}
}
}
""";

// Act - Deserialize from JSON
var deserialized = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Extensions);
Assert.Equal(2, deserialized.Extensions.Count);
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/oauth-client-credentials"));
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/test-extension"));

// Act - Serialize back to JSON
string roundtrippedJson = JsonSerializer.Serialize(deserialized, McpJsonUtilities.DefaultOptions);

// Assert - Deserialize again to verify
var deserialized2 = JsonSerializer.Deserialize<ClientCapabilities>(roundtrippedJson, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized2);
Assert.NotNull(deserialized2.Extensions);
Assert.Equal(2, deserialized2.Extensions.Count);
}

[Fact]
public static void ExtensionsProperty_DeserializesCorrectly()
{
// Arrange
string json = """
{
"extensions": {
"io.modelcontextprotocol/test": {}
}
}
""";

// Act
var capabilities = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(capabilities);
Assert.NotNull(capabilities.Extensions);
Assert.Single(capabilities.Extensions);
Assert.True(capabilities.Extensions.ContainsKey("io.modelcontextprotocol/test"));
}

[Fact]
public static void WithoutExtensions_DeserializesWithNullExtensions()
{
// Arrange
string json = "{}";

// Act
var capabilities = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(capabilities);
Assert.Null(capabilities.Extensions);
}

[Fact]
public static void WithEmptyExtensions_DeserializesAsEmptyDictionary()
{
// Arrange
string json = """
{
"extensions": {}
}
""";

// Act
var capabilities = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(capabilities);
Assert.NotNull(capabilities.Extensions);
Assert.Empty(capabilities.Extensions);
}

[Fact]
public static void ExtensionsWithComplexValues_RoundTrips()
{
// Arrange
string json = """
{
"extensions": {
"io.modelcontextprotocol/complex": {
"stringValue": "test",
"numberValue": 123,
"boolValue": true,
"arrayValue": [1, 2, 3]
}
}
}
""";

// Act - Deserialize from JSON
var deserialized = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Extensions);
Assert.Single(deserialized.Extensions);
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/complex"));

// Verify the complex value can be accessed as JsonElement
var complexValue = deserialized.Extensions["io.modelcontextprotocol/complex"];
Assert.NotNull(complexValue);

// Act - Serialize back to JSON
string roundtrippedJson = JsonSerializer.Serialize(deserialized, McpJsonUtilities.DefaultOptions);

// Assert - Verify it can deserialize again
var deserialized2 = JsonSerializer.Deserialize<ClientCapabilities>(roundtrippedJson, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized2);
Assert.NotNull(deserialized2.Extensions);
Assert.Single(deserialized2.Extensions);
}
}
138 changes: 138 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/ServerCapabilitiesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;

namespace ModelContextProtocol.Tests.Protocol;

public static class ServerCapabilitiesTests
{
[Fact]
public static void ExtensionsProperty_SerializationRoundTrip()
{
// Arrange - Use raw JSON instead of objects for source generation compatibility
string json = """
{
"extensions": {
"io.modelcontextprotocol/apps": {},
"io.modelcontextprotocol/custom": {
"option": 42,
"enabled": true
}
}
}
""";

// Act - Deserialize from JSON
var deserialized = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Extensions);
Assert.Equal(2, deserialized.Extensions.Count);
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/apps"));
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/custom"));

// Act - Serialize back to JSON
string roundtrippedJson = JsonSerializer.Serialize(deserialized, McpJsonUtilities.DefaultOptions);

// Assert - Deserialize again to verify
var deserialized2 = JsonSerializer.Deserialize<ServerCapabilities>(roundtrippedJson, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized2);
Assert.NotNull(deserialized2.Extensions);
Assert.Equal(2, deserialized2.Extensions.Count);
}

[Fact]
public static void ExtensionsProperty_DeserializesCorrectly()
{
// Arrange
string json = """
{
"extensions": {
"io.modelcontextprotocol/test": {}
}
}
""";

// Act
var capabilities = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(capabilities);
Assert.NotNull(capabilities.Extensions);
Assert.Single(capabilities.Extensions);
Assert.True(capabilities.Extensions.ContainsKey("io.modelcontextprotocol/test"));
}

[Fact]
public static void WithoutExtensions_DeserializesWithNullExtensions()
{
// Arrange
string json = "{}";

// Act
var capabilities = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(capabilities);
Assert.Null(capabilities.Extensions);
}

[Fact]
public static void WithEmptyExtensions_DeserializesAsEmptyDictionary()
{
// Arrange
string json = """
{
"extensions": {}
}
""";

// Act
var capabilities = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(capabilities);
Assert.NotNull(capabilities.Extensions);
Assert.Empty(capabilities.Extensions);
}

[Fact]
public static void ExtensionsWithComplexValues_RoundTrips()
{
// Arrange
string json = """
{
"extensions": {
"io.modelcontextprotocol/complex": {
"stringValue": "test",
"numberValue": 456,
"boolValue": false,
"arrayValue": ["a", "b", "c"]
}
}
}
""";

// Act - Deserialize from JSON
var deserialized = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Extensions);
Assert.Single(deserialized.Extensions);
Assert.True(deserialized.Extensions.ContainsKey("io.modelcontextprotocol/complex"));

// Verify the complex value can be accessed as JsonElement
var complexValue = deserialized.Extensions["io.modelcontextprotocol/complex"];
Assert.NotNull(complexValue);

// Act - Serialize back to JSON
string roundtrippedJson = JsonSerializer.Serialize(deserialized, McpJsonUtilities.DefaultOptions);

// Assert - Verify it can deserialize again
var deserialized2 = JsonSerializer.Deserialize<ServerCapabilities>(roundtrippedJson, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized2);
Assert.NotNull(deserialized2.Extensions);
Assert.Single(deserialized2.Extensions);
}
}
Loading