Completion support (#15)

This commit is contained in:
Peder Holdgaard Pedersen 2025-01-19 12:59:21 +01:00 • committed by GitHub
commit 107c7acc8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 270 additions and 1 deletions

View file

@ -21,6 +21,7 @@ This library aims to provide a clean, specification-compliant implementation of
- MCP client implementation for .NET applications
- Support for stdio and SSE transports
- Support for all MCP capabilities: Tool, Resource, Prompt, Sampling, Roots
- Support for the Completion utility capability
- Support for pagination and notifications
- Async/await pattern throughout
- Comprehensive logging support

View file

@ -99,6 +99,16 @@ public interface IMcpClient : IAsyncDisposable
/// <returns></returns>
Task<ReadResourceResult> ReadResourceAsync(string uri, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the completion options for a resource or prompt reference and (named) argument.
/// </summary>
/// <param name="reference">A resource (uri) or prompt (name) reference</param>
/// <param name="argumentName">Name of argument. Must be non-null and non-empty.</param>
/// <param name="argumentValue">Value of argument. Must be non-null.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns></returns>
Task<CompleteResult> GetCompletionAsync(Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to a resource on the server.
/// </summary>

View file

@ -264,6 +264,39 @@ internal class McpClient : IMcpClient
).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<CompleteResult> GetCompletionAsync(Reference reference, string argumentName, string argumentValue, CancellationToken cancellationToken = default)
{
if (!reference.Validate(out string validationMessage))
{
_logger.InvalidCompletionReference(_serverConfig.Id, _serverConfig.Name, reference.ToString(), validationMessage);
throw new McpClientException($"Invalid reference: {validationMessage}");
}
if (string.IsNullOrWhiteSpace(argumentName))
{
_logger.InvalidCompletionArgumentName(_serverConfig.Id, _serverConfig.Name, argumentName);
throw new McpClientException("Argument name cannot be null or empty");
}
if (argumentValue is null)
{
_logger.InvalidCompletionArgumentValue(_serverConfig.Id, _serverConfig.Name, argumentValue);
throw new McpClientException("Argument value cannot be null");
}
_logger.GettingCompletion(_serverConfig.Id, _serverConfig.Name, reference.ToString(), argumentName, argumentValue);
return await SendRequestAsync<CompleteResult>(
new JsonRpcRequest
{
Method = "completion/complete",
Params = new Dictionary<string, object?>
{
["ref"] = reference,
["argument"] = new Argument { Name = argumentName, Value = argumentValue }
}
},
cancellationToken
).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default)

View file

@ -322,4 +322,55 @@ internal static partial class Log
string serverName,
string data,
Exception exception);
// trace _logger.InvalidCompletionReference(_serverConfig.Id, _serverConfig.Name, reference, validationMessage);
[LoggerMessage(
EventId = 7004,
Level = LogLevel.Trace,
Message = "Invalid completion reference {reference} for server {serverId} ({serverName}): {validationMessage}"
)]
public static partial void InvalidCompletionReference(
this ILogger logger,
string serverId,
string serverName,
string reference,
string validationMessage);
// trace _logger.InvalidACompletionArgumentName(_serverConfig.Id, _serverConfig.Name, argumentName);
[LoggerMessage(
EventId = 7005,
Level = LogLevel.Trace,
Message = "Invalid completion argument name {argumentName} for server {serverId} ({serverName})"
)]
public static partial void InvalidCompletionArgumentName(
this ILogger logger,
string serverId,
string serverName,
string argumentName);
// trace _logger.InvalidCompletionArgumentValue(_serverConfig.Id, _serverConfig.Name, argumentValue);
[LoggerMessage(
EventId = 7006,
Level = LogLevel.Trace,
Message = "Invalid completion argument value {argumentValue} for server {serverId} ({serverName})"
)]
public static partial void InvalidCompletionArgumentValue(
this ILogger logger,
string serverId,
string serverName,
string argumentValue);
// debug _logger.GettingCompletion(_serverConfig.Id, _serverConfig.Name, reference, argumentName, argumentValue);
[LoggerMessage(
EventId = 7007,
Level = LogLevel.Debug,
Message = "Getting completion for server {serverId} ({serverName}) with reference {reference}, argument name {argumentName}, argument value {argumentValue}"
)]
public static partial void GettingCompletion(
this ILogger logger,
string serverId,
string serverName,
string reference,
string argumentName,
string argumentValue);
}

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace McpDotNet.Protocol.Types;
/// <summary>
/// Used for completion requests to provide additional context for the completion options.
/// </summary>
public class Argument
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}

View file

