Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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";
Comment thread
halter73 marked this conversation as resolved.

/// <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; }
Comment thread
stephentoub marked this conversation as resolved.
}
269 changes: 269 additions & 0 deletions tests/ModelContextProtocol.Tests/Protocol/CapabilitiesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;

namespace ModelContextProtocol.Tests.Protocol;

public static class CapabilitiesTests
{
[Fact]
public static void ClientCapabilities_ExtensionsProperty_SerializationRoundTrip()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@copilot these should be split into test files by type, one for client, one for server

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b19aefd. Split into ClientCapabilitiesTests.cs (5 tests) and ServerCapabilitiesTests.cs (5 tests).

{
// 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 ClientCapabilities_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 ClientCapabilities_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 ClientCapabilities_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 ServerCapabilities_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 ServerCapabilities_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 ServerCapabilities_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 ServerCapabilities_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 ClientCapabilities_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);
}

[Fact]
public static void ServerCapabilities_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