SSE ClientTransport fixes and tests (#27)
SSE fixes Additional SSE integration tests with everything server
This commit is contained in:
parent
89c4913ed7
commit
30f3f192ca
5 changed files with 381 additions and 81 deletions
|
|
@ -9,10 +9,10 @@
|
|||
<PropertyGroup>
|
||||
<!-- license and package properties -->
|
||||
<PackageId>McpDotNet.Extensions.AI</PackageId>
|
||||
<Version>1.0.1.1</Version>
|
||||
<Version>1.0.1.3</Version>
|
||||
<Authors>PederHP</Authors>
|
||||
<Description>Microsoft.Extensions.AI integration for the Model Context Protocol (MCP). Enables seamless use of MCP tools as AI functions in any IChatClient implementation.</Description>
|
||||
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet/src/McpDotNet.Extensions.AI</PackageProjectUrl>
|
||||
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet/tree/main/src/McpDotNet.Extensions.AI</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/PederHP/mcpdotnet</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>mcp;ai;microsoft-extensions-ai;chatclient;llm</PackageTags>
|
||||
|
|
@ -26,6 +26,10 @@
|
|||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="McpDotNet.Extensions.AI.Tests" />
|
||||
</ItemGroup>
|
||||
|
|
@ -33,7 +37,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="9.3.0-preview.1.25114.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="9.3.0-preview.1.25114.11" />
|
||||
<PackageReference Include="mcpdotnet" Version="1.0.1.1" />
|
||||
<PackageReference Include="mcpdotnet" Version="1.0.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ public class McpSessionScope : IAsyncDisposable
|
|||
/// <summary>
|
||||
/// The list of tools exposed by the servers in this scope.
|
||||
/// </summary>
|
||||
public IList<AITool> Tools { get; private set; }
|
||||
public IList<AITool> Tools { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The server instructions provided by the servers in this scope.
|
||||
|
|
@ -84,7 +84,7 @@ public class McpSessionScope : IAsyncDisposable
|
|||
return client;
|
||||
}
|
||||
|
||||
/// </inheritdoc>
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
|
|
@ -480,4 +480,25 @@ internal static partial class Log
|
|||
this ILogger logger,
|
||||
string endpointName,
|
||||
Exception e);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 7018,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "SSE transport POST accepted for {endpointName} with message ID {messageId}"
|
||||
)]
|
||||
public static partial void SSETransportPostAccepted(
|
||||
this ILogger logger,
|
||||
string endpointName,
|
||||
string messageId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 7019,
|
||||
Level = LogLevel.Error,
|
||||
Message = "SSE transport POST not accepted for {endpointName} with message ID {messageId} and server response {responseContent}"
|
||||
)]
|
||||
public static partial void SSETransportPostNotAccepted(
|
||||
this ILogger logger,
|
||||
string endpointName,
|
||||
string messageId,
|
||||
string responseContent);
|
||||
}
|
||||
|
|
@ -99,6 +99,13 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
"application/json"
|
||||
);
|
||||
|
||||
string messageId = "(no id)";
|
||||
RequestId? requestId = null;
|
||||
if (message is IJsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
|
||||
var response = await _httpClient.PostAsync(
|
||||
_messageEndpoint,
|
||||
content,
|
||||
|
|
@ -107,31 +114,40 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Handle notifications, which don't have responses
|
||||
if (message is JsonRpcNotification)
|
||||
// Check if the message was an initialize request
|
||||
if (message is JsonRpcRequest request && request.Method == "initialize")
|
||||
{
|
||||
await HandleNotificationResponseAsync(message, responseContent, cancellationToken);
|
||||
// If the response is not a JSON-RPC response, it is an SSE message
|
||||
if (responseContent.ToLowerInvariant() == "accepted")
|
||||
{
|
||||
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
||||
// The response will arrive as an SSE message
|
||||
}
|
||||
else
|
||||
{
|
||||
JsonRpcResponse initializeResponse = JsonSerializer.Deserialize<JsonRpcResponse>(responseContent, _jsonOptions);
|
||||
if (initializeResponse == null)
|
||||
{
|
||||
throw new McpTransportException("Failed to initialize client");
|
||||
}
|
||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||
await WriteMessageAsync(initializeResponse, cancellationToken);
|
||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var responseMessage = JsonSerializer.Deserialize<IJsonRpcMessage>(responseContent, _jsonOptions);
|
||||
if (responseMessage != null)
|
||||
// Otherwise, check if the response was accepted (the response will come as an SSE message)
|
||||
if (responseContent.ToLowerInvariant() == "accepted")
|
||||
{
|
||||
string messageId = "(no id)";
|
||||
if (responseMessage is IJsonRpcMessageWithId messageWithId)
|
||||
{
|
||||
messageId = messageWithId.Id.ToString();
|
||||
}
|
||||
_logger.TransportReceivedMessageParsed(EndpointName, messageId);
|
||||
await WriteMessageAsync(responseMessage, cancellationToken);
|
||||
_logger.TransportMessageWritten(EndpointName, messageId);
|
||||
_logger.SSETransportPostAccepted(EndpointName, messageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.TransportMessageParseUnexpectedType(EndpointName, responseContent);
|
||||
_logger.SSETransportPostNotAccepted(EndpointName, messageId, responseContent);
|
||||
throw new McpTransportException("Failed to send message");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,28 +170,6 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task HandleNotificationResponseAsync(
|
||||
IJsonRpcMessage message,
|
||||
string responseContent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for error response, but don't require one
|
||||
try
|
||||
{
|
||||
var errorResponse = JsonSerializer.Deserialize<JsonRpcError>(responseContent, _jsonOptions);
|
||||
if (errorResponse is not null)
|
||||
{
|
||||
await WriteMessageAsync(errorResponse, cancellationToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore deserialization errors for notifications
|
||||
// This is expected as most notifications won't have responses
|
||||
}
|
||||
}
|
||||
|
||||
internal Uri? MessageEndpoint => _messageEndpoint;
|
||||
|
||||
internal SseClientTransportOptions Options => _options;
|
||||
|
|
@ -207,7 +201,7 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
|
||||
string? currentEvent = null;
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
|
||||
{
|
||||
if (line.StartsWith("event: "))
|
||||
{
|
||||
|
|
@ -227,6 +221,16 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
||||
// Normal shutdown
|
||||
}
|
||||
catch (IOException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.TransportReadMessagesCancelled(EndpointName);
|
||||
// Normal shutdown
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.TransportConnectionError(EndpointName, ex);
|
||||
|
|
@ -279,14 +283,31 @@ public sealed class SseClientTransport : TransportBase, IClientTransport
|
|||
{
|
||||
try
|
||||
{
|
||||
var endpointData = JsonSerializer.Deserialize<EndpointEventData>(data, _jsonOptions);
|
||||
if (endpointData?.Uri == null)
|
||||
if (string.IsNullOrEmpty(data))
|
||||
{
|
||||
_logger.TransportEndpointEventInvalid(EndpointName, data);
|
||||
return;
|
||||
}
|
||||
|
||||
_messageEndpoint = new Uri(endpointData.Uri);
|
||||
// Check if data is absolute URI
|
||||
if (data.ToLowerInvariant().StartsWith("http://") || data.ToLowerInvariant().StartsWith("https://"))
|
||||
{
|
||||
// Since the endpoint is an absolute URI, we can use it directly
|
||||
_messageEndpoint = new Uri(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If the endpoint is a relative URI, we need to combine it with the relative path of the SSE endpoint
|
||||
var hostUrl = _sseEndpoint.AbsoluteUri;
|
||||
if (hostUrl.EndsWith("/sse"))
|
||||
hostUrl = hostUrl[..^4];
|
||||
|
||||
var endpointUri = hostUrl + data;
|
||||
|
||||
_messageEndpoint = new Uri(endpointUri);
|
||||
}
|
||||
|
||||
// Set connected state
|
||||
SetConnected(true);
|
||||
_connectionEstablished.TrySetResult(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<PropertyGroup>
|
||||
<!-- license and package properties -->
|
||||
<PackageId>mcpdotnet</PackageId>
|
||||
<Version>1.0.1.1</Version>
|
||||
<Version>1.0.1.3</Version>
|
||||
<Authors>PederHP</Authors>
|
||||
<Description>.NET client library for the Model Context Protocol (MCP)</Description>
|
||||
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl>
|
||||
|
|
|
|||
Loading…
Reference in a new issue