Skip to content

Commit 0c10b09

Browse files
authored
Merge branch 'main' into localden/experimental
2 parents ec926c3 + befa31d commit 0c10b09

File tree

14 files changed

+105
-25
lines changed

14 files changed

+105
-25
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<LangVersion>13</LangVersion>
3+
<LangVersion>preview</LangVersion>
44
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>

src/Common/Polyfills/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace System.Runtime.CompilerServices
1313
{
1414
/// <summary>Provides a handler used by the language compiler to process interpolated strings into <see cref="string"/> instances.</summary>
15-
public ref struct DefaultInterpolatedStringHandler
15+
internal ref struct DefaultInterpolatedStringHandler
1616
{
1717
// Implementation note:
1818
// As this type lives in CompilerServices and is only intended to be targeted by the compiler,

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public class HttpServerTransportOptions
2828
/// </summary>
2929
/// <remarks>
3030
/// If <see langword="true"/>, the "/sse" endpoint will be disabled, and client information will be round-tripped as part
31-
/// of the "mcp-session-id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client
31+
/// of the "MCP-Session-Id" header instead of stored in memory. Unsolicited server-to-client messages and all server-to-client
3232
/// requests are also unsupported, because any responses may arrive at another ASP.NET Core application process.
3333
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.
3434
/// Defaults to <see langword="false"/>.

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ internal sealed class StreamableHttpHandler(
2626
ILoggerFactory loggerFactory,
2727
IServiceProvider applicationServices)
2828
{
29+
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
2930
private static readonly JsonTypeInfo<JsonRpcError> s_errorTypeInfo = GetRequiredJsonTypeInfo<JsonRpcError>();
3031

3132
public ConcurrentDictionary<string, HttpMcpSession<StreamableHttpServerTransport>> Sessions { get; } = new(StringComparer.Ordinal);
@@ -70,8 +71,8 @@ await WriteJsonRpcErrorAsync(context,
7071
}
7172
finally
7273
{
73-
// Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the mcp-session-id.
74-
// Non-stateless sessions are 1:1 with the mcp-session-id and outlive the POST request.
74+
// Stateless sessions are 1:1 with HTTP requests and are outlived by the MCP session tracked by the Mcp-Session-Id.
75+
// Non-stateless sessions are 1:1 with the Mcp-Session-Id and outlive the POST request.
7576
// Non-stateless sessions get disposed by a DELETE request or the IdleTrackingBackgroundService.
7677
if (HttpServerTransportOptions.Stateless)
7778
{
@@ -90,7 +91,7 @@ await WriteJsonRpcErrorAsync(context,
9091
return;
9192
}
9293

93-
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
94+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
9495
var session = await GetSessionAsync(context, sessionId);
9596
if (session is null)
9697
{
@@ -117,7 +118,7 @@ await WriteJsonRpcErrorAsync(context,
117118

118119
public async Task HandleDeleteRequestAsync(HttpContext context)
119120
{
120-
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
121+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
121122
if (Sessions.TryRemove(sessionId, out var session))
122123
{
123124
await session.DisposeAsync();
@@ -157,14 +158,14 @@ await WriteJsonRpcErrorAsync(context,
157158
return null;
158159
}
159160

160-
context.Response.Headers["mcp-session-id"] = session.Id;
161+
context.Response.Headers[McpSessionIdHeaderName] = session.Id;
161162
context.Features.Set(session.Server);
162163
return session;
163164
}
164165

165166
private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>?> GetOrCreateSessionAsync(HttpContext context)
166167
{
167-
var sessionId = context.Request.Headers["mcp-session-id"].ToString();
168+
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
168169

169170
if (string.IsNullOrEmpty(sessionId))
170171
{
@@ -188,11 +189,11 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS
188189
{
189190
SessionId = sessionId,
190191
};
191-
context.Response.Headers["mcp-session-id"] = sessionId;
192+
context.Response.Headers[McpSessionIdHeaderName] = sessionId;
192193
}
193194
else
194195
{
195-
// "(uninitialized stateless id)" is not written anywhere. We delay writing the mcp-session-id
196+
// "(uninitialized stateless id)" is not written anywhere. We delay writing the MCP-Session-Id
196197
// until after we receive the initialize request with the client info we need to serialize.
197198
sessionId = "(uninitialized stateless id)";
198199
transport = new()
@@ -204,7 +205,7 @@ private async ValueTask<HttpMcpSession<StreamableHttpServerTransport>> StartNewS
204205

205206
var session = await CreateSessionAsync(context, transport, sessionId);
206207

207-
// The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the mcp-session-id.
208+
// The HttpMcpSession is not stored between requests in stateless mode. Instead, the session is recreated from the MCP-Session-Id.
208209
if (!HttpServerTransportOptions.Stateless)
209210
{
210211
if (!Sessions.TryAdd(sessionId, session))
@@ -299,7 +300,7 @@ private void ScheduleStatelessSessionIdWrite(HttpContext context, StreamableHttp
299300

300301
var sessionJson = JsonSerializer.Serialize(statelessId, StatelessSessionIdJsonContext.Default.StatelessSessionId);
301302
transport.SessionId = Protector.Protect(sessionJson);
302-
context.Response.Headers["mcp-session-id"] = transport.SessionId;
303+
context.Response.Headers[McpSessionIdHeaderName] = transport.SessionId;
303304
return ValueTask.CompletedTask;
304305
};
305306
}

src/ModelContextProtocol.Core/Client/StdioClientTransport.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
146146
stderrRollingLog.Enqueue(data);
147147
}
148148

149+
_options.StandardErrorLines?.Invoke(data);
150+
149151
LogReadStderr(logger, endpointName, data);
150152
}
151153
};

src/ModelContextProtocol.Core/Client/StdioClientTransportOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,9 @@ public required string Command
6969
/// </para>
7070
/// </remarks>
7171
public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(5);
72+
73+
/// <summary>
74+
/// Gets or sets a callback that is invoked for each line of stderr received from the server process.
75+
/// </summary>
76+
public Action<string>? StandardErrorLines { get; set; }
7277
}

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2727
private readonly CancellationTokenSource _connectionCts;
2828
private readonly ILogger _logger;
2929

30+
private string? _negotiatedProtocolVersion;
3031
private Task? _getReceiveTask;
3132
private int _disposed;
3233

@@ -85,7 +86,7 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
8586
},
8687
};
8788

88-
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId);
89+
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
8990

9091
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
9192

@@ -119,14 +120,17 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
119120
throw new McpException($"Streamable HTTP POST response completed without a reply to request with ID: {rpcRequest.Id}");
120121
}
121122

122-
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse)
123+
if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse)
123124
{
124-
// We've successfully initialized! Copy session-id and start GET request if any.
125-
if (response.Headers.TryGetValues("mcp-session-id", out var sessionIdValues))
125+
// We've successfully initialized! Copy session-id and protocol version, then start GET request if any.
126+
if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues))
126127
{
127128
SessionId = sessionIdValues.FirstOrDefault();
128129
}
129130

