Skip to content

Commit b2e290f

Browse files
Address feedback and use lists when representing stdio arguments.
1 parent 14b184e commit b2e290f

File tree

15 files changed

+195
-115
lines changed

15 files changed

+195
-115
lines changed

samples/ChatWithTools/Program.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
var mcpClient = await McpClientFactory.CreateAsync(
99
new StdioClientTransport(new()
1010
{
11-
Id = "everything",
12-
Name = "Everything",
1311
Command = "npx",
14-
Arguments = "-y --verbose @modelcontextprotocol/server-everything",
12+
Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"],
13+
Description = "Everything",
1514
}));
1615

1716
// Get all available tools

samples/QuickstartClient/Program.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@
1515

1616
await using var mcpClient = await McpClientFactory.CreateAsync(new StdioClientTransport(new()
1717
{
18-
Id = "demo-server",
19-
Name = "Demo Server",
2018
Command = command,
2119
Arguments = arguments,
20+
Description = "Demo Server",
2221
}));
2322

2423
var tools = await mcpClient.ListToolsAsync();
@@ -82,13 +81,13 @@ static void PromptForInput()
8281
///
8382
/// This method would only be required if you're creating a generic client, such as we use for the quickstart.
8483
/// </remarks>
85-
static (string command, string arguments) GetCommandAndArguments(string[] args)
84+
static (string command, string[] arguments) GetCommandAndArguments(string[] args)
8685
{
8786
return args switch
8887
{
89-
[var script] when script.EndsWith(".py") => ("python", script),
90-
[var script] when script.EndsWith(".js") => ("node", script),
91-
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", $"run --project {script} --no-build"),
92-
_ => ("dotnet", "run --project ../../../../QuickstartWeatherServer --no-build")
88+
[var script] when script.EndsWith(".py") => ("python", args),
89+
[var script] when script.EndsWith(".js") => ("node", args),
90+
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", ["run", "--project", script, "--no-build"]),
91+
_ => ("dotnet", ["run", "--project", "../../../../QuickstartWeatherServer", "--no-build"])
9392
};
9493
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
// Copied from:
5+
// https://github.com/dotnet/runtime/blob/d2650b6ae7023a2d9d2c74c56116f1f18472ab04/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs
6+
// and changed from using ValueStringBuilder to StringBuilder.
7+
8+
using System.Text;
9+
10+
namespace System;
11+
12+
internal static partial class PasteArguments
13+
{
14+
internal static void AppendArgument(StringBuilder stringBuilder, string argument)
15+
{
16+
if (stringBuilder.Length != 0)
17+
{
18+
stringBuilder.Append(' ');
19+
}
20+
21+
// Parsing rules for non-argv[0] arguments:
22+
// - Backslash is a normal character except followed by a quote.
23+
// - 2N backslashes followed by a quote ==> N literal backslashes followed by unescaped quote
24+
// - 2N+1 backslashes followed by a quote ==> N literal backslashes followed by a literal quote
25+
// - Parsing stops at first whitespace outside of quoted region.
26+
// - (post 2008 rule): A closing quote followed by another quote ==> literal quote, and parsing remains in quoting mode.
27+
if (argument.Length != 0 && ContainsNoWhitespaceOrQuotes(argument))
28+
{
29+
// Simple case - no quoting or changes needed.
30+
stringBuilder.Append(argument);
31+
}
32+
else
33+
{
34+
stringBuilder.Append(Quote);
35+
int idx = 0;
36+
while (idx < argument.Length)
37+
{
38+
char c = argument[idx++];
39+
if (c == Backslash)
40+
{
41+
int numBackSlash = 1;
42+
while (idx < argument.Length && argument[idx] == Backslash)
43+
{
44+
idx++;
45+
numBackSlash++;
46+
}
47+
48+
if (idx == argument.Length)
49+
{
50+
// We'll emit an end quote after this so must double the number of backslashes.
51+
stringBuilder.Append(Backslash, numBackSlash * 2);
52+
}
53+
else if (argument[idx] == Quote)
54+
{
55+
// Backslashes will be followed by a quote. Must double the number of backslashes.
56+
stringBuilder.Append(Backslash, numBackSlash * 2 + 1);
57+
stringBuilder.Append(Quote);
58+
idx++;
59+
}
60+
else
61+
{
62+
// Backslash will not be followed by a quote, so emit as normal characters.
63+
stringBuilder.Append(Backslash, numBackSlash);
64+
}
65+
66+
continue;
67+
}
68+
69+
if (c == Quote)
70+
{
71+
// Escape the quote so it appears as a literal. This also guarantees that we won't end up generating a closing quote followed
72+
// by another quote (which parses differently pre-2008 vs. post-2008.)
73+
stringBuilder.Append(Backslash);
74+
stringBuilder.Append(Quote);
75+
continue;
76+
}
77+
78+
stringBuilder.Append(c);
79+
}
80+
81+
stringBuilder.Append(Quote);
82+
}
83+
}
84+
85+
private static bool ContainsNoWhitespaceOrQuotes(string s)
86+
{
87+
for (int i = 0; i < s.Length; i++)
88+
{
89+
char c = s[i];
90+
if (char.IsWhiteSpace(c) || c == Quote)
91+
{
92+
return false;
93+
}
94+
}
95+
96+
return true;
97+
}
98+
99+
private const char Quote = '\"';
100+
private const char Backslash = '\\';
101+
}

src/ModelContextProtocol/Client/McpClientFactory.cs

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,31 +34,16 @@ public static async Task<IMcpClient> CreateAsync(
3434
var logger = loggerFactory?.CreateLogger(typeof(McpClientFactory)) ?? NullLogger.Instance;
3535
logger.CreatingClient(endpointName);
3636

37+
McpClient client = new(clientTransport, clientOptions, loggerFactory);
3738
try
3839
{
39-
McpClient client = new(clientTransport, clientOptions, loggerFactory);
40-
try
41-
{
42-
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
43-
logger.ClientCreated(endpointName);
44-
return client;
45-
}
46-
catch
47-
{
48-
await client.DisposeAsync().ConfigureAwait(false);
49-
throw;
50-
}
40+
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
41+
logger.ClientCreated(endpointName);
42+
return client;
5143
}
5244
catch
5345
{
54-
if (clientTransport is IAsyncDisposable asyncDisposableTransport)
55-
{
56-
await asyncDisposableTransport.DisposeAsync().ConfigureAwait(false);
57-
}
58-
else if (clientTransport is IDisposable disposableTransport)
59-
{
60-
disposableTransport.Dispose();
61-
}
46+
await client.DisposeAsync().ConfigureAwait(false);
6247
throw;
6348
}
6449
}

src/ModelContextProtocol/Protocol/Transport/SseClientSessionTransport.cs

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ namespace ModelContextProtocol.Protocol.Transport;
1616
/// </summary>
1717
internal sealed class SseClientSessionTransport : TransportBase
1818
{
19+
private readonly string _endpointName;
1920
private readonly HttpClient _httpClient;
2021
private readonly SseClientTransportOptions _options;
2122
private readonly Uri _sseEndpoint;
@@ -25,16 +26,15 @@ internal sealed class SseClientSessionTransport : TransportBase
2526
private readonly ILogger _logger;
2627
private readonly TaskCompletionSource<bool> _connectionEstablished;
2728

28-
private string EndpointName => $"Client (SSE) for ({_options.Id}: {_options.Name})";
29-
3029
/// <summary>
3130
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
3231
/// The HTTP server can be local or remote, and must support the SSE protocol.
3332
/// </summary>
3433
/// <param name="transportOptions">Configuration options for the transport.</param>
3534
/// <param name="httpClient">The HTTP client instance used for requests.</param>
3635
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
37-
public SseClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory)
36+
/// <param name="endpointName">The endpoint name used for logging purposes.</param>
37+
public SseClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
3838
: base(loggerFactory)
3939
{
4040
Throw.IfNull(transportOptions);
@@ -46,6 +46,7 @@ public SseClientSessionTransport(SseClientTransportOptions transportOptions, Htt
4646
_connectionCts = new CancellationTokenSource();
4747
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
4848
_connectionEstablished = new TaskCompletionSource<bool>();
49+
_endpointName = endpointName;
4950
}
5051

5152
/// <inheritdoc/>
@@ -55,14 +56,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
5556
{
5657
if (IsConnected)
5758
{
58-
_logger.TransportAlreadyConnected(EndpointName);
59+
_logger.TransportAlreadyConnected(_endpointName);
5960
throw new McpTransportException("Transport is already connected");
6061
}
6162

6263
// Start message receiving loop
6364
_receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
6465

65-
_logger.TransportReadingMessages(EndpointName);
66+
_logger.TransportReadingMessages(_endpointName);
6667

6768
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
6869
}
@@ -73,7 +74,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
7374
}
7475
catch (Exception ex)
7576
{
76-
_logger.TransportConnectFailed(EndpointName, ex);
77+
_logger.TransportConnectFailed(_endpointName, ex);
7778
await CloseAsync().ConfigureAwait(false);
7879
throw new McpTransportException("Failed to connect transport", ex);
7980
}
@@ -116,29 +117,29 @@ public override async Task SendMessageAsync(
116117
// If the response is not a JSON-RPC response, it is an SSE message
117118
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
118119
{
119-
_logger.SSETransportPostAccepted(EndpointName, messageId);
120+
_logger.SSETransportPostAccepted(_endpointName, messageId);
120121
// The response will arrive as an SSE message
121122
}
122123
else
123124
{
124125
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, McpJsonUtilities.JsonContext.Default.JsonRpcResponse) ??
125126
throw new McpTransportException("Failed to initialize client");
126127

127-
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
128+
_logger.TransportReceivedMessageParsed(_endpointName, messageId);
128129
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
129-
_logger.TransportMessageWritten(EndpointName, messageId);
130+
_logger.TransportMessageWritten(_endpointName, messageId);
130131
}
131132
return;
132133
}
133134

