Skip to content

Commit 157f855

Browse files
tarekghTarek Mahmoud Sayed
andauthored
Add RegisterTools API to McpClient for pre-populating tool cache (#1590)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
1 parent b99d887 commit 157f855

5 files changed

Lines changed: 1016 additions & 6 deletions

File tree

docs/concepts/tools/tools.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,50 @@ Rules and constraints:
340340
- Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper.
341341
- Header names must be case-insensitively unique within the tool's input schema.
342342
- Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later).
343+
344+
### Pre-loading tool definitions on the client
345+
346+
By default, `Mcp-Param-*` headers are sent only for tools discovered via <xref:ModelContextProtocol.Client.McpClient.ListToolsAsync*>. If a client already has tool schema information (for example, from a previous session, hardcoded configuration, or an out-of-band source), it can pre-load those definitions so that headers are sent immediately—without a round trip to the server.
347+
348+
```csharp
349+
// Build the tool definition with x-mcp-header annotations
350+
var tool = new Tool
351+
{
352+
Name = "execute_sql",
353+
InputSchema = JsonDocument.Parse("""
354+
{
355+
"type": "object",
356+
"properties": {
357+
"region": {
358+
"type": "string",
359+
"x-mcp-header": "Region"
360+
},
361+
"query": {
362+
"type": "string"
363+
}
364+
}
365+
}
366+
""").RootElement.Clone(),
367+
};
368+
369+
// Pre-load the tool definition — no ListToolsAsync needed
370+
client.AddKnownTools([tool]);
371+
372+
// This call now sends an Mcp-Param-Region header automatically
373+
var result = await client.CallToolAsync("execute_sql",
374+
new Dictionary<string, object?> { ["region"] = "us-west-2", ["query"] = "SELECT 1" });
375+
```
376+
377+
Known tools survive <xref:ModelContextProtocol.Client.McpClient.ListToolsAsync*> cache clears—they remain in the cache even when the server's tool list is refreshed. If the server returns a tool with the same name, the server's definition overwrites the cached one, but the tool keeps its known status.
378+
379+
To remove known tools, use <xref:ModelContextProtocol.Client.McpClient.RemoveKnownTools*> for specific tools or <xref:ModelContextProtocol.Client.McpClient.ClearKnownTools*> to remove all:
380+
381+
```csharp
382+
// Remove specific known tools by name
383+
client.RemoveKnownTools(["execute_sql"]);
384+
385+
// Or remove all known tools at once
386+
client.ClearKnownTools();
387+
```
388+
389+
All tools passed to <xref:ModelContextProtocol.Client.McpClient.AddKnownTools*> are validated for correct `x-mcp-header` annotations. If any tool in the batch fails validation, an <xref:System.ArgumentException> is thrown and no tools are added (all-or-nothing).