131+
var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
132+
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
133+
130134
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
131135
}
132136

@@ -175,7 +179,7 @@ private async Task ReceiveUnsolicitedMessagesAsync()
175179
// Send a GET request to handle any unsolicited messages not sent over a POST response.
176180
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
177181
request.Headers.Accept.Add(s_textEventStreamMediaType);
178-
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId);
182+
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
179183

180184
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _connectionCts.Token).ConfigureAwait(false);
181185

@@ -250,11 +254,20 @@ private void LogJsonException(JsonException ex, string data)
250254
}
251255
}
252256

253-
internal static void CopyAdditionalHeaders(HttpRequestHeaders headers, IDictionary<string, string>? additionalHeaders, string? sessionId = null)
257+
internal static void CopyAdditionalHeaders(
258+
HttpRequestHeaders headers,
259+
IDictionary<string, string>? additionalHeaders,
260+
string? sessionId = null,
261+
string? protocolVersion = null)
254262
{
255263
if (sessionId is not null)
256264
{
257-
headers.Add("mcp-session-id", sessionId);
265+
headers.Add("Mcp-Session-Id", sessionId);
266+
}
267+
268+
if (protocolVersion is not null)
269+
{
270+
headers.Add("MCP-Protocol-Version", protocolVersion);
258271
}
259272

260273
if (additionalHeaders is null)

src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<PackageId>ModelContextProtocol.Core</PackageId>
88
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
10-
<LangVersion>preview</LangVersion>
1110
</PropertyGroup>
1211

1312
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

src/ModelContextProtocol/ModelContextProtocol.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
<PackageId>ModelContextProtocol</PackageId>
88
<Description>.NET SDK for the Model Context Protocol (MCP) with hosting and dependency injection extensions.</Description>
99
<PackageReadmeFile>README.md</PackageReadmeFile>
10-
<LangVersion>preview</LangVersion>
1110
</PropertyGroup>
1211

1312
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.AspNetCore.Builder;
22
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Primitives;
34
using ModelContextProtocol.Client;
45

56
namespace ModelContextProtocol.AspNetCore.Tests;
@@ -143,4 +144,38 @@ public async Task SseMode_Works_WithSseEndpoint()
143144

144145
Assert.Equal("SseTestServer", mcpClient.ServerInfo.Name);
145146
}
147+
148+
[Fact]
149+
public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitialization()
150+
{
151+
var protocolVersionHeaderValues = new List<string?>();
152+
153+
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
154+
155+
await using var app = Builder.Build();
156+
157+
app.Use(next =>
158+
{
159+
return async context =>
160+
{
161+
if (!StringValues.IsNullOrEmpty(context.Request.Headers["mcp-session-id"]))
162+
{
163+
protocolVersionHeaderValues.Add(context.Request.Headers["mcp-protocol-version"]);
164+
}
165+
166+
await next(context);
167+
};
168+
});
169+
170+
app.MapMcp();
171+
172+
await app.StartAsync(TestContext.Current.CancellationToken);
173+
174+
await using var mcpClient = await ConnectAsync();
175+
await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
176+
177+
// The header should be included in the GET request, the initialized notification, and the tools/list call.
178+
Assert.Equal(3, protocolVersionHeaderValues.Count);
179+
Assert.All(protocolVersionHeaderValues, v => Assert.Equal("2025-03-26", v));
180+
}
146181
}

0 commit comments

Comments
 (0)