Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,11 @@ public static class MessageTypeName
public const string FunctionCall = "function";
public const string Audio = "audio";
public const string Error = "error";

/// <summary>
/// A message that belongs to the conversation record but not to the conversation as the user
/// sees it -- what an agent said to itself on the way to an answer. Stored like any other
/// message and read back into the model's context; skipped when the dialog is rendered.
/// </summary>
public const string Internal = "internal";
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ public class RoleDialogModel : ITrackableMessage
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }

/// <summary>
/// Every tool call this reply asked for, in the order the model produced them, or null when
/// it asked for none.
/// </summary>
/// <remarks>
/// <see cref="FunctionName"/>, <see cref="FunctionArgs"/> and <see cref="ToolCallId"/> beside
/// this are the first entry, so a caller that can only run one call keeps working unchanged;
/// a caller that can run several reads this instead. The one difference is name repair: the
/// single field carries the normalized name it always has, while entries here keep the name
/// the model actually sent.
/// <para>
/// Deliberately not copied by <see cref="From"/>: this describes one model reply, and a
/// message derived from that reply -- a tool result, an assistant answer -- is not it.
/// </para>
/// </remarks>
public List<LlmToolCall>? ToolCalls { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string?>? Thought { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace BotSharp.Abstraction.Functions.Models;

/// <summary>
/// One tool call in a model's reply.
/// </summary>
/// <remarks>
/// A reply can carry several: models routinely ask for independent lookups at once, and every
/// provider here used to keep only the first. See <see cref="RoleDialogModel.ToolCalls"/> for
/// how the whole set is carried and how it relates to the single-call fields beside it.
/// </remarks>
public class LlmToolCall
{
/// <summary>
/// The provider's id for this call. It is what a tool result has to be sent back under, so
/// results cannot be matched to calls without it.
/// </summary>
public string? Id { get; set; }

/// <summary>
/// The name exactly as the model produced it, with no normalization applied -- a remote MCP
/// tool may legitimately have a name that name repair would rewrite.
/// </summary>
public string? FunctionName { get; set; }

/// <summary>
/// Raw JSON arguments. The model does not always produce valid JSON, so parse defensively.
/// </summary>
public string? FunctionArgs { get; set; }

public LlmToolCall()
{
}

public LlmToolCall(string? id, string? functionName, string? functionArgs)
{
Id = id;
FunctionName = functionName;
FunctionArgs = functionArgs;
}

public override string ToString() => $"{FunctionName}({FunctionArgs}) [{Id}]";
}
5 changes: 5 additions & 0 deletions src/Infrastructure/BotSharp.Core/MCP/BotSharpMCPExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public static IServiceCollection AddBotSharpMCP(this IServiceCollection services

if (settings != null && settings.Enabled && !settings.McpServerConfigs.IsNullOrEmpty())
{
// McpClientManager opens every connection over a client from this factory, so that
// connections to one server share a pooled handler instead of each building its own.
// Idempotent, and a host that already called it is unaffected.
services.AddHttpClient();

services.AddScoped<McpClientManager>();
services.AddScoped<IAgentHook, McpToolAgentHook>();
}
Expand Down
17 changes: 5 additions & 12 deletions src/Infrastructure/BotSharp.Core/MCP/Hooks/MCPToolAgentHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,13 @@ private async Task<IEnumerable<FunctionDef>> GetMcpContent(Agent agent)
var mcps = agent.McpTools?.Where(x => !x.Disabled) ?? [];
foreach (var item in mcps)
{
var mcpClient = await mcpClientManager.GetMcpClientAsync(item.ServerId);
if (mcpClient == null) continue;
// Cached per server for a short window: this runs on every agent load, and listing
// tools costs a session of its own against the server.
var tools = await mcpClientManager.GetToolDefinitionsAsync(item.ServerId);
if (tools.Count == 0) continue;

var tools = await mcpClient.ListToolsAsync();
var toolNames = item.Functions.Select(x => x.Name).ToList();
var targetTools = tools.Where(x => toolNames.Contains(x.Name, StringComparer.OrdinalIgnoreCase));
foreach (var tool in targetTools)
{
var funDef = AiFunctionHelper.MapToFunctionDef(tool);
if (funDef != null)
{
functionDefs.Add(funDef);
}
}
functionDefs.AddRange(tools.Where(x => toolNames.Contains(x.Name, StringComparer.OrdinalIgnoreCase)));
}

return functionDefs;
Expand Down
186 changes: 176 additions & 10 deletions src/Infrastructure/BotSharp.Core/MCP/Managers/McpClientManager.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
using BotSharp.Abstraction.Infrastructures;
using BotSharp.Core.MCP.Helpers;
using BotSharp.Core.MCP.Settings;
using ModelContextProtocol.Client;
using System.Net.Http;
using System.Security.Cryptography;

namespace BotSharp.Core.MCP.Managers;

public class McpClientManager : IDisposable
/// <summary>
/// Opens MCP clients. Each call returns a client of its own, which the caller owns and must
/// dispose; what is shared between callers is the HTTP connection underneath it.
/// </summary>
/// <remarks>
/// <para>
/// WHY NOTHING ABOVE THE SOCKET IS SHARED. An MCP client is a session: CreateAsync performs the
/// initialize handshake, the server answers with a session id, and subscriptions and long-running
/// tool tasks (ListTasksAsync, GetTaskResultAsync) live on that session. Handing one session to
/// two callers would show one of them the other's tasks, and no per-request header can undo that
/// because it is server-side state rather than an authorization question. Since
/// <see cref="IMcpClientHeaderProvider"/> lets a host open a connection as the signed-in user,
/// sharing a session would also mean sharing an identity. So sessions are never shared.
/// </para>
/// <para>
/// WHAT IS SHARED. The HttpClient comes from IHttpClientFactory, named per server, so every
/// connection to one server reuses a pooled HttpMessageHandler -- the same TCP and TLS the
/// factory would give any other caller. That layer carries no identity: the credential lives in
/// the transport's headers, and CreateClient hands back a fresh HttpClient each time, so headers
/// set for one caller are never seen by another. This is what makes a per-call session cheap:
/// the handshake runs over an already-warm connection.
/// </para>
/// <para>
/// Building the transport with its own HttpClient, as this did before, gave every MCP connection
/// a private handler and therefore a private socket pool -- the usual way to exhaust sockets and
/// to keep talking to an address DNS has already moved.
/// </para>
/// </remarks>
public class McpClientManager
{
private readonly IServiceProvider _services;
private readonly ILogger<McpClientManager> _logger;
Expand All @@ -16,12 +48,17 @@ public McpClientManager(
_logger = logger;
}

/// <summary>
/// Opens a client for <paramref name="serverId"/>. <b>The caller owns it and must dispose it</b>
/// -- an undisposed client leaves its session open on the server until the server times it out.
/// Answers null rather than throwing when the server is unknown, disabled or unreachable.
/// </summary>
public async Task<McpClient?> GetMcpClientAsync(string serverId)
{
try
{
var settings = _services.GetRequiredService<McpSettings>();
var config = settings.McpServerConfigs.Where(x => x.Id == serverId).FirstOrDefault();
var config = settings.McpServerConfigs?.FirstOrDefault(x => x.Id == serverId);
if (config == null || !config.Enabled)
{
return null;
Expand All @@ -30,7 +67,7 @@ public McpClientManager(
IClientTransport? transport = null;
if (config.HttpConfig != null)
{
transport = new HttpClientTransport(new HttpClientTransportOptions
transport = CreateHttpTransport(config, new HttpClientTransportOptions
{
Name = config.Name,
Endpoint = new Uri(config.HttpConfig.EndPoint),
Expand All @@ -40,7 +77,7 @@ public McpClientManager(
}
else if (config.SseConfig != null)
{
transport = new HttpClientTransport(new HttpClientTransportOptions
transport = CreateHttpTransport(config, new HttpClientTransportOptions
{
Name = config.Name,
Endpoint = new Uri(config.SseConfig.EndPoint),
Expand Down Expand Up @@ -74,23 +111,152 @@ public McpClientManager(
}
}

/// <summary>
/// The tools a server offers, as function definitions, reused for
/// <see cref="McpSettings.ToolListCacheSeconds"/> rather than listed again on every agent load.
/// </summary>
/// <remarks>
/// The entry is keyed by the headers the connection would carry and not by the server alone,
/// because <see cref="IMcpClientHeaderProvider"/> lets a host open the connection as the
/// signed-in user: a server that shows one caller a different set of tools than another must
/// never be able to serve one caller from the other one is cache entry. The headers are
/// fingerprinted rather than used directly, so no credential ends up in a cache key.
/// <para>
/// A failed or empty listing is never cached. A server that is briefly unreachable would
/// otherwise leave every agent that depends on it disarmed -- answering from the prompt alone
/// as if it had never had tools -- for the length of the window.
/// </para>
/// <para>
/// Callers get their own FunctionDef instances over shared parameter schemas, so an agent
/// that rewrites a description on the way to the model cannot rewrite it for every other
/// agent on the same server.
/// </para>
/// </remarks>
public async Task<List<FunctionDef>> GetToolDefinitionsAsync(string serverId)
{
var settings = _services.GetRequiredService<McpSettings>();
var config = settings.McpServerConfigs?.FirstOrDefault(x => x.Id == serverId);
if (config == null || !config.Enabled)
{
return [];
}

var window = TimeSpan.FromSeconds(Math.Max(0, settings.ToolListCacheSeconds));
var cache = _services.GetRequiredService<ICacheService>();
var key = ToolListCacheKey(config);

if (window > TimeSpan.Zero)
{
var cached = await cache.GetAsync<List<FunctionDef>>(key);
if (!cached.IsNullOrEmpty())
{
return Copy(cached!);
}
}

var tools = new List<FunctionDef>();

try
{
await using var client = await GetMcpClientAsync(serverId);
if (client == null)
{
return tools;
}

foreach (var tool in await client.ListToolsAsync())
{
var def = AiFunctionHelper.MapToFunctionDef(tool);
if (def != null)
{
tools.Add(def);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Error when listing tools of mcp server {serverId}");
return [];
}

if (window > TimeSpan.Zero && tools.Count > 0)
{
await cache.SetAsync(key, tools, window);
}

return Copy(tools);
}

private static List<FunctionDef> Copy(List<FunctionDef> tools)
=> tools.Select(x => new FunctionDef
{
Type = x.Type,
Name = x.Name,
Description = x.Description,
Channels = x.Channels,
VisibilityExpression = x.VisibilityExpression,
Impact = x.Impact,
Parameters = x.Parameters,
Output = x.Output
}).ToList();

private string ToolListCacheKey(McpServerConfigModel config)
{
var configured = config.HttpConfig?.AdditionalHeaders
?? config.SseConfig?.AdditionalHeaders;
var headers = ResolveHeaders(config.Id, configured);

var identity = headers.IsNullOrEmpty()
? "no-headers"
: Fingerprint(string.Join("|", headers!
.OrderBy(x => x.Key, StringComparer.Ordinal)
.Select(x => $"{x.Key}={x.Value}")));

return $"mcp-tools:{config.Id}:{identity}";
}

private static string Fingerprint(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)), 0, 8);

/// <summary>
/// A transport over an HttpClient from the factory, named for this server so its handler --
/// and therefore its connection pool -- is reused by every later connection to the same
/// server. The instance itself is fresh per call, which is what keeps one caller's headers
/// out of another's request.
/// </summary>
private HttpClientTransport CreateHttpTransport(McpServerConfigModel config, HttpClientTransportOptions options)
{
var factory = _services.GetRequiredService<IHttpClientFactory>();
var http = factory.CreateClient(HttpClientName(config.Id));

// Timeout is left at the factory default (100s) deliberately: no configured tool is
// expected to run that long. Note this is a cap the SDK's own HttpClient may not have
// had, so it arrived with this change -- a server whose transport keeps a GET open for
// the session (SSE, or streamable HTTP with a standalone listening stream) would be cut
// off at 100s no matter how quick its tools are. The symptom is a tool call failing with
// a canceled request; the fix is Timeout.InfiniteTimeSpan here.

return new HttpClientTransport(options, http, loggerFactory: null, ownsHttpClient: true);
}

/// <summary>
/// One handler pool per server, so a slow or unhealthy server cannot occupy the connections
/// of the others.
/// </summary>
private static string HttpClientName(string serverId) => $"mcp:{serverId}";

/// <summary>
/// The headers to open a connection with: the ones from configuration, unless the host has
/// registered an <see cref="IMcpClientHeaderProvider"/> that wants to adjust them.
/// </summary>
/// <remarks>
/// No provider is registered by default, and a provider is free to answer with what it was
/// given, so a host without one or with one that does not recognise this server gets the
/// given, so a host without one -- or with one that does not recognise this server -- gets the
/// configured headers back untouched.
/// </remarks>
private Dictionary<string, string>? ResolveHeaders(string serverId, Dictionary<string, string>? configured)
{
var provider = _services.GetService<IMcpClientHeaderProvider>();
return provider == null ? configured : provider.GetHeaders(serverId, configured);
}

public void Dispose()
{

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task<IEnumerable<McpServerOptionModel>> GetServerConfigsAsync()

foreach (var config in configs)
{
var client = await clientManager.GetMcpClientAsync(config.Id);
await using var client = await clientManager.GetMcpClientAsync(config.Id);
if (client == null) continue;

var tools = await client.ListToolsAsync();
Expand Down
13 changes: 13 additions & 0 deletions src/Infrastructure/BotSharp.Core/MCP/Settings/MCPSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,17 @@ public class McpSettings
public bool Enabled { get; set; } = true;
public McpClientOptions McpClientOptions { get; set; }
public List<McpServerConfigModel> McpServerConfigs { get; set; } = [];

/// <summary>
/// How long a server's tool listing is reused before it is fetched again. Zero disables the
/// cache and lists the tools on every agent load, which is what this did before.
/// </summary>
/// <remarks>
/// A listing costs a session of its own -- handshake, notification and stream -- on every
/// agent load, for a list that changes when a server is redeployed rather than between two
/// messages of one conversation. Sixty seconds is short enough that a tool added upstream
/// shows up while someone is still testing it, and long enough that no conversation pays for
/// the listing twice.
/// </remarks>
public int ToolListCacheSeconds { get; set; } = 60;
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ public async Task<bool> ExecuteAsync(RoleDialogModel message)
Dictionary<string, object?> argDict = JsonToDictionary(message.FunctionArgs);

var clientManager = _services.GetRequiredService<McpClientManager>();
var client = await clientManager.GetMcpClientAsync(_mcpServerId);

// The client is a session of its own, so this call owns it. Disposing closes the
// session on the server; the connection underneath it stays in the factory's pool.
await using var client = await clientManager.GetMcpClientAsync(_mcpServerId);

if (client == null)
{
Expand Down
Loading
Loading