src/ModelContextProtocol.Core/Client/McpClient.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,84 @@ protected McpClient()
7070
/// </para>
7171
/// </remarks>
7272
public abstract Task<ClientCompletionDetails> Completion { get; }
73+
74+
/// <summary>
75+
/// Registers one or more tool definitions in the client's tool cache, enabling the transport
76+
/// to send <c>Mcp-Param-*</c> headers for those tools without requiring a prior <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> call.
77+
/// </summary>
78+
/// <param name="tools">The tool definitions to register.</param>
79+
/// <remarks>
80+
/// <para>
81+
/// This method allows callers who already have tool schema information (e.g., from a previous session,
82+
/// hardcoded configuration, or an out-of-band source) to provide it directly to the client. Once registered,
83+
/// any <see cref="McpClient.CallToolAsync(string, IReadOnlyDictionary{string, object?}?, IProgress{ProgressNotificationValue}?, RequestOptions?, CancellationToken)"/>
84+
/// call for a registered tool will automatically include <c>Mcp-Param-*</c> HTTP headers based on
85+
/// the tool's <c>x-mcp-header</c> schema annotations, exactly as if the tool had been discovered
86+
/// via <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/>.
87+
/// </para>
88+
/// <para>
89+
/// <b>Cache interaction behavior:</b>
90+
/// <list type="bullet">
91+
/// <item>Registered tools are added to the same internal tool cache used by <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/>.</item>
92+
/// <item>Calling <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> after <see cref="AddKnownTools"/> preserves
93+
/// manually registered tools — only server-discovered tools are cleared and repopulated.</item>
94+
/// <item>If the server returns a tool with the same name as a manually registered tool, the server's
95+
/// definition overwrites the registered one in the cache, but the tool retains its known status
96+
/// and will survive subsequent cache clears. This registration is sticky for the lifetime of the
97+
/// <see cref="McpClient"/>; use <see cref="RemoveKnownTools"/> or <see cref="ClearKnownTools"/> to
98+
/// explicitly drop known tools that are no longer needed.</item>
99+
/// <item>Tools can be registered at any time — before or after <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/>,
100+
/// and across multiple calls.</item>
101+
/// <item>Re-registering a tool with the same name overwrites the previous definition in the cache (last write wins).</item>
102+
/// </list>
103+
/// </para>
104+
/// <para>
105+
/// Tools with invalid <c>x-mcp-header</c> annotations cause an <see cref="ArgumentException"/> to be thrown.
106+
/// No tools are added to the cache if any tool in the batch fails validation (all-or-nothing).
107+
/// </para>
108+
/// </remarks>
109+
/// <exception cref="ArgumentNullException"><paramref name="tools"/> is <see langword="null"/>.</exception>
110+
/// <exception cref="ArgumentException">One or more tools have invalid <c>x-mcp-header</c> annotations.</exception>
111+
public virtual void AddKnownTools(IEnumerable<Tool> tools)
112+
{
113+
Throw.IfNull(tools);
114+
throw new NotSupportedException($"{GetType().Name} does not support adding known tools.");
115+
}
116+
117+
/// <summary>
118+
/// Removes one or more previously registered tool definitions from the client's tool cache by name.
119+
/// </summary>
120+
/// <param name="toolNames">The names of the tools to remove.</param>
121+
/// <remarks>
122+
/// <para>
123+
/// This removes the specified tools from both the known-tools set and the internal tool cache.
124+
/// After removal, those tools will no longer survive <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/>
125+
/// cache clears, and <c>Mcp-Param-*</c> headers will no longer be sent for them unless the server
126+
/// re-discovers them via <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/>.
127+
/// </para>
128+
/// <para>
129+
/// Removing a tool name that was not previously added via <see cref="AddKnownTools"/> is a no-op.
130+
/// </para>
131+
/// </remarks>
132+
/// <exception cref="ArgumentNullException"><paramref name="toolNames"/> is <see langword="null"/>.</exception>
133+
public virtual void RemoveKnownTools(IEnumerable<string> toolNames)
134+
{
135+
Throw.IfNull(toolNames);
136+
throw new NotSupportedException($"{GetType().Name} does not support removing known tools.");
137+
}
138+
139+
/// <summary>
140+
/// Removes all previously registered tool definitions from the client's tool cache.
141+
/// </summary>
142+
/// <remarks>
143+
/// <para>
144+
/// This clears all tools that were added via <see cref="AddKnownTools"/> from both the known-tools
145+
/// set and the internal tool cache. Server-discovered tools that are not also known tools are not affected
146+
/// and will remain in the cache until the next <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> call.
147+
/// </para>
148+
/// </remarks>
149+
public virtual void ClearKnownTools()
150+
{
151+
throw new NotSupportedException($"{GetType().Name} does not support clearing known tools.");
152+
}
73153
}

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal sealed partial class McpClientImpl : McpClient
2424
private readonly SemaphoreSlim _disposeLock = new(1, 1);
2525
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
2626
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
27+
private readonly ConcurrentDictionary<string, byte> _registeredToolNames = new(StringComparer.Ordinal);
2728

2829
private ServerCapabilities? _serverCapabilities;
2930
private Implementation? _serverInfo;
@@ -72,7 +73,23 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio
7273

7374
ToolDiscovered = tool => _toolCache[tool.Name] = tool;
7475
ToolRejected = (tool, reason) => LogToolRejected(tool.Name, reason);
75-
ToolCacheClearing = () => _toolCache.Clear();
76+
ToolCacheClearing = () =>
77+
{
78+
if (_registeredToolNames.IsEmpty)
79+
{
80+
_toolCache.Clear();
81+
return;
82+
}
83+
84+
// Only remove server-discovered tools; preserve manually registered tools.
85+
foreach (var key in _toolCache.Keys)
86+
{
87+
if (!_registeredToolNames.ContainsKey(key))
88+
{
89+
_toolCache.TryRemove(key, out _);
90+
}
91+
}
92+
};
7693
}
7794