134135
// Otherwise, check if the response was accepted (the response will come as an SSE message)
135136
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
136137
{
137-
_logger.SSETransportPostAccepted(EndpointName, messageId);
138+
_logger.SSETransportPostAccepted(_endpointName, messageId);
138139
}
139140
else
140141
{
141-
_logger.SSETransportPostNotAccepted(EndpointName, messageId, responseContent);
142+
_logger.SSETransportPostNotAccepted(_endpointName, messageId, responseContent);
142143
throw new McpTransportException("Failed to send message");
143144
}
144145
}
@@ -216,17 +217,17 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
216217
}
217218
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
218219
{
219-
_logger.TransportReadMessagesCancelled(EndpointName);
220+
_logger.TransportReadMessagesCancelled(_endpointName);
220221
// Normal shutdown
221222
}
222223
catch (IOException) when (cancellationToken.IsCancellationRequested)
223224
{
224-
_logger.TransportReadMessagesCancelled(EndpointName);
225+
_logger.TransportReadMessagesCancelled(_endpointName);
225226
// Normal shutdown
226227
}
227228
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
228229
{
229-
_logger.TransportConnectionError(EndpointName, ex);
230+
_logger.TransportConnectionError(_endpointName, ex);
230231

231232
reconnectAttempts++;
232233
if (reconnectAttempts >= _options.MaxReconnectAttempts)
@@ -245,7 +246,7 @@ private async Task ProcessSseMessage(string data, CancellationToken cancellation
245246
{
246247
if (!IsConnected)
247248
{
248-
_logger.TransportMessageReceivedBeforeConnected(EndpointName, data);
249+
_logger.TransportMessageReceivedBeforeConnected(_endpointName, data);
249250
return;
250251
}
251252

@@ -254,7 +255,7 @@ private async Task ProcessSseMessage(string data, CancellationToken cancellation
254255
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage);
255256
if (message == null)
256257
{
257-
_logger.TransportMessageParseUnexpectedType(EndpointName, data);
258+
_logger.TransportMessageParseUnexpectedType(_endpointName, data);
258259
return;
259260
}
260261

@@ -264,13 +265,13 @@ private async Task ProcessSseMessage(string data, CancellationToken cancellation
264265
messageId = messageWithId.Id.ToString();
265266
}
266267

267-
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
268+
_logger.TransportReceivedMessageParsed(_endpointName, messageId);
268269
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
269-
_logger.TransportMessageWritten(EndpointName, messageId);
270+
_logger.TransportMessageWritten(_endpointName, messageId);
270271
}
271272
catch (JsonException ex)
272273
{
273-
_logger.TransportMessageParseFailed(EndpointName, data, ex);
274+
_logger.TransportMessageParseFailed(_endpointName, data, ex);
274275
}
275276
}
276277

@@ -280,7 +281,7 @@ private void HandleEndpointEvent(string data)
280281
{
281282
if (string.IsNullOrEmpty(data))
282283
{
283-
_logger.TransportEndpointEventInvalid(EndpointName, data);
284+
_logger.TransportEndpointEventInvalid(_endpointName, data);
284285
return;
285286
}
286287

@@ -308,7 +309,7 @@ private void HandleEndpointEvent(string data)
308309
}
309310
catch (JsonException ex)
310311
{
311-
_logger.TransportEndpointEventParseFailed(EndpointName, data, ex);
312+
_logger.TransportEndpointEventParseFailed(_endpointName, data, ex);
312313
throw new McpTransportException("Failed to parse endpoint event", ex);
313314
}
314315
}

src/ModelContextProtocol/Protocol/Transport/SseClientTransport.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,16 @@ public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient
4141
_httpClient = httpClient;
4242
_loggerFactory = loggerFactory;
4343
_ownsHttpClient = ownsHttpClient;
44+
EndpointName = $"Client (SSE) for ({transportOptions.Description ?? transportOptions.Endpoint.ToString()})";
4445
}
4546

4647
/// <inheritdoc />
47-
public string EndpointName => $"Client (SSE) for ({_options.Id}: {_options.Name})";
48+
public string EndpointName { get; }
4849

4950
/// <inheritdoc />
5051
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
5152
{
52-
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory);
53+
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, EndpointName);
5354

5455
try
5556
{

0 commit comments

Comments
 (0)