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
This commit is contained in:
parent
8bd9ee81c0
commit
3e21f3594a
29 changed files with 315 additions and 527 deletions
13
README.md
13
README.md
|
|
@ -31,18 +31,15 @@ To get started writing a client, the `McpClientFactory.CreateAsync` method is us
|
||||||
to a server. Once you have an `IMcpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
|
to a server. Once you have an `IMcpClient`, you can interact with it, such as to enumerate all available tools and invoke tools.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var client = await McpClientFactory.CreateAsync(new()
|
var clientTransport = new StdioClientTransport(new()
|
||||||
{
|
{
|
||||||
Id = "everything",
|
|
||||||
Name = "Everything",
|
Name = "Everything",
|
||||||
TransportType = TransportTypes.StdIo,
|
Command = "npx",
|
||||||
TransportOptions = new()
|
Arguments = ["-y", "@modelcontextprotocol/server-everything"],
|
||||||
{
|
|
||||||
["command"] = "npx",
|
|
||||||
["arguments"] = "-y @modelcontextprotocol/server-everything",
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var client = await McpClientFactory.CreateAsync(clientTransport);
|
||||||
|
|
||||||
// Print the list of tools available from the server.
|
// Print the list of tools available from the server.
|
||||||
foreach (var tool in await client.ListToolsAsync())
|
foreach (var tool in await client.ListToolsAsync())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,12 @@ using OpenAI;
|
||||||
// Connect to an MCP server
|
// Connect to an MCP server
|
||||||
Console.WriteLine("Connecting client to MCP 'everything' server");
|
Console.WriteLine("Connecting client to MCP 'everything' server");
|
||||||
var mcpClient = await McpClientFactory.CreateAsync(
|
var mcpClient = await McpClientFactory.CreateAsync(
|
||||||
new()
|
new StdioClientTransport(new()
|
||||||
{
|
{
|
||||||
Id = "everything",
|
Command = "npx",
|
||||||
|
Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"],
|
||||||
Name = "Everything",
|
Name = "Everything",
|
||||||
TransportType = TransportTypes.StdIo,
|
}));
|
||||||
TransportOptions = new()
|
|
||||||
{
|
|
||||||
["command"] = "npx", ["arguments"] = "-y @modelcontextprotocol/server-everything",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all available tools
|
// Get all available tools
|
||||||
Console.WriteLine("Tools available:");
|
Console.WriteLine("Tools available:");
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,15 @@ builder.Configuration
|
||||||
|
|
||||||
var (command, arguments) = GetCommandAndArguments(args);
|
var (command, arguments) = GetCommandAndArguments(args);
|
||||||
|
|
||||||
await using var mcpClient = await McpClientFactory.CreateAsync(new()
|
var clientTransport = new StdioClientTransport(new()
|
||||||
{
|
{
|
||||||
Id = "demo-server",
|
|
||||||
Name = "Demo Server",
|
Name = "Demo Server",
|
||||||
TransportType = TransportTypes.StdIo,
|
Command = command,
|
||||||
TransportOptions = new()
|
Arguments = arguments,
|
||||||
{
|
|
||||||
["command"] = command,
|
|
||||||
["arguments"] = arguments,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await using var mcpClient = await McpClientFactory.CreateAsync(clientTransport);
|
||||||
|
|
||||||
var tools = await mcpClient.ListToolsAsync();
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
foreach (var tool in tools)
|
foreach (var tool in tools)
|
||||||
{
|
{
|
||||||
|
|
@ -86,13 +83,13 @@ static void PromptForInput()
|
||||||
///
|
///
|
||||||
/// This method would only be required if you're creating a generic client, such as we use for the quickstart.
|
/// This method would only be required if you're creating a generic client, such as we use for the quickstart.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
static (string command, string arguments) GetCommandAndArguments(string[] args)
|
static (string command, string[] arguments) GetCommandAndArguments(string[] args)
|
||||||
{
|
{
|
||||||
return args switch
|
return args switch
|
||||||
{
|
{
|
||||||
[var script] when script.EndsWith(".py") => ("python", script),
|
[var script] when script.EndsWith(".py") => ("python", args),
|
||||||
[var script] when script.EndsWith(".js") => ("node", script),
|
[var script] when script.EndsWith(".js") => ("node", args),
|
||||||
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", $"run --project {script} --no-build"),
|
[var script] when Directory.Exists(script) || (File.Exists(script) && script.EndsWith(".csproj")) => ("dotnet", ["run", "--project", script, "--no-build"]),
|
||||||
_ => ("dotnet", "run --project ../../../../QuickstartWeatherServer --no-build")
|
_ => ("dotnet", ["run", "--project", "../../../../QuickstartWeatherServer", "--no-build"])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
101
src/Common/Polyfills/System/PasteArguments.cs
Normal file
101
src/Common/Polyfills/System/PasteArguments.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// Licensed to the .NET Foundation under one or more agreements.
|
||||||
|
// The .NET Foundation licenses this file to you under the MIT license.
|
||||||
|
|
||||||
|
// Copied from:
|
||||||
|
// https://github.com/dotnet/runtime/blob/d2650b6ae7023a2d9d2c74c56116f1f18472ab04/src/libraries/System.Private.CoreLib/src/System/PasteArguments.cs
|
||||||
|
// and changed from using ValueStringBuilder to StringBuilder.
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace System;
|
||||||
|
|
||||||
|
internal static partial class PasteArguments
|
||||||
|
{
|
||||||
|
internal static void AppendArgument(StringBuilder stringBuilder, string argument)
|
||||||
|
{
|
||||||
|
if (stringBuilder.Length != 0)
|
||||||
|
{
|
||||||
|
stringBuilder.Append(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing rules for non-argv[0] arguments:
|
||||||
|
// - Backslash is a normal character except followed by a quote.
|
||||||
|
// - 2N backslashes followed by a quote ==> N literal backslashes followed by unescaped quote
|
||||||
|
// - 2N+1 backslashes followed by a quote ==> N literal backslashes followed by a literal quote
|
||||||
|
// - Parsing stops at first whitespace outside of quoted region.
|
||||||
|
// - (post 2008 rule): A closing quote followed by another quote ==> literal quote, and parsing remains in quoting mode.
|
||||||
|
if (argument.Length != 0 && ContainsNoWhitespaceOrQuotes(argument))
|
||||||
|
{
|
||||||
|
// Simple case - no quoting or changes needed.
|
||||||
|
stringBuilder.Append(argument);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stringBuilder.Append(Quote);
|
||||||
|
int idx = 0;
|
||||||
|
while (idx < argument.Length)
|
||||||
|
{
|
||||||
|
char c = argument[idx++];
|
||||||
|
if (c == Backslash)
|
||||||
|
{
|
||||||
|
int numBackSlash = 1;
|
||||||
|
while (idx < argument.Length && argument[idx] == Backslash)
|
||||||
|
{
|
||||||
|
idx++;
|
||||||
|
numBackSlash++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx == argument.Length)
|
||||||
|
{
|
||||||
|
// We'll emit an end quote after this so must double the number of backslashes.
|
||||||
|
stringBuilder.Append(Backslash, numBackSlash * 2);
|
||||||
|
}
|
||||||
|
else if (argument[idx] == Quote)
|
||||||
|
{
|
||||||
|
// Backslashes will be followed by a quote. Must double the number of backslashes.
|
||||||
|
stringBuilder.Append(Backslash, numBackSlash * 2 + 1);
|
||||||
|
stringBuilder.Append(Quote);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Backslash will not be followed by a quote, so emit as normal characters.
|
||||||
|
stringBuilder.Append(Backslash, numBackSlash);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == Quote)
|
||||||
|
{
|
||||||
|
// Escape the quote so it appears as a literal. This also guarantees that we won't end up generating a closing quote followed
|
||||||
|
// by another quote (which parses differently pre-2008 vs. post-2008.)
|
||||||
|
stringBuilder.Append(Backslash);
|
||||||
|
stringBuilder.Append(Quote);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stringBuilder.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
stringBuilder.Append(Quote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsNoWhitespaceOrQuotes(string s)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
char c = s[i];
|
||||||
|
if (char.IsWhiteSpace(c) || c == Quote)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const char Quote = '\"';
|
||||||
|
private const char Backslash = '\\';
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,8 @@ internal sealed class McpClient : McpEndpoint, IMcpClient
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="clientTransport">The transport to use for communication with the server.</param>
|
/// <param name="clientTransport">The transport to use for communication with the server.</param>
|
||||||
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
|
/// <param name="options">Options for the client, defining protocol version and capabilities.</param>
|
||||||
/// <param name="serverConfig">The server configuration.</param>
|
|
||||||
/// <param name="loggerFactory">The logger factory.</param>
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
public McpClient(IClientTransport clientTransport, McpClientOptions? options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
public McpClient(IClientTransport clientTransport, McpClientOptions? options, ILoggerFactory? loggerFactory)
|
||||||
: base(loggerFactory)
|
: base(loggerFactory)
|
||||||
{
|
{
|
||||||
options ??= new();
|
options ??= new();
|
||||||
|
|
@ -43,7 +42,7 @@ internal sealed class McpClient : McpEndpoint, IMcpClient
|
||||||
_clientTransport = clientTransport;
|
_clientTransport = clientTransport;
|
||||||
_options = options;
|
_options = options;
|
||||||
|
|
||||||
EndpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
|
EndpointName = clientTransport.Name;
|
||||||
|
|
||||||
if (options.Capabilities is { } capabilities)
|
if (options.Capabilities is { } capabilities)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,128 +12,39 @@ namespace ModelContextProtocol.Client;
|
||||||
public static class McpClientFactory
|
public static class McpClientFactory
|
||||||
{
|
{
|
||||||
/// <summary>Creates an <see cref="IMcpClient"/>, connecting it to the specified server.</summary>
|
/// <summary>Creates an <see cref="IMcpClient"/>, connecting it to the specified server.</summary>
|
||||||
/// <param name="serverConfig">Configuration for the target server to which the client should connect.</param>
|
/// <param name="clientTransport">The transport instance used to communicate with the server.</param>
|
||||||
/// <param name="clientOptions">
|
/// <param name="clientOptions">
|
||||||
/// A client configuration object which specifies client capabilities and protocol version.
|
/// A client configuration object which specifies client capabilities and protocol version.
|
||||||
/// If <see langword="null"/>, details based on the current process will be employed.
|
/// If <see langword="null"/>, details based on the current process will be employed.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="createTransportFunc">An optional factory method which returns transport implementations based on a server configuration.</param>
|
|
||||||
/// <param name="loggerFactory">A logger factory for creating loggers for clients.</param>
|
/// <param name="loggerFactory">A logger factory for creating loggers for clients.</param>
|
||||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||||
/// <returns>An <see cref="IMcpClient"/> that's connected to the specified server.</returns>
|
/// <returns>An <see cref="IMcpClient"/> that's connected to the specified server.</returns>
|
||||||
/// <exception cref="ArgumentNullException"><paramref name="serverConfig"/> is <see langword="null"/>.</exception>
|
/// <exception cref="ArgumentNullException"><paramref name="clientTransport"/> is <see langword="null"/>.</exception>
|
||||||
/// <exception cref="ArgumentNullException"><paramref name="clientOptions"/> is <see langword="null"/>.</exception>
|
/// <exception cref="ArgumentNullException"><paramref name="clientOptions"/> is <see langword="null"/>.</exception>
|
||||||
/// <exception cref="ArgumentException"><paramref name="serverConfig"/> contains invalid information.</exception>
|
|
||||||
/// <exception cref="InvalidOperationException"><paramref name="createTransportFunc"/> returns an invalid transport.</exception>
|
|
||||||
public static async Task<IMcpClient> CreateAsync(
|
public static async Task<IMcpClient> CreateAsync(
|
||||||
McpServerConfig serverConfig,
|
IClientTransport clientTransport,
|
||||||
McpClientOptions? clientOptions = null,
|
McpClientOptions? clientOptions = null,
|
||||||
Func<McpServerConfig, ILoggerFactory?, IClientTransport>? createTransportFunc = null,
|
|
||||||
ILoggerFactory? loggerFactory = null,
|
ILoggerFactory? loggerFactory = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Throw.IfNull(serverConfig);
|
Throw.IfNull(clientTransport);
|
||||||
|
|
||||||
createTransportFunc ??= CreateTransport;
|
|
||||||
|
|
||||||
string endpointName = $"Client ({serverConfig.Id}: {serverConfig.Name})";
|
|
||||||
|
|
||||||
|
string endpointName = clientTransport.Name;
|
||||||
var logger = loggerFactory?.CreateLogger(typeof(McpClientFactory)) ?? NullLogger.Instance;
|
var logger = loggerFactory?.CreateLogger(typeof(McpClientFactory)) ?? NullLogger.Instance;
|
||||||
logger.CreatingClient(endpointName);
|
logger.CreatingClient(endpointName);
|
||||||
|
|
||||||
var transport =
|
McpClient client = new(clientTransport, clientOptions, loggerFactory);
|
||||||
createTransportFunc(serverConfig, loggerFactory) ??
|
|
||||||
throw new InvalidOperationException($"{nameof(createTransportFunc)} returned a null transport.");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
McpClient client = new(transport, clientOptions, serverConfig, loggerFactory);
|
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
logger.ClientCreated(endpointName);
|
||||||
{
|
return client;
|
||||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
logger.ClientCreated(endpointName);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await client.DisposeAsync().ConfigureAwait(false);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
if (transport is IAsyncDisposable asyncDisposableTransport)
|
await client.DisposeAsync().ConfigureAwait(false);
|
||||||
{
|
|
||||||
await asyncDisposableTransport.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
else if (transport is IDisposable disposableTransport)
|
|
||||||
{
|
|
||||||
disposableTransport.Dispose();
|
|
||||||
}
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IClientTransport CreateTransport(McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
|
||||||
{
|
|
||||||
if (string.Equals(serverConfig.TransportType, TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
string? command = serverConfig.TransportOptions?.GetValueOrDefault("command");
|
|
||||||
if (string.IsNullOrWhiteSpace(command))
|
|
||||||
{
|
|
||||||
command = serverConfig.Location;
|
|
||||||
if (string.IsNullOrWhiteSpace(command))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Command is required for stdio transport.", nameof(serverConfig));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string? arguments = serverConfig.TransportOptions?.GetValueOrDefault("arguments");
|
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
|
||||||
serverConfig.TransportType.Equals(TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!string.IsNullOrEmpty(command) &&
|
|
||||||
!string.Equals(Path.GetFileName(command), "cmd.exe", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// On Windows, for stdio, we need to wrap non-shell commands with cmd.exe /c {command} (usually npx or uvicorn).
|
|
||||||
// The stdio transport will not work correctly if the command is not run in a shell.
|
|
||||||
arguments = string.IsNullOrWhiteSpace(arguments) ?
|
|
||||||
$"/c {command}" :
|
|
||||||
$"/c {command} {arguments}";
|
|
||||||
command = "cmd.exe";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StdioClientTransport(new StdioClientTransportOptions
|
|
||||||
{
|
|
||||||
Command = command!,
|
|
||||||
Arguments = arguments,
|
|
||||||
WorkingDirectory = serverConfig.TransportOptions?.GetValueOrDefault("workingDirectory"),
|
|
||||||
EnvironmentVariables = serverConfig.TransportOptions?
|
|
||||||
.Where(kv => kv.Key.StartsWith("env:", StringComparison.Ordinal))
|
|
||||||
.ToDictionary(kv => kv.Key.Substring("env:".Length), kv => kv.Value),
|
|
||||||
ShutdownTimeout = TimeSpan.TryParse(serverConfig.TransportOptions?.GetValueOrDefault("shutdownTimeout"), CultureInfo.InvariantCulture, out var timespan) ? timespan : StdioClientTransportOptions.DefaultShutdownTimeout
|
|
||||||
}, serverConfig, loggerFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(serverConfig.TransportType, TransportTypes.Sse, StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(serverConfig.TransportType, "http", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return new SseClientTransport(new SseClientTransportOptions
|
|
||||||
{
|
|
||||||
ConnectionTimeout = TimeSpan.FromSeconds(ParseInt32OrDefault(serverConfig.TransportOptions, "connectionTimeout", 30)),
|
|
||||||
MaxReconnectAttempts = ParseInt32OrDefault(serverConfig.TransportOptions, "maxReconnectAttempts", 3),
|
|
||||||
ReconnectDelay = TimeSpan.FromSeconds(ParseInt32OrDefault(serverConfig.TransportOptions, "reconnectDelay", 5)),
|
|
||||||
AdditionalHeaders = serverConfig.TransportOptions?
|
|
||||||
.Where(kv => kv.Key.StartsWith("header.", StringComparison.Ordinal))
|
|
||||||
.ToDictionary(kv => kv.Key.Substring("header.".Length), kv => kv.Value)
|
|
||||||
}, serverConfig, loggerFactory);
|
|
||||||
|
|
||||||
static int ParseInt32OrDefault(Dictionary<string, string>? options, string key, int defaultValue) =>
|
|
||||||
options?.TryGetValue(key, out var value) is not true ? defaultValue :
|
|
||||||
int.TryParse(value, out var result) ? result :
|
|
||||||
throw new ArgumentException($"Invalid value '{value}' for option '{key}' in transport options.", nameof(serverConfig));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ArgumentException($"Unsupported transport type '{serverConfig.TransportType}'.", nameof(serverConfig));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
namespace ModelContextProtocol;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration for an MCP server connection.
|
|
||||||
/// This is passed to the client factory to create a client for a specific server.
|
|
||||||
/// </summary>
|
|
||||||
public record McpServerConfig
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Unique identifier for this server configuration.
|
|
||||||
/// </summary>
|
|
||||||
public required string Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Display name for the server.
|
|
||||||
/// </summary>
|
|
||||||
public required string Name { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The type of transport to use.
|
|
||||||
/// </summary>
|
|
||||||
public required string TransportType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// For stdio transport: path to the executable
|
|
||||||
/// For HTTP transport: base URL of the server
|
|
||||||
/// </summary>
|
|
||||||
public string? Location { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Additional transport-specific configuration.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, string>? TransportOptions { get; init; }
|
|
||||||
}
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<PackageId>ModelContextProtocol</PackageId>
|
<PackageId>ModelContextProtocol</PackageId>
|
||||||
<Description>.NET SDK for the Model Context Protocol (MCP)</Description>
|
<Description>.NET SDK for the Model Context Protocol (MCP)</Description>
|
||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
|
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IClientTransport
|
public interface IClientTransport
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies a transport identifier used for logging purposes.
|
||||||
|
/// </summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Asynchronously establishes a transport session with an MCP server and returns an interface for the duplex JSON-RPC message stream.
|
/// Asynchronously establishes a transport session with an MCP server and returns an interface for the duplex JSON-RPC message stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ namespace ModelContextProtocol.Protocol.Transport;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SseClientSessionTransport : TransportBase
|
internal sealed class SseClientSessionTransport : TransportBase
|
||||||
{
|
{
|
||||||
|
private readonly string _endpointName;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SseClientTransportOptions _options;
|
private readonly SseClientTransportOptions _options;
|
||||||
private readonly Uri _sseEndpoint;
|
private readonly Uri _sseEndpoint;
|
||||||
|
|
@ -23,33 +24,29 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
private readonly CancellationTokenSource _connectionCts;
|
private readonly CancellationTokenSource _connectionCts;
|
||||||
private Task? _receiveTask;
|
private Task? _receiveTask;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly McpServerConfig _serverConfig;
|
|
||||||
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
private readonly TaskCompletionSource<bool> _connectionEstablished;
|
||||||
|
|
||||||
private string EndpointName => $"Client (SSE) for ({_serverConfig.Id}: {_serverConfig.Name})";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
|
/// SSE transport for client endpoints. Unlike stdio it does not launch a process, but connects to an existing server.
|
||||||
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||||
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
|
|
||||||
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
||||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||||
public SseClientSessionTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory)
|
/// <param name="endpointName">The endpoint name used for logging purposes.</param>
|
||||||
|
public SseClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
|
||||||
: base(loggerFactory)
|
: base(loggerFactory)
|
||||||
{
|
{
|
||||||
Throw.IfNull(transportOptions);
|
Throw.IfNull(transportOptions);
|
||||||
Throw.IfNull(serverConfig);
|
|
||||||
Throw.IfNull(httpClient);
|
Throw.IfNull(httpClient);
|
||||||
|
|
||||||
_options = transportOptions;
|
_options = transportOptions;
|
||||||
_serverConfig = serverConfig;
|
_sseEndpoint = transportOptions.Endpoint;
|
||||||
_sseEndpoint = new Uri(serverConfig.Location!);
|
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_connectionCts = new CancellationTokenSource();
|
_connectionCts = new CancellationTokenSource();
|
||||||
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
|
_logger = (ILogger?)loggerFactory?.CreateLogger<SseClientTransport>() ?? NullLogger.Instance;
|
||||||
_connectionEstablished = new TaskCompletionSource<bool>();
|
_connectionEstablished = new TaskCompletionSource<bool>();
|
||||||
|
_endpointName = endpointName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|
@ -59,14 +56,14 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
{
|
{
|
||||||
if (IsConnected)
|
if (IsConnected)
|
||||||
{
|
{
|
||||||
_logger.TransportAlreadyConnected(EndpointName);
|
_logger.TransportAlreadyConnected(_endpointName);
|
||||||
throw new McpTransportException("Transport is already connected");
|
throw new McpTransportException("Transport is already connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start message receiving loop
|
// Start message receiving loop
|
||||||
_receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
|
_receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
|
||||||
|
|
||||||
_logger.TransportReadingMessages(EndpointName);
|
_logger.TransportReadingMessages(_endpointName);
|
||||||
|
|
||||||
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
|
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +74,7 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.TransportConnectFailed(EndpointName, ex);
|
_logger.TransportConnectFailed(_endpointName, ex);
|
||||||
await CloseAsync().ConfigureAwait(false);
|
await CloseAsync().ConfigureAwait(false);
|
||||||
throw new McpTransportException("Failed to connect transport", ex);
|
throw new McpTransportException("Failed to connect transport", ex);
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +117,7 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
// If the response is not a JSON-RPC response, it is an SSE message
|
// If the response is not a JSON-RPC response, it is an SSE message
|
||||||
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
_logger.SSETransportPostAccepted(_endpointName, messageId);
|
||||||
// The response will arrive as an SSE message
|
// The response will arrive as an SSE message
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -128,9 +125,9 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, McpJsonUtilities.JsonContext.Default.JsonRpcResponse) ??
|
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize(responseContent, McpJsonUtilities.JsonContext.Default.JsonRpcResponse) ??
|
||||||
throw new McpTransportException("Failed to initialize client");
|
throw new McpTransportException("Failed to initialize client");
|
||||||
|
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
_logger.TransportReceivedMessageParsed(_endpointName, messageId);
|
||||||
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
|
await WriteMessageAsync(initializeResponse, cancellationToken).ConfigureAwait(false);
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
_logger.TransportMessageWritten(_endpointName, messageId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -138,11 +135,11 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
// Otherwise, check if the response was accepted (the response will come as an SSE message)
|
// Otherwise, check if the response was accepted (the response will come as an SSE message)
|
||||||
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
if (responseContent.Equals("accepted", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
_logger.SSETransportPostAccepted(_endpointName, messageId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.SSETransportPostNotAccepted(EndpointName, messageId, responseContent);
|
_logger.SSETransportPostNotAccepted(_endpointName, messageId, responseContent);
|
||||||
throw new McpTransportException("Failed to send message");
|
throw new McpTransportException("Failed to send message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,17 +217,17 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
_logger.TransportReadMessagesCancelled(_endpointName);
|
||||||
// Normal shutdown
|
// Normal shutdown
|
||||||
}
|
}
|
||||||
catch (IOException) when (cancellationToken.IsCancellationRequested)
|
catch (IOException) when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
_logger.TransportReadMessagesCancelled(_endpointName);
|
||||||
// Normal shutdown
|
// Normal shutdown
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
_logger.TransportConnectionError(EndpointName, ex);
|
_logger.TransportConnectionError(_endpointName, ex);
|
||||||
|
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
if (reconnectAttempts >= _options.MaxReconnectAttempts)
|
if (reconnectAttempts >= _options.MaxReconnectAttempts)
|
||||||
|
|
@ -249,7 +246,7 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
{
|
{
|
||||||
if (!IsConnected)
|
if (!IsConnected)
|
||||||
{
|
{
|
||||||
_logger.TransportMessageReceivedBeforeConnected(EndpointName, data);
|
_logger.TransportMessageReceivedBeforeConnected(_endpointName, data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,7 +255,7 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage);
|
var message = JsonSerializer.Deserialize(data, McpJsonUtilities.JsonContext.Default.IJsonRpcMessage);
|
||||||
if (message == null)
|
if (message == null)
|
||||||
{
|
{
|
||||||
_logger.TransportMessageParseUnexpectedType(EndpointName, data);
|
_logger.TransportMessageParseUnexpectedType(_endpointName, data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,13 +265,13 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
messageId = messageWithId.Id.ToString();
|
messageId = messageWithId.Id.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
_logger.TransportReceivedMessageParsed(_endpointName, messageId);
|
||||||
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
await WriteMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
_logger.TransportMessageWritten(_endpointName, messageId);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
_logger.TransportMessageParseFailed(EndpointName, data, ex);
|
_logger.TransportMessageParseFailed(_endpointName, data, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +281,7 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(data))
|
if (string.IsNullOrEmpty(data))
|
||||||
{
|
{
|
||||||
_logger.TransportEndpointEventInvalid(EndpointName, data);
|
_logger.TransportEndpointEventInvalid(_endpointName, data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,7 +309,7 @@ internal sealed class SseClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
_logger.TransportEndpointEventParseFailed(EndpointName, data, ex);
|
_logger.TransportEndpointEventParseFailed(_endpointName, data, ex);
|
||||||
throw new McpTransportException("Failed to parse endpoint event", ex);
|
throw new McpTransportException("Failed to parse endpoint event", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ namespace ModelContextProtocol.Protocol.Transport;
|
||||||
public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly SseClientTransportOptions _options;
|
private readonly SseClientTransportOptions _options;
|
||||||
private readonly McpServerConfig _serverConfig;
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILoggerFactory? _loggerFactory;
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
private readonly bool _ownsHttpClient;
|
private readonly bool _ownsHttpClient;
|
||||||
|
|
@ -19,10 +18,9 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||||
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
|
|
||||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||||
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, ILoggerFactory? loggerFactory)
|
public SseClientTransport(SseClientTransportOptions transportOptions, ILoggerFactory? loggerFactory = null)
|
||||||
: this(transportOptions, serverConfig, new HttpClient(), loggerFactory, true)
|
: this(transportOptions, new HttpClient(), loggerFactory, true)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,27 +29,28 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
|
||||||
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
/// The HTTP server can be local or remote, and must support the SSE protocol.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="transportOptions">Configuration options for the transport.</param>
|
/// <param name="transportOptions">Configuration options for the transport.</param>
|
||||||
/// <param name="serverConfig">The configuration object indicating which server to connect to.</param>
|
|
||||||
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
/// <param name="httpClient">The HTTP client instance used for requests.</param>
|
||||||
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
||||||
/// <param name="ownsHttpClient">True to dispose HTTP client on close connection.</param>
|
/// <param name="ownsHttpClient">True to dispose HTTP client on close connection.</param>
|
||||||
public SseClientTransport(SseClientTransportOptions transportOptions, McpServerConfig serverConfig, HttpClient httpClient, ILoggerFactory? loggerFactory, bool ownsHttpClient = false)
|
public SseClientTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory = null, bool ownsHttpClient = false)
|
||||||
{
|
{
|
||||||
Throw.IfNull(transportOptions);
|
Throw.IfNull(transportOptions);
|
||||||
Throw.IfNull(serverConfig);
|
|
||||||
Throw.IfNull(httpClient);
|
Throw.IfNull(httpClient);
|
||||||
|
|
||||||
_options = transportOptions;
|
_options = transportOptions;
|
||||||
_serverConfig = serverConfig;
|
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_ownsHttpClient = ownsHttpClient;
|
_ownsHttpClient = ownsHttpClient;
|
||||||
|
Name = transportOptions.Name ?? transportOptions.Endpoint.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var sessionTransport = new SseClientSessionTransport(_options, _serverConfig, _httpClient, _loggerFactory);
|
var sessionTransport = new SseClientSessionTransport(_options, _httpClient, _loggerFactory, Name);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,36 @@
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record SseClientTransportOptions
|
public record SseClientTransportOptions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The base address of the server for SSE connections.
|
||||||
|
/// </summary>
|
||||||
|
public required Uri Endpoint
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
init
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(value), "Endpoint cannot be null.");
|
||||||
|
}
|
||||||
|
if (!value.IsAbsoluteUri)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Endpoint must be an absolute URI.", nameof(value));
|
||||||
|
}
|
||||||
|
if (value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Endpoint must use HTTP or HTTPS scheme.", nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
field = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies a transport identifier used for logging purposes.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Timeout for initial connection and endpoint event.
|
/// Timeout for initial connection and endpoint event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ModelContextProtocol.Logging;
|
using ModelContextProtocol.Logging;
|
||||||
using ModelContextProtocol.Utils;
|
using ModelContextProtocol.Utils;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
|
||||||
|
|
||||||
|
|
@ -15,33 +17,44 @@ namespace ModelContextProtocol.Protocol.Transport;
|
||||||
public sealed class StdioClientTransport : IClientTransport
|
public sealed class StdioClientTransport : IClientTransport
|
||||||
{
|
{
|
||||||
private readonly StdioClientTransportOptions _options;
|
private readonly StdioClientTransportOptions _options;
|
||||||
private readonly McpServerConfig _serverConfig;
|
|
||||||
private readonly ILoggerFactory? _loggerFactory;
|
private readonly ILoggerFactory? _loggerFactory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="StdioClientTransport"/> class.
|
/// Initializes a new instance of the <see cref="StdioClientTransport"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">Configuration options for the transport.</param>
|
/// <param name="options">Configuration options for the transport.</param>
|
||||||
/// <param name="serverConfig">The server configuration for the transport.</param>
|
|
||||||
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
/// <param name="loggerFactory">A logger factory for creating loggers.</param>
|
||||||
public StdioClientTransport(StdioClientTransportOptions options, McpServerConfig serverConfig, ILoggerFactory? loggerFactory = null)
|
public StdioClientTransport(StdioClientTransportOptions options, ILoggerFactory? loggerFactory = null)
|
||||||
{
|
{
|
||||||
Throw.IfNull(options);
|
Throw.IfNull(options);
|
||||||
Throw.IfNull(serverConfig);
|
|
||||||
|
|
||||||
_options = options;
|
_options = options;
|
||||||
_serverConfig = serverConfig;
|
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
|
Name = options.Name ?? $"stdio-{Regex.Replace(Path.GetFileName(options.Command), @"[\s\.]+", "-")}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
string endpointName = $"Client (stdio) for ({_serverConfig.Id}: {_serverConfig.Name})";
|
string endpointName = Name;
|
||||||
|
|
||||||
Process? process = null;
|
Process? process = null;
|
||||||
bool processStarted = false;
|
bool processStarted = false;
|
||||||
|
|
||||||
|
string command = _options.Command;
|
||||||
|
IList<string>? arguments = _options.Arguments;
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
||||||
|
!string.Equals(Path.GetFileName(command), "cmd.exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// On Windows, for stdio, we need to wrap non-shell commands with cmd.exe /c {command} (usually npx or uvicorn).
|
||||||
|
// The stdio transport will not work correctly if the command is not run in a shell.
|
||||||
|
arguments = arguments is null or [] ? ["/c", command] : ["/c", command, ..arguments];
|
||||||
|
command = "cmd.exe";
|
||||||
|
}
|
||||||
|
|
||||||
ILogger logger = (ILogger?)_loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
ILogger logger = (ILogger?)_loggerFactory?.CreateLogger<StdioClientTransport>() ?? NullLogger.Instance;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -51,7 +64,7 @@ public sealed class StdioClientTransport : IClientTransport
|
||||||
|
|
||||||
ProcessStartInfo startInfo = new()
|
ProcessStartInfo startInfo = new()
|
||||||
{
|
{
|
||||||
FileName = _options.Command,
|
FileName = command,
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
|
|
@ -65,9 +78,22 @@ public sealed class StdioClientTransport : IClientTransport
|
||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_options.Arguments))
|
if (arguments is not null)
|
||||||
{
|
{
|
||||||
startInfo.Arguments = _options.Arguments;
|
#if NET
|
||||||
|
foreach (string arg in arguments)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(arg);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
StringBuilder argsBuilder = new();
|
||||||
|
foreach (string arg in arguments)
|
||||||
|
{
|
||||||
|
PasteArguments.AppendArgument(argsBuilder, arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.Arguments = argsBuilder.ToString();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_options.EnvironmentVariables != null)
|
if (_options.EnvironmentVariables != null)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// Protocol/Transport/StdioTransport.cs
|
namespace ModelContextProtocol.Protocol.Transport;
|
||||||
namespace ModelContextProtocol.Protocol.Transport;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents configuration options for the stdio transport.
|
/// Represents configuration options for the stdio transport.
|
||||||
|
|
@ -14,12 +13,29 @@ public record StdioClientTransportOptions
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The command to execute to start the server process.
|
/// The command to execute to start the server process.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Command { get; set; }
|
public required string Command
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Command cannot be null or empty.", nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
field = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Arguments to pass to the server process.
|
/// Arguments to pass to the server process.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Arguments { get; set; }
|
public IList<string>? Arguments { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies a transport identifier used for logging purposes.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The working directory for the server process.
|
/// The working directory for the server process.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ public sealed class StreamClientTransport : IClientTransport
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => $"in-memory-stream";
|
||||||
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
namespace ModelContextProtocol.Protocol.Transport;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// List all transport types
|
|
||||||
/// </summary>
|
|
||||||
public static class TransportTypes
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the Standard IO transport.
|
|
||||||
/// </summary>
|
|
||||||
public const string StdIo = "stdio";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the ServerSideEvents transport.
|
|
||||||
/// </summary>
|
|
||||||
public const string Sse = "sse";
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ using ModelContextProtocol.Protocol.Messages;
|
||||||
using ModelContextProtocol.Protocol.Transport;
|
using ModelContextProtocol.Protocol.Transport;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Utils;
|
using ModelContextProtocol.Utils;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,13 +234,7 @@ public class McpClientExtensionsTests : LoggedTest
|
||||||
private async Task<IMcpClient> CreateMcpClientForServer()
|
private async Task<IMcpClient> CreateMcpClientForServer()
|
||||||
{
|
{
|
||||||
return await McpClientFactory.CreateAsync(
|
return await McpClientFactory.CreateAsync(
|
||||||
new McpServerConfig()
|
new StreamClientTransport(
|
||||||
{
|
|
||||||
Id = "TestServer",
|
|
||||||
Name = "TestServer",
|
|
||||||
TransportType = "ignored",
|
|
||||||
},
|
|
||||||
createTransportFunc: (_, _) => new StreamClientTransport(
|
|
||||||
serverInput: _clientToServerPipe.Writer.AsStream(),
|
serverInput: _clientToServerPipe.Writer.AsStream(),
|
||||||
serverOutput: _serverToClientPipe.Reader.AsStream(),
|
serverOutput: _serverToClientPipe.Reader.AsStream(),
|
||||||
LoggerFactory),
|
LoggerFactory),
|
||||||
|
|
|
||||||
|
|
@ -14,186 +14,26 @@ public class McpClientFactoryTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_WithInvalidArgs_Throws()
|
public async Task CreateAsync_WithInvalidArgs_Throws()
|
||||||
{
|
{
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>("serverConfig", () => McpClientFactory.CreateAsync((McpServerConfig)null!, cancellationToken: TestContext.Current.CancellationToken));
|
await Assert.ThrowsAsync<ArgumentNullException>("clientTransport", () => McpClientFactory.CreateAsync(null!, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentException>("serverConfig", () => McpClientFactory.CreateAsync(new McpServerConfig()
|
|
||||||
{
|
|
||||||
Name = "name",
|
|
||||||
Id = "id",
|
|
||||||
TransportType = "somethingunsupported",
|
|
||||||
}, cancellationToken: TestContext.Current.CancellationToken));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() => McpClientFactory.CreateAsync(new McpServerConfig()
|
|
||||||
{
|
|
||||||
Name = "name",
|
|
||||||
Id = "id",
|
|
||||||
TransportType = TransportTypes.StdIo,
|
|
||||||
}, createTransportFunc: (_, __) => null!, cancellationToken: TestContext.Current.CancellationToken));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_NullOptions_EntryAssemblyInferred()
|
public async Task CreateAsync_NopTransport_ReturnsClient()
|
||||||
{
|
{
|
||||||
// Arrange
|
|
||||||
var serverConfig = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "test-server",
|
|
||||||
Name = "Test Server",
|
|
||||||
TransportType = TransportTypes.StdIo,
|
|
||||||
Location = "/path/to/server",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await using var client = await McpClientFactory.CreateAsync(
|
await using var client = await McpClientFactory.CreateAsync(
|
||||||
serverConfig,
|
new NopTransport(),
|
||||||
null,
|
|
||||||
(_, __) => new NopTransport(),
|
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.NotNull(client);
|
Assert.NotNull(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_WithValidStdioConfig_CreatesNewClient()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var serverConfig = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "test-server",
|
|
||||||
Name = "Test Server",
|
|
||||||
TransportType = TransportTypes.StdIo,
|
|
||||||
Location = "/path/to/server",
|
|
||||||
TransportOptions = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["arguments"] = "--test arg",
|
|
||||||
["workingDirectory"] = "/working/dir"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await using var client = await McpClientFactory.CreateAsync(
|
|
||||||
serverConfig,
|
|
||||||
createTransportFunc: (_, __) => new NopTransport(),
|
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(client);
|
|
||||||
// We could add more assertions here about the client's configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_WithNoTransportOptions_CreatesNewClient()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var serverConfig = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "test-server",
|
|
||||||
Name = "Test Server",
|
|
||||||
TransportType = TransportTypes.StdIo,
|
|
||||||
Location = "/path/to/server",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await using var client = await McpClientFactory.CreateAsync(
|
|
||||||
serverConfig,
|
|
||||||
createTransportFunc: (_, __) => new NopTransport(),
|
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(client);
|
|
||||||
// We could add more assertions here about the client's configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_WithValidSseConfig_CreatesNewClient()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var serverConfig = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "test-server",
|
|
||||||
Name = "Test Server",
|
|
||||||
TransportType = TransportTypes.Sse,
|
|
||||||
Location = "http://localhost:8080"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await using var client = await McpClientFactory.CreateAsync(
|
|
||||||
serverConfig,
|
|
||||||
createTransportFunc: (_, __) => new NopTransport(),
|
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(client);
|
|
||||||
// We could add more assertions here about the client's configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreateAsync_WithSse_CreatesCorrectTransportOptions()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var serverConfig = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "test-server",
|
|
||||||
Name = "Test Server",
|
|
||||||
TransportType = TransportTypes.Sse,
|
|
||||||
Location = "http://localhost:8080",
|
|
||||||
TransportOptions = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["connectionTimeout"] = "10",
|
|
||||||
["maxReconnectAttempts"] = "2",
|
|
||||||
["reconnectDelay"] = "5",
|
|
||||||
["header.test"] = "the_header_value"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await using var client = await McpClientFactory.CreateAsync(
|
|
||||||
serverConfig,
|
|
||||||
createTransportFunc: (_, __) => new NopTransport(),
|
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(client);
|
|
||||||
// We could add more assertions here about the client's configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("connectionTimeout", "not_a_number")]
|
|
||||||
[InlineData("maxReconnectAttempts", "invalid")]
|
|
||||||
[InlineData("reconnectDelay", "bad_value")]
|
|
||||||
public async Task McpFactory_WithInvalidTransportOptions_ThrowsFormatException(string key, string value)
|
|
||||||
{
|
|
||||||
// arrange
|
|
||||||
var config = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "test-server",
|
|
||||||
Name = "Test Server",
|
|
||||||
TransportType = TransportTypes.Sse,
|
|
||||||
Location = "http://localhost:8080",
|
|
||||||
TransportOptions = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
[key] = value
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// act & assert
|
|
||||||
await Assert.ThrowsAsync<ArgumentException>(() => McpClientFactory.CreateAsync(config, cancellationToken: TestContext.Current.CancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(typeof(NopTransport))]
|
[InlineData(typeof(NopTransport))]
|
||||||
[InlineData(typeof(FailureTransport))]
|
[InlineData(typeof(FailureTransport))]
|
||||||
public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
|
public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var serverConfig = new McpServerConfig
|
|
||||||
{
|
|
||||||
Id = "TestServer",
|
|
||||||
Name = "TestServer",
|
|
||||||
TransportType = "stdio",
|
|
||||||
Location = "test-location"
|
|
||||||
};
|
|
||||||
|
|
||||||
var clientOptions = new McpClientOptions
|
var clientOptions = new McpClientOptions
|
||||||
{
|
{
|
||||||
Capabilities = new ClientCapabilities
|
Capabilities = new ClientCapabilities
|
||||||
|
|
@ -216,10 +56,10 @@ public class McpClientFactoryTests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var clientTransport = (IClientTransport?)Activator.CreateInstance(transportType);
|
var clientTransport = (IClientTransport)Activator.CreateInstance(transportType)!;
|
||||||
IMcpClient? client = null;
|
IMcpClient? client = null;
|
||||||
|
|
||||||
var actionTask = McpClientFactory.CreateAsync(serverConfig, clientOptions, (config, logger) => clientTransport ?? new NopTransport(), new Mock<ILoggerFactory>().Object, CancellationToken.None);
|
var actionTask = McpClientFactory.CreateAsync(clientTransport, clientOptions, new Mock<ILoggerFactory>().Object, CancellationToken.None);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
if (clientTransport is FailureTransport)
|
if (clientTransport is FailureTransport)
|
||||||
|
|
@ -248,6 +88,8 @@ public class McpClientFactoryTests
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => default;
|
public ValueTask DisposeAsync() => default;
|
||||||
|
|
||||||
|
public string Name => "Test Nop Transport";
|
||||||
|
|
||||||
public virtual Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
public virtual Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
switch (message)
|
switch (message)
|
||||||
|
|
|
||||||
|
|
@ -8,41 +8,31 @@ public class ClientIntegrationTestFixture
|
||||||
{
|
{
|
||||||
private ILoggerFactory? _loggerFactory;
|
private ILoggerFactory? _loggerFactory;
|
||||||
|
|
||||||
public McpServerConfig EverythingServerConfig { get; }
|
public StdioClientTransportOptions EverythingServerTransportOptions { get; }
|
||||||
public McpServerConfig TestServerConfig { get; }
|
public StdioClientTransportOptions TestServerTransportOptions { get; }
|
||||||
|
|
||||||
public static IEnumerable<string> ClientIds => ["everything", "test_server"];
|
public static IEnumerable<string> ClientIds => ["everything", "test_server"];
|
||||||
|
|
||||||
public ClientIntegrationTestFixture()
|
public ClientIntegrationTestFixture()
|
||||||
{
|
{
|
||||||
EverythingServerConfig = new()
|
EverythingServerTransportOptions = new()
|
||||||
{
|
{
|
||||||
Id = "everything",
|
Command = "npx",
|
||||||
|
// Change to Arguments = ["mcp-server-everything"] if you want to run the server locally after creating a symlink
|
||||||
|
Arguments = ["-y", "--verbose", "@modelcontextprotocol/server-everything"],
|
||||||
Name = "Everything",
|
Name = "Everything",
|
||||||
TransportType = TransportTypes.StdIo,
|
|
||||||
TransportOptions = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["command"] = "npx",
|
|
||||||
// Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink
|
|
||||||
["arguments"] = "-y --verbose @modelcontextprotocol/server-everything"
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TestServerConfig = new()
|
TestServerTransportOptions = new()
|
||||||
{
|
{
|
||||||
Id = "test_server",
|
Command = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet",
|
||||||
Name = "TestServer",
|
Name = "TestServer",
|
||||||
TransportType = TransportTypes.StdIo,
|
|
||||||
TransportOptions = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["command"] = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet",
|
|
||||||
// Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
TestServerConfig.TransportOptions["arguments"] = "TestServer.dll";
|
// Change to Arguments to "mcp-server-everything" if you want to run the server locally after creating a symlink
|
||||||
|
TestServerTransportOptions.Arguments = ["TestServer.dll"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,10 +42,10 @@ public class ClientIntegrationTestFixture
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IMcpClient> CreateClientAsync(string clientId, McpClientOptions? clientOptions = null) =>
|
public Task<IMcpClient> CreateClientAsync(string clientId, McpClientOptions? clientOptions = null) =>
|
||||||
McpClientFactory.CreateAsync(clientId switch
|
McpClientFactory.CreateAsync(new StdioClientTransport(clientId switch
|
||||||
{
|
{
|
||||||
"everything" => EverythingServerConfig,
|
"everything" => EverythingServerTransportOptions,
|
||||||
"test_server" => TestServerConfig,
|
"test_server" => TestServerTransportOptions,
|
||||||
_ => throw new ArgumentException($"Unknown client ID: {clientId}")
|
_ => throw new ArgumentException($"Unknown client ID: {clientId}")
|
||||||
}, clientOptions, loggerFactory: _loggerFactory);
|
}), clientOptions, loggerFactory: _loggerFactory);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue