Skip to content

Commit 3e21f35

Browse files
Remove McpServerConfig and have McpClientFactory accept IClientTransport instances directly. (#230)
* Remove McpServerConfig and have McpClientFactory accept IClientTransport instances directly. * Address feedback and use lists when representing stdio arguments. * Use "Name" for transport descriptors and "EndpointName" for MCP clients and servers. * Update code samples. * Fix source file naming
1 parent 8bd9ee8 commit 3e21f35

29 files changed

+315
-527
lines changed

README.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,15 @@ To get started writing a client, the `McpClientFactory.CreateAsync` method is us
3131
to a server. Once you have an `IMcpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
3232

3333
```csharp
34-
var client = await McpClientFactory.CreateAsync(new()
34+
var clientTransport = new StdioClientTransport(new()
3535
{
36-
Id = "everything",
3736
Name = "Everything",
38-
TransportType = TransportTypes.StdIo,
39-
TransportOptions = new()
40-
{
41-
["command"] = "npx",
42-
["arguments"] = "-y @modelcontextprotocol/server-everything",
43-
}
37+
Command = "npx",
38+
Arguments = ["-y", "@modelcontextprotocol/server-everything"],
4439
});
4540

41+
var client = await McpClientFactory.CreateAsync(clientTransport);
42+
4643
// Print the list of tools available from the server.
4744
foreach (var tool in await client.ListToolsAsync())
4845
{

samples/ChatWithTools/Program.cs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,12 @@
66
// Connect to an MCP server
77
Console.WriteLine("Connecting client to MCP 'everything' server");
88
var mcpClient = await McpClientFactory.CreateAsync(
9-
new()
9+
new StdioClientTransport(new()
1010
{
11-
Id = "everything",
11+
Command = "npx",
12+
Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"],
1213
Name = "Everything",
13-
TransportType = TransportTypes.StdIo,
14-
TransportOptions = new()
15-
{
16-
["command"] = "npx", ["arguments"] = "-y @modelcontextprotocol/server-everything",
17-
}
18-
});
14+
}));
1915

2016
// Get all available tools
2117
Console.WriteLine("Tools available:");

samples/QuickstartClient/Program.cs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,15 @@
1313

1414
var (command, arguments) = GetCommandAndArguments(args);
1515

16-
await using var mcpClient = await McpClientFactory.CreateAsync(new()
16+
var clientTransport = new StdioClientTransport(new()
1717
{
18-
Id = "demo-server",
1918
Name = "Demo Server",
20-
TransportType = TransportTypes.StdIo,
21-
TransportOptions = new()
22-
{
23-
["command"] = command,
24-
["arguments"] = arguments,
25-
}
19+
Command = command,
20+
Arguments = arguments,
2621
});
2722

23+
await using var mcpClient = await McpClientFactory.CreateAsync(clientTransport);
24+
2825
var tools = await mcpClient.ListToolsAsync();
2926
foreach (var tool in tools)
3027
{
@@ -86,13 +83,13 @@ static void PromptForInput()
8683
///
8784
/// This method would only be required if you're creating a generic client, such as we use for the quickstart.
8885
/// </remarks>
89-
static (string command, string arguments) GetCommandAndArguments(string[] args)
86+
static (string command, string[] arguments) GetCommandAndArguments(string[] args)
9087
{
9188
return args switch
9289
{
93-
[var script] when script.EndsWith(".py") => ("python", script),
94-
[var script] when script.EndsWith(".js") => ("node", script),
95-
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", $"run --project {script} --no-build"),
96-
_ => ("dotnet", "run --project ../../../../QuickstartWeatherServer --no-build")
90+
[var script] when script.EndsWith(".py") => ("python", args),
91+
[var script] when script.EndsWith(".js") => ("node", args),
92+
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", ["run", "--project", script, "--no-build"]),
93+
_ => ("dotnet", ["run", "--project", "../../../../QuickstartWeatherServer", "--no-build"])
9794
};
9895
}
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/McpClient.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,16 @@ internal sealed class McpClient : McpEndpoint, IMcpClient
3333
/// </summary>
3434
/// <param name="clientTransport">The transport to use for communication with the server.</param>
3535
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
36-
/// <param name="serverConfig">The server configuration.</param>
3736
/// <param name="loggerFactory">The logger factory.</param>
38-
public McpClient(IClientTransport clientTransport, McpClientOptions? options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
37+
public McpClient(IClientTransport clientTransport, McpClientOptions? options, ILoggerFactory? loggerFactory)
3938
: base(loggerFactory)
4039
{
4140
options ??= new();
4241

4342
_clientTransport = clientTransport;
4443
_options = options;
4544

46-
EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
45+
EndpointName = clientTransport.Name;
4746

4847
if (options.Capabilities is { } capabilities)
4948
{

src/ModelContextProtocol/Client/McpClientFactory.cs

Lines changed: 10 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -12,128 +12,39 @@ namespace ModelContextProtocol.Client;
1212
public static class McpClientFactory
1313
{
1414
/// <summary>Creates an <see cref="IMcpClient"/>, connecting it to the specified server.</summary>
15-
/// <param name="serverConfig">Configuration for the target server to which the client should connect.</param>
15+
/// <param name="clientTransport">The transport instance used to communicate with the server.</param>
1616
/// <param name="clientOptions">
1717
/// A client configuration object which specifies client capabilities and protocol version.
1818
/// If <see langword="null"/>, details based on the current process will be employed.
1919
/// </param>
20-
/// <param name="createTransportFunc">An optional factory method which returns transport implementations based on a server configuration.</param>
2120
/// <param name="loggerFactory">A logger factory for creating loggers for clients.</param>
2221
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
2322
/// <returns>An <see cref="IMcpClient"/> that's connected to the specified server.</returns>
24-
/// <exception cref="ArgumentNullException"><paramref name="serverConfig"/> is <see langword="null"/>.</exception>
23+
/// <exception cref="ArgumentNullException"><paramref name="clientTransport"/> is <see langword="null"/>.</exception>
2524
/// <exception cref="ArgumentNullException"><paramref name="clientOptions"/> is <see langword="null"/>.</exception>
26-
/// <exception cref="ArgumentException"><paramref name="serverConfig"/> contains invalid information.</exception>
27-
/// <exception cref="InvalidOperationException"><paramref name="createTransportFunc"/> returns an invalid transport.</exception>
2825
public static async Task<IMcpClient> CreateAsync(
29-
McpServerConfig serverConfig,
26+
IClientTransport clientTransport,
3027
McpClientOptions? clientOptions = null,
31-
Func<McpServerConfig, ILoggerFactory?, IClientTransport>? createTransportFunc = null,
3228
ILoggerFactory? loggerFactory = null,
3329
CancellationToken cancellationToken = default)
3430
{
35-
Throw.IfNull(serverConfig);
36-
37-
createTransportFunc ??= CreateTransport;
38-
39-
string endpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
31+
Throw.IfNull(clientTransport);
4032

33+
string endpointName = clientTransport.Name;
4134
var logger = loggerFactory?.CreateLogger(typeof(McpClientFactory)) ?? NullLogger.Instance;
4235
logger.CreatingClient(endpointName);
4336

44-
var transport =
45-
createTransportFunc(serverConfig, loggerFactory) ??
46-
throw new InvalidOperationException($"{nameof(createTransportFunc)} returned a null transport.");
47-
37+
McpClient client = new(clientTransport, clientOptions, loggerFactory);
4838
try
4939
{
50-
McpClient client = new(transport, clientOptions, serverConfig, loggerFactory);
51-
try
52-
{
53-
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
54-
logger.ClientCreated(endpointName);
55-
return client;
56-
}
57-
catch
58-
{
59-
await client.DisposeAsync().ConfigureAwait(false);
60-
throw;
61-
}
40+
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
41+
logger.ClientCreated(endpointName);
42+
return client;
6243
}
6344
catch
6445
{
65-
if (transport is IAsyncDisposable asyncDisposableTransport)
66-
{
67-
await asyncDisposableTransport.DisposeAsync().ConfigureAwait(false);
68-
}
69-
else if (transport is IDisposable disposableTransport)
70-
{
71-
disposableTransport.Dispose();
72-
}
46+
await client.DisposeAsync().ConfigureAwait(false);
7347
throw;
7448
}
7549
}
76-
77-
private static IClientTransport CreateTransport(McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
78-
{
79-
if (string.Equals(serverConfig.TransportType, TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase))
80-
{
81-
string? command = serverConfig.TransportOptions?.GetValueOrDefault("command");
82-
if (string.IsNullOrWhiteSpace(command))
83-
{
84-
command = serverConfig.Location;
85-
if (string.IsNullOrWhiteSpace(command))
86-
{
87-
throw new ArgumentException("Command is required for stdio transport.", nameof(serverConfig));
88-
}
89-
}
90-
91-
string? arguments = serverConfig.TransportOptions?.GetValueOrDefault("arguments");
92-
93-
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
94-
serverConfig.TransportType.Equals(TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase) &&
95-
!string.IsNullOrEmpty(command) &&
96-
!string.Equals(Path.GetFileName(command), "cmd.exe", StringComparison.OrdinalIgnoreCase))
97-
{
98-
// On Windows, for stdio, we need to wrap non-shell commands with cmd.exe /c {command} (usually npx or uvicorn).
99-
// The stdio transport will not work correctly if the command is not run in a shell.
100-
arguments = string.IsNullOrWhiteSpace(arguments) ?
101-
$"/c {command}" :
102-
$"/c {command} {arguments}";
103-
command = "cmd.exe";
104-
}
105-
106-
return new StdioClientTransport(new StdioClientTransportOptions
107-
{
108-
Command = command!,
109-
Arguments = arguments,
110-
WorkingDirectory = serverConfig.TransportOptions?.GetValueOrDefault("workingDirectory"),
111-
EnvironmentVariables = serverConfig.TransportOptions?
112-
.Where(kv => kv.Key.StartsWith("env:", StringComparison.Ordinal))
113-
.ToDictionary(kv => kv.Key.Substring("env:".Length), kv => kv.Value),
114-
ShutdownTimeout = TimeSpan.TryParse(serverConfig.TransportOptions?.GetValueOrDefault("shutdownTimeout"), CultureInfo.InvariantCulture, out var timespan) ? timespan : StdioClientTransportOptions.DefaultShutdownTimeout
115-
}, serverConfig, loggerFactory);
116-
}
117-
118-
if (string.Equals(serverConfig.TransportType, TransportTypes.Sse, StringComparison.OrdinalIgnoreCase) ||
119-
string.Equals(serverConfig.TransportType, "http", StringComparison.OrdinalIgnoreCase))
120-
{
121-
return new SseClientTransport(new SseClientTransportOptions
122-
{
123-
ConnectionTimeout = TimeSpan.FromSeconds(ParseInt32OrDefault(serverConfig.TransportOptions, "connectionTimeout", 30)),
124-
MaxReconnectAttempts = ParseInt32OrDefault(serverConfig.TransportOptions, "maxReconnectAttempts", 3),
125-
ReconnectDelay = TimeSpan.FromSeconds(ParseInt32OrDefault(serverConfig.TransportOptions, "reconnectDelay", 5)),
126-
AdditionalHeaders = serverConfig.TransportOptions?
127-
.Where(kv => kv.Key.StartsWith("header.", StringComparison.Ordinal))
128-
.ToDictionary(kv => kv.Key.Substring("header.".Length), kv => kv.Value)
129-
}, serverConfig, loggerFactory);
130-
131-
static int ParseInt32OrDefault(Dictionary<string, string>? options, string key, int defaultValue) =>
132-
options?.TryGetValue(key, out var value) is not true ? defaultValue :
133-
int.TryParse(value, out var result) ? result :
134-
throw new ArgumentException($"Invalid value '{value}' for option '{key}' in transport options.", nameof(serverConfig));
135-
}
136-
137-
throw new ArgumentException($"Unsupported transport type '{serverConfig.TransportType}'.", nameof(serverConfig));
138-
}
13950
}

src/ModelContextProtocol/Configuration/McpServerConfig.cs

Lines changed: 0 additions & 34 deletions
This file was deleted.

src/ModelContextProtocol/ModelContextProtocol.csproj

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

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

0 commit comments

Comments
 (0)