-
Notifications
You must be signed in to change notification settings - Fork 602
Add UseMcpClient #1086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jozkee
wants to merge
7
commits into
modelcontextprotocol:main
Choose a base branch
from
jozkee:mcp_chatclient
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,180
−0
Open
Add UseMcpClient #1086
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
a069f64
Add UseMcpClient
jozkee 1b3de48
Address feedback
jozkee 298e76e
Add LRU cache
jozkee 3e5cce5
Merge branch 'main' into mcp_chatclient
jozkee 9ecbbd4
Revert remnants of previous testing approach
jozkee 104e721
Use Experimental ID MCP5002 instead of MEAI001
jozkee 9920d16
Trailing whitespace
jozkee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
225 changes: 225 additions & 0 deletions
225
src/ModelContextProtocol/McpChatClientBuilderExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| using System.Collections.Concurrent; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Runtime.CompilerServices; | ||
| using Microsoft.Extensions.AI; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Logging.Abstractions; | ||
| using ModelContextProtocol.Client; | ||
|
|
||
| namespace ModelContextProtocol; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for adding MCP client support to chat clients. | ||
| /// </summary> | ||
| public static class McpChatClientBuilderExtensions | ||
| { | ||
| /// <summary> | ||
| /// Adds a chat client to the chat client pipeline that creates an <see cref="McpClient"/> for each <see cref="HostedMcpServerTool"/> | ||
| /// in <see cref="ChatOptions.Tools"/> and augments it with the tools from MCP servers as <see cref="AIFunction"/> instances. | ||
| /// </summary> | ||
| /// <param name="builder">The <see cref="ChatClientBuilder"/> to configure.</param> | ||
| /// <param name="httpClient">The <see cref="HttpClient"/> to use, or <see langword="null"/> to create a new instance.</param> | ||
| /// <param name="loggerFactory">The <see cref="ILoggerFactory"/> to use, or <see langword="null"/> to resolve from services.</param> | ||
| /// <returns>The <see cref="ChatClientBuilder"/> for method chaining.</returns> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// When a <c>HostedMcpServerTool</c> is encountered in the tools collection, the client | ||
| /// connects to the MCP server, retrieves available tools, and expands them into callable AI functions. | ||
| /// Connections are cached by server address to avoid redundant connections. | ||
| /// </para> | ||
| /// <para> | ||
| /// Use this method as an alternative when working with chat providers that don't have built-in support for hosted MCP servers. | ||
| /// </para> | ||
| /// </remarks> | ||
| [Experimental("MEAI001")] | ||
| public static ChatClientBuilder UseMcpClient( | ||
| this ChatClientBuilder builder, | ||
| HttpClient? httpClient = null, | ||
jozkee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ILoggerFactory? loggerFactory = null) | ||
| { | ||
| return builder.Use((innerClient, services) => | ||
| { | ||
| loggerFactory ??= (ILoggerFactory)services.GetService(typeof(ILoggerFactory))!; | ||
| var chatClient = new McpChatClient(innerClient, httpClient, loggerFactory); | ||
| return chatClient; | ||
| }); | ||
| } | ||
|
|
||
| [Experimental("MEAI001")] | ||
jozkee marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| private sealed class McpChatClient : DelegatingChatClient | ||
| { | ||
| private readonly ILoggerFactory? _loggerFactory; | ||
| private readonly ILogger _logger; | ||
| private readonly HttpClient _httpClient; | ||
| private readonly bool _ownsHttpClient; | ||
| private readonly ConcurrentDictionary<string, Task<McpClient>> _mcpClientTasks = []; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="McpChatClient"/> class. | ||
| /// </summary> | ||
| /// <param name="innerClient">The underlying <see cref="IChatClient"/>, or the next instance in a chain of clients.</param> | ||
| /// <param name="httpClient">An optional <see cref="HttpClient"/> to use when connecting to MCP servers. If not provided, a new instance will be created.</param> | ||
| /// <param name="loggerFactory">An <see cref="ILoggerFactory"/> to use for logging information about function invocation.</param> | ||
| public McpChatClient(IChatClient innerClient, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) | ||
| : base(innerClient) | ||
| { | ||
| _loggerFactory = loggerFactory; | ||
| _logger = (ILogger?)loggerFactory?.CreateLogger<McpChatClient>() ?? NullLogger.Instance; | ||
| _httpClient = httpClient ?? new HttpClient(); | ||
| _ownsHttpClient = httpClient is null; | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async Task<ChatResponse> GetResponseAsync( | ||
| IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) | ||
| { | ||
| if (options?.Tools is { Count: > 0 }) | ||
| { | ||
| var downstreamTools = await BuildDownstreamAIToolsAsync(options.Tools, cancellationToken).ConfigureAwait(false); | ||
| options = options.Clone(); | ||
| options.Tools = downstreamTools; | ||
| } | ||
|
|
||
| return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) | ||
| { | ||
| if (options?.Tools is { Count: > 0 }) | ||
| { | ||
| var downstreamTools = await BuildDownstreamAIToolsAsync(options.Tools, cancellationToken).ConfigureAwait(false); | ||
| options = options.Clone(); | ||
| options.Tools = downstreamTools; | ||
| } | ||
|
|
||
| await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) | ||
| { | ||
| yield return update; | ||
| } | ||
| } | ||
|
|
||
| private async Task<List<AITool>> BuildDownstreamAIToolsAsync(IList<AITool> inputTools, CancellationToken cancellationToken) | ||
| { | ||
| List<AITool> downstreamTools = []; | ||
| foreach (var tool in inputTools) | ||
| { | ||
| if (tool is not HostedMcpServerTool mcpTool) | ||
| { | ||
| // For other tools, we want to keep them in the list of tools. | ||
| downstreamTools.Add(tool); | ||
| continue; | ||
| } | ||
|
|
||
| if (!Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out var parsedAddress) || | ||
| (parsedAddress.Scheme != Uri.UriSchemeHttp && parsedAddress.Scheme != Uri.UriSchemeHttps)) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Invalid http(s) address: '{mcpTool.ServerAddress}'. MCP server address must be an absolute https(s) URL."); | ||
| } | ||
|
|
||
| // List all MCP functions from the specified MCP server. | ||
| var mcpClient = await CreateMcpClientAsync(mcpTool.ServerAddress, parsedAddress, mcpTool.ServerName, mcpTool.AuthorizationToken).ConfigureAwait(false); | ||
| var mcpFunctions = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); | ||
|
|
||
| // Add the listed functions to our list of tools we'll pass to the inner client. | ||
jozkee marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| foreach (var mcpFunction in mcpFunctions) | ||
| { | ||
| if (mcpTool.AllowedTools is not null && !mcpTool.AllowedTools.Contains(mcpFunction.Name)) | ||
| { | ||
| if (_logger.IsEnabled(LogLevel.Information)) | ||
| { | ||
| _logger.LogInformation("MCP function '{FunctionName}' is not allowed by the tool configuration.", mcpFunction.Name); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| switch (mcpTool.ApprovalMode) | ||
| { | ||
| case HostedMcpServerToolNeverRequireApprovalMode: | ||
| case HostedMcpServerToolRequireSpecificApprovalMode specificApprovalMode when specificApprovalMode.NeverRequireApprovalToolNames?.Contains(mcpFunction.Name) is true: | ||
| downstreamTools.Add(mcpFunction); | ||
| break; | ||
|
|
||
| default: | ||
| // Default to always require approval if no specific mode is set. | ||
| downstreamTools.Add(new ApprovalRequiredAIFunction(mcpFunction)); | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return downstreamTools; | ||
| } | ||
|
|
||
| /// <inheritdoc/> | ||
| protected override void Dispose(bool disposing) | ||
| { | ||
| if (disposing) | ||
| { | ||
| // Dispose of the HTTP client if it was created by this client. | ||
| if (_ownsHttpClient) | ||
| { | ||
| _httpClient?.Dispose(); | ||
| } | ||
|
|
||
| if (_mcpClientTasks is not null) | ||
| { | ||
| // Dispose of all cached MCP clients. | ||
| foreach (var clientTask in _mcpClientTasks.Values) | ||
| { | ||
| if (clientTask.Status == TaskStatus.RanToCompletion) | ||
| { | ||
| _ = clientTask.Result.DisposeAsync(); | ||
| } | ||
| } | ||
|
|
||
| _mcpClientTasks.Clear(); | ||
| } | ||
| } | ||
|
|
||
| base.Dispose(disposing); | ||
| } | ||
|
|
||
| private async Task<McpClient> CreateMcpClientAsync(string key, Uri serverAddress, string serverName, string? authorizationToken) | ||
| { | ||
| // Note: We don't pass cancellationToken to the factory because the cached task should not be tied to any single caller's cancellation token. | ||
| // Instead, callers can cancel waiting for the task, but the connection attempt itself will complete independently. | ||
| #if NET | ||
| // Avoid closure allocation. | ||
| Task<McpClient> task = _mcpClientTasks.GetOrAdd(key, | ||
| static (_, state) => state.self.CreateMcpClientCoreAsync(state.serverAddress, state.serverName, state.authorizationToken, CancellationToken.None), | ||
| (self: this, serverAddress, serverName, authorizationToken)); | ||
| #else | ||
| Task<McpClient> task = _mcpClientTasks.GetOrAdd(key, | ||
| _ => CreateMcpClientCoreAsync(serverAddress, serverName, authorizationToken, CancellationToken.None)); | ||
| #endif | ||
|
|
||
| try | ||
| { | ||
| return await task.ConfigureAwait(false); | ||
| } | ||
| catch | ||
| { | ||
| // Remove the failed task from cache so subsequent requests can retry. | ||
| _mcpClientTasks.TryRemove(key, out _); | ||
| throw; | ||
| } | ||
| } | ||
|
|
||
| private Task<McpClient> CreateMcpClientCoreAsync(Uri serverAddress, string serverName, string? authorizationToken, CancellationToken cancellationToken) | ||
| { | ||
| var transport = new HttpClientTransport(new HttpClientTransportOptions | ||
| { | ||
| Endpoint = serverAddress, | ||
| Name = serverName, | ||
| AdditionalHeaders = authorizationToken is not null | ||
| // Update to pass all headers once https://github.com/dotnet/extensions/pull/7053 is available. | ||
| ? new Dictionary<string, string>() { { "Authorization", $"Bearer {authorizationToken}" } } | ||
| : null, | ||
| }, _httpClient, _loggerFactory); | ||
|
|
||
| return McpClient.CreateAsync(transport, cancellationToken: cancellationToken); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.