@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace McpDotNet.Protocol.Types;
/// <summary>
/// Represents a completion object in the server's response.
/// </summary>
public class Completion
{
/// <summary>
/// An array of completion values. Must not exceed 100 items.
/// </summary>
[JsonPropertyName("values")]
public string[] Values { get; set; } = new string[0];
/// <summary>
/// The total number of completion options available. This can exceed the number of values actually sent in the response.
/// </summary>
[JsonPropertyName("total")]
public int? Total { get; set; }
/// <summary>
/// Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.
/// </summary>
[JsonPropertyName("hasMore")]
public bool? HasMore { get; set; }
}

View file

@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
namespace McpDotNet.Protocol.Types;
/// <summary>
/// Represents a reference to a resource or prompt. Umbrella type for both ResourceReference and PromptReference from the spec schema.
/// </summary>
public class Reference
{
/// <summary>
/// The type of content. Can be ref/resource or ref/prompt.
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("uri")]
public string? Uri { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Returns a string representation of the reference.
/// </summary>
public override string ToString()
{
return $"\"{Type}\": \"{Uri ?? Name}\"";
}
/// <summary>
/// Validates the reference object.
/// </summary>
public bool Validate(out string validationMessage)
{
if (Type == "ref/resource")
{
if (string.IsNullOrEmpty(Uri))
{
validationMessage = "Uri is required for ref/resource";
return false;
}
}
else if (Type == "ref/prompt")
{
if (string.IsNullOrEmpty(Name))
{
validationMessage = "Name is required for ref/prompt";
return false;
}
}
else
{
validationMessage = $"Unknown reference type: {Type}";
return false;
}
validationMessage = string.Empty;
return true;
}
}
/// <summary>
/// The server's response to a completion/complete request
/// </summary>
public class CompleteResult
{
[JsonPropertyName("completion")]
public Completion Completion { get; set; } = new Completion();
}

View file

@ -9,7 +9,7 @@
<PropertyGroup>
<!-- license and package properties -->
<PackageId>mcpdotnet</PackageId>
<Version>0.7.0.1</Version>
<Version>0.8.0.1</Version>
<Authors>PederHP</Authors>
<Description>.NET client library for the Model Context Protocol (MCP)</Description>
<PackageProjectUrl>https://github.com/PederHP/mcpdotnet</PackageProjectUrl>

View file

@ -30,6 +30,7 @@ public class IntegrationTestFixture : IDisposable
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 @modelcontextprotocol/server-everything",
}
};

View file

@ -180,6 +180,68 @@ public class IntegrationTests : IClassFixture<IntegrationTestFixture>
Assert.NotNull(result.Contents[0].Blob);
}
/// <summary>
/// Note that as of 19th January 2025, the everything server published to npx does not support prompt completion.
/// However, the prompt completion is implemented in the github.com/modelcontextprotocol/servers repository.
/// You can clone this repo, and build locally then change the config to use a local symlink instead of npx until this is fixed, if you wish to run this test.
/// TransportOptions = new Dictionary<string, string>
/// {
/// ["command"] = "npx",
/// ["arguments"] = "mcp-server-everything" // changed from "-y @modelcontextprotocol/server-everything"
/// }
/// </summary>
[Fact]
public async Task GetCompletion_Stdio_ResourceReference()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var result = await client.GetCompletionAsync(new Reference
{
Type = "ref/resource",
Uri = "test://static/resource/1"
},
"argument_name", "1",
CancellationToken.None
);
Assert.NotNull(result);
Assert.Single(result.Completion.Values);
Assert.True(result.Completion.Values[0] == "1");
}
/// <summary>
/// Note that as of 19th January 2025, the everything server published to npx does not support prompt completion.
/// However, the prompt completion is implemented in the github.com/modelcontextprotocol/servers repository.
/// You can clone this repo, and build locally then change the config to use a local symlink instead of npx until this is fixed, if you wish to run this test.
/// TransportOptions = new Dictionary<string, string>
/// {
/// ["command"] = "npx",
/// ["arguments"] = "mcp-server-everything" // changed from "-y @modelcontextprotocol/server-everything"
/// }
/// </summary>
[Fact]
public async Task GetCompletion_Stdio_PromptReference()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("everything");
var result = await client.GetCompletionAsync(new Reference
{
Type = "ref/prompt",
Name = "irrelevant"
},
argumentName:"style", argumentValue:"fo",
CancellationToken.None
);
Assert.NotNull(result);
Assert.Single(result.Completion.Values);
Assert.True(result.Completion.Values[0] == "formal");
}
[Fact]
public async Task Sampling_Stdio_EverythingServer()
{