7895
private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers)
@@ -637,6 +654,69 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions)
637654
LogClientSessionResumed(_endpointName);
638655
}
639656

657+
/// <inheritdoc/>
658+
public override void AddKnownTools(IEnumerable<Tool> tools)
659+
{
660+
Throw.IfNull(tools);
661+
662+
var snapshot = tools as IReadOnlyCollection<Tool> ?? [.. tools];
663+
664+
List<string>? rejections = null;
665+
foreach (var tool in snapshot)
666+
{
667+
Throw.IfNull(tool);
668+
669+
if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason))
670+
{
671+
ToolRejected?.Invoke(tool, rejectionReason!);
672+
(rejections ??= []).Add($"{tool.Name}: {rejectionReason}");
673+
}
674+
}
675+
676+
if (rejections is { Count: > 0 })
677+
{
678+
throw new ArgumentException(
679+
"One or more tools failed x-mcp-header validation: " + string.Join("; ", rejections),
680+
nameof(tools));
681+
}
682+
683+
foreach (var tool in snapshot)
684+
{
685+
_registeredToolNames[tool.Name] = 0;
686+
_toolCache[tool.Name] = tool;
687+
}
688+
}
689+
690+
/// <inheritdoc/>
691+
public override void RemoveKnownTools(IEnumerable<string> toolNames)
692+
{
693+
Throw.IfNull(toolNames);
694+
695+
var snapshot = toolNames as IReadOnlyCollection<string> ?? [.. toolNames];
696+
697+
foreach (var name in snapshot)
698+
{
699+
Throw.IfNull(name);
700+
}
701+
702+
foreach (var name in snapshot)
703+
{
704+
_registeredToolNames.TryRemove(name, out _);
705+
_toolCache.TryRemove(name, out _);
706+
}
707+
}
708+
709+
/// <inheritdoc/>
710+
public override void ClearKnownTools()
711+
{
712+
foreach (var name in _registeredToolNames.Keys)
713+
{
714+
_toolCache.TryRemove(name, out _);
715+
}
716+
717+
_registeredToolNames.Clear();
718+
}
719+
640720
/// <inheritdoc/>
641721
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
642722
{
@@ -645,12 +725,18 @@ public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, C
645725
if (request.Method == RequestMethods.ToolsCall &&
646726
request.Params is System.Text.Json.Nodes.JsonObject paramsObj &&
647727
paramsObj.TryGetPropertyValue("name", out var nameNode) &&
648-
nameNode?.GetValue<string>() is { } toolName &&
649-
_toolCache.TryGetValue(toolName, out var tool))
728+
nameNode?.GetValue<string>() is { } toolName)
650729
{
651-
request.Context ??= new();
652-
request.Context.Items ??= new Dictionary<string, object?>();
653-
request.Context.Items[McpHttpHeaders.ToolContextKey] = tool;
730+
if (_toolCache.TryGetValue(toolName, out var tool))
731+
{
732+
request.Context ??= new();
733+
request.Context.Items ??= new Dictionary<string, object?>();
734+
request.Context.Items[McpHttpHeaders.ToolContextKey] = tool;
735+
}
736+
else if (_transport is StreamableHttpClientSessionTransport)
737+
{
738+
LogToolCacheMiss(toolName);
739+
}
654740
}
655741

656742
return _sessionHandler.SendRequestAsync(request, cancellationToken);
@@ -707,6 +793,9 @@ public override async ValueTask DisposeAsync()
707793
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
708794
private partial void LogClientSessionResumed(string endpointName);
709795

796+
[LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' not found in cache during tools/call. Mcp-Param-* headers will not be sent. Call AddKnownTools or ListToolsAsync to populate the cache.")]
797+
private partial void LogToolCacheMiss(string toolName);
798+
710799
[LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")]
711800
private partial void LogToolRejected(string toolName, string reason);
712801

0 commit comments

Comments
 (0)