Skip to content

Commit 5640270

Browse files
Merge branch 'main' into code-coverage
2 parents 0d0b2eb + 729e688 commit 5640270

File tree

9 files changed

+335
-128
lines changed

9 files changed

+335
-128
lines changed

README.MD

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,10 @@ For more information about MCP:
2020
## Getting Started (Client)
2121

2222
To get started writing a client, the `McpClientFactory.CreateAsync` method is used to instantiate and connect an `IMcpClient`
23-
to a server, with details about the client and server specified in `McpClientOptions` and `McpServerConfig` objects.
24-
Once you have an `IMcpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
23+
to a server. Once you have an `IMcpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
2524

2625
```csharp
27-
McpClientOptions options = new()
28-
{
29-
ClientInfo = new() { Name = "TestClient", Version = "1.0.0" }
30-
};
31-
32-
McpServerConfig config = new()
26+
var client = await McpClientFactory.CreateAsync(new()
3327
{
3428
Id = "everything",
3529
Name = "Everything",
@@ -39,9 +33,7 @@ McpServerConfig config = new()
3933
["command"] = "npx",
4034
["arguments"] = "-y @modelcontextprotocol/server-everything",
4135
}
42-
};
43-
44-
var client = await McpClientFactory.CreateAsync(config, options);
36+
});
4537

4638
// Print the list of tools available from the server.
4739
await foreach (var tool in client.ListToolsAsync())

samples/ChatWithTools/Program.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515
{
1616
["command"] = "npx", ["arguments"] = "-y @modelcontextprotocol/server-everything",
1717
}
18-
},
19-
new() { ClientInfo = new() { Name = "ChatClient", Version = "1.0.0" } });
18+
});
2019

2120
// Get all available tools
2221
Console.WriteLine("Tools available:");

src/ModelContextProtocol/Client/McpClientFactory.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,36 @@
66
using ModelContextProtocol.Utils;
77
using Microsoft.Extensions.Logging;
88
using Microsoft.Extensions.Logging.Abstractions;
9+
using System.Reflection;
910

1011
namespace ModelContextProtocol.Client;
1112

1213
/// <summary>Provides factory methods for creating MCP clients.</summary>
1314
public static class McpClientFactory
1415
{
16+
/// <summary>Default client options to use when none are supplied.</summary>
17+
private static readonly McpClientOptions s_defaultClientOptions = CreateDefaultClientOptions();
18+
19+
/// <summary>Creates default client options to use when no options are supplied.</summary>
20+
private static McpClientOptions CreateDefaultClientOptions()
21+
{
22+
var asmName = (Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()).GetName();
23+
return new()
24+
{
25+
ClientInfo = new()
26+
{
27+
Name = asmName.Name ?? "McpClient",
28+
Version = asmName.Version?.ToString() ?? "1.0.0",
29+
},
30+
};
31+
}
32+
1533
/// <summary>Creates an <see cref="IMcpClient"/>, connecting it to the specified server.</summary>
1634
/// <param name="serverConfig">Configuration for the target server to which the client should connect.</param>
17-
/// <param name="clientOptions">A client configuration object which specifies client capabilities and protocol version.</param>
35+
/// <param name="clientOptions">
36+
/// A client configuration object which specifies client capabilities and protocol version.
37+
/// If <see langword="null"/>, details based on the current process will be employed.
38+
/// </param>
1839
/// <param name="createTransportFunc">An optional factory method which returns transport implementations based on a server configuration.</param>
1940
/// <param name="loggerFactory">A logger factory for creating loggers for clients.</param>
2041
/// <param name="cancellationToken">A token to cancel the operation.</param>
@@ -25,14 +46,14 @@ public static class McpClientFactory
2546
/// <exception cref="InvalidOperationException"><paramref name="createTransportFunc"/> returns an invalid transport.</exception>
2647
public static async Task<IMcpClient> CreateAsync(
2748
McpServerConfig serverConfig,
28-
McpClientOptions clientOptions,
49+
McpClientOptions? clientOptions = null,
2950
Func<McpServerConfig, ILoggerFactory?, IClientTransport>? createTransportFunc = null,
3051
ILoggerFactory? loggerFactory = null,
3152
CancellationToken cancellationToken = default)
3253
{
3354
Throw.IfNull(serverConfig);
34-
Throw.IfNull(clientOptions);
3555

56+
clientOptions ??= s_defaultClientOptions;
3657
createTransportFunc ??= CreateTransport;
3758

3859
string endpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";

src/ModelContextProtocol/Logging/Log.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ internal static partial class Log
114114
internal static partial void TransportNotConnected(this ILogger logger, string endpointName);
115115

116116
[LoggerMessage(Level = LogLevel.Information, Message = "Transport sending message for {endpointName} with ID {messageId}, JSON {json}")]
117-
internal static partial void TransportSendingMessage(this ILogger logger, string endpointName, string messageId, string json);
117+
internal static partial void TransportSendingMessage(this ILogger logger, string endpointName, string messageId, string? json = null);
118118

119119
[LoggerMessage(Level = LogLevel.Information, Message = "Transport message sent for {endpointName} with ID {messageId}")]
120120
internal static partial void TransportSentMessage(this ILogger logger, string endpointName, string messageId);
@@ -347,4 +347,35 @@ public static partial void SSETransportPostNotAccepted(
347347
string endpointName,
348348
string messageId,
349349
string responseContent);
350+
351+
/// <summary>
352+
/// Logs the byte representation of a message in UTF-8 encoding.
353+
/// </summary>
354+
/// <param name="logger">The logger to use.</param>
355+
/// <param name="endpointName">The name of the endpoint.</param>
356+
/// <param name="byteRepresentation">The byte representation as a hex string.</param>
357+
[LoggerMessage(EventId = 39000, Level = LogLevel.Trace, Message = "Transport {EndpointName}: Message bytes (UTF-8): {ByteRepresentation}")]
358+
private static partial void TransportMessageBytes(this ILogger logger, string endpointName, string byteRepresentation);
359+
360+
/// <summary>
361+
/// Logs the byte representation of a message for diagnostic purposes.
362+
/// This is useful for diagnosing encoding issues with non-ASCII characters.
363+
/// </summary>
364+
/// <param name="logger">The logger to use.</param>
365+
/// <param name="endpointName">The name of the endpoint.</param>
366+
/// <param name="message">The message to log bytes for.</param>
367+
internal static void TransportMessageBytesUtf8(this ILogger logger, string endpointName, string message)
368+
{
369+
if (logger.IsEnabled(LogLevel.Trace))
370+
{
371+
var bytes = System.Text.Encoding.UTF8.GetBytes(message);
372+
var byteRepresentation =
373+
#if NET
374+
Convert.ToHexString(bytes);
375+
#else
376+
BitConverter.ToString(bytes).Replace("-", " ");
377+
#endif
378+
logger.TransportMessageBytes(endpointName, byteRepresentation);
379+
}
380+
}
350381
}

src/ModelContextProtocol/Protocol/Transport/StdioClientTransport.cs

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
using System.Diagnostics;
2-
using System.Text.Json;
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
33
using ModelContextProtocol.Configuration;
44
using ModelContextProtocol.Logging;
55
using ModelContextProtocol.Protocol.Messages;
66
using ModelContextProtocol.Utils;
77
using ModelContextProtocol.Utils.Json;
8-
using Microsoft.Extensions.Logging;
9-
using Microsoft.Extensions.Logging.Abstractions;
8+
using System.Diagnostics;
9+
using System.Text;
10+
using System.Text.Json;
1011

1112
namespace ModelContextProtocol.Protocol.Transport;
1213

@@ -59,6 +60,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
5960

6061
_shutdownCts = new CancellationTokenSource();
6162

63+
UTF8Encoding noBomUTF8 = new(encoderShouldEmitUTF8Identifier: false);
64+
6265
var startInfo = new ProcessStartInfo
6366
{
6467
FileName = _options.Command,
@@ -68,6 +71,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
6871
UseShellExecute = false,
6972
CreateNoWindow = true,
7073
WorkingDirectory = _options.WorkingDirectory ?? Environment.CurrentDirectory,
74+
StandardOutputEncoding = noBomUTF8,
75+
StandardErrorEncoding = noBomUTF8,
76+
#if NET
77+
StandardInputEncoding = noBomUTF8,
78+
#endif
7179
};
7280

7381
if (!string.IsNullOrWhiteSpace(_options.Arguments))
@@ -92,13 +100,35 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
92100
// Set up error logging
93101
_process.ErrorDataReceived += (sender, args) => _logger.TransportError(EndpointName, args.Data ?? "(no data)");
94102

95-
if (!_process.Start())
103+
// We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
104+
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
105+
// StandardInputEncoding doesn't exist on .NET Framework; instead, it always picks
106+
// up the encoding from Console.InputEncoding. As such, when not targeting .NET Core,
107+
// we temporarily change Console.InputEncoding to no-BOM UTF-8 around the Process.Start
108+
// call, to ensure it picks up the correct encoding.
109+
#if NET
110+
_processStarted = _process.Start();
111+
#else
112+
Encoding originalInputEncoding = Console.InputEncoding;
113+
try
114+
{
115+
Console.InputEncoding = noBomUTF8;
116+
_processStarted = _process.Start();
117+
}
118+
finally
119+
{
120+
Console.InputEncoding = originalInputEncoding;
121+
}
122+
#endif
123+
124+
if (!_processStarted)
96125
{
97126
_logger.TransportProcessStartFailed(EndpointName);
98127
throw new McpTransportException("Failed to start MCP server process");
99128
}
129+
100130
_logger.TransportProcessStarted(EndpointName, _process.Id);
101-
_processStarted = true;
131+
102132
_process.BeginErrorReadLine();
103133

104134
// Start reading messages in the background
@@ -134,9 +164,10 @@ public override async Task SendMessageAsync(IJsonRpcMessage message, Cancellatio
134164
{
135165
var json = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
136166
_logger.TransportSendingMessage(EndpointName, id, json);
167+
_logger.TransportMessageBytesUtf8(EndpointName, json);
137168

138-
// Write the message followed by a newline
139-
await _process!.StandardInput.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
169+
// Write the message followed by a newline using our UTF-8 writer
170+
await _process!.StandardInput.WriteLineAsync(json).ConfigureAwait(false);
140171
await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
141172

142173
_logger.TransportSentMessage(EndpointName, id);
@@ -161,12 +192,10 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
161192
{
162193
_logger.TransportEnteringReadMessagesLoop(EndpointName);
163194

164-
using var reader = _process!.StandardOutput;
165-
166-
while (!cancellationToken.IsCancellationRequested && !_process.HasExited)
195+
while (!cancellationToken.IsCancellationRequested && !_process!.HasExited)
167196
{
168197
_logger.TransportWaitingForMessage(EndpointName);
169-
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
198+
var line = await _process.StandardOutput.ReadLineAsync(cancellationToken).ConfigureAwait(false);
170199
if (line == null)
171200
{
172201
_logger.TransportEndOfStream(EndpointName);
@@ -179,6 +208,7 @@ private async Task ReadMessagesAsync(CancellationToken cancellationToken)
179208
}
180209

181210
_logger.TransportReceivedMessage(EndpointName, line);
211+
_logger.TransportMessageBytesUtf8(EndpointName, line);
182212

183213
await ProcessMessageAsync(line, cancellationToken).ConfigureAwait(false);
184214
}
@@ -230,28 +260,27 @@ private async Task ProcessMessageAsync(string line, CancellationToken cancellati
230260
private async Task CleanupAsync(CancellationToken cancellationToken)
231261
{
232262
_logger.TransportCleaningUp(EndpointName);
233-
if (_process != null && _processStarted && !_process.HasExited)
263+
264+
if (_process is Process process && _processStarted && !process.HasExited)
234265
{
235266
try
236267
{
237-
// Try to close stdin to signal the process to exit
238-
_logger.TransportClosingStdin(EndpointName);
239-
_process.StandardInput.Close();
240-
241268
// Wait for the process to exit
242269
_logger.TransportWaitingForShutdown(EndpointName);
243270

244271
// Kill the while process tree because the process may spawn child processes
245272
// and Node.js does not kill its children when it exits properly
246-
_process.KillTree(_options.ShutdownTimeout);
273+
process.KillTree(_options.ShutdownTimeout);
247274
}
248275
catch (Exception ex)
249276
{
250277
_logger.TransportShutdownFailed(EndpointName, ex);
251278
}
252-
253-
_process.Dispose();
254-
_process = null;
279+
finally
280+
{
281+
process.Dispose();
282+
_process = null;
283+
}
255284
}
256285

257286
if (_shutdownCts is { } shutdownCts)
@@ -261,29 +290,30 @@ private async Task CleanupAsync(CancellationToken cancellationToken)
261290
_shutdownCts = null;
262291
}
263292

264-
if (_readTask != null)
293+
if (_readTask is Task readTask)
265294
{
266295
try
267296
{
268297
_logger.TransportWaitingForReadTask(EndpointName);
269-
await _readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
298+
await readTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
270299
}
271300
catch (TimeoutException)
272301
{
273302
_logger.TransportCleanupReadTaskTimeout(EndpointName);
274-
// Continue with cleanup
275303
}
276304
catch (OperationCanceledException)
277305
{
278306
_logger.TransportCleanupReadTaskCancelled(EndpointName);
279-
// Ignore cancellation
280307
}
281308
catch (Exception ex)
282309
{
283310
_logger.TransportCleanupReadTaskFailed(EndpointName, ex);
284311
}
285-
_readTask = null;
286-
_logger.TransportReadTaskCleanedUp(EndpointName);
312+
finally
313+
{
314+
_logger.TransportReadTaskCleanedUp(EndpointName);
315+
_readTask = null;
316+
}
287317
}
288318

289319
SetConnected(false);

0 commit comments

Comments
 (0)