diff --git a/docs/concepts/tools/tools.md b/docs/concepts/tools/tools.md index 1cd412b73..4936f4e5d 100644 --- a/docs/concepts/tools/tools.md +++ b/docs/concepts/tools/tools.md @@ -340,3 +340,50 @@ Rules and constraints: - Values containing non-ASCII characters, control characters, or leading/trailing whitespace are Base64-encoded using the `=?base64?{value}?=` wrapper. - Header names must be case-insensitively unique within the tool's input schema. - Header validation is enforced only for protocol versions that support the HTTP Standardization feature (currently `DRAFT-2026-v1` and later). + +### Pre-loading tool definitions on the client + +By default, `Mcp-Param-*` headers are sent only for tools discovered via . 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. + +```csharp +// Build the tool definition with x-mcp-header annotations +var tool = new Tool +{ + Name = "execute_sql", + InputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "region": { + "type": "string", + "x-mcp-header": "Region" + }, + "query": { + "type": "string" + } + } + } + """).RootElement.Clone(), +}; + +// Pre-load the tool definition — no ListToolsAsync needed +client.AddKnownTools([tool]); + +// This call now sends an Mcp-Param-Region header automatically +var result = await client.CallToolAsync("execute_sql", + new Dictionary { ["region"] = "us-west-2", ["query"] = "SELECT 1" }); +``` + +Known tools survive 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. + +To remove known tools, use for specific tools or to remove all: + +```csharp +// Remove specific known tools by name +client.RemoveKnownTools(["execute_sql"]); + +// Or remove all known tools at once +client.ClearKnownTools(); +``` + +All tools passed to are validated for correct `x-mcp-header` annotations. If any tool in the batch fails validation, an is thrown and no tools are added (all-or-nothing). diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index 406969121..96960db04 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -70,4 +70,84 @@ protected McpClient() /// /// public abstract Task Completion { get; } + + /// + /// Registers one or more tool definitions in the client's tool cache, enabling the transport + /// to send Mcp-Param-* headers for those tools without requiring a prior call. + /// + /// The tool definitions to register. + /// + /// + /// This method allows callers who already have tool schema information (e.g., from a previous session, + /// hardcoded configuration, or an out-of-band source) to provide it directly to the client. Once registered, + /// any + /// call for a registered tool will automatically include Mcp-Param-* HTTP headers based on + /// the tool's x-mcp-header schema annotations, exactly as if the tool had been discovered + /// via . + /// + /// + /// Cache interaction behavior: + /// + /// Registered tools are added to the same internal tool cache used by . + /// Calling after preserves + /// manually registered tools — only server-discovered tools are cleared and repopulated. + /// If the server returns a tool with the same name as a manually registered tool, the server's + /// definition overwrites the registered one in the cache, but the tool retains its known status + /// and will survive subsequent cache clears. This registration is sticky for the lifetime of the + /// ; use or to + /// explicitly drop known tools that are no longer needed. + /// Tools can be registered at any time — before or after , + /// and across multiple calls. + /// Re-registering a tool with the same name overwrites the previous definition in the cache (last write wins). + /// + /// + /// + /// Tools with invalid x-mcp-header annotations cause an to be thrown. + /// No tools are added to the cache if any tool in the batch fails validation (all-or-nothing). + /// + /// + /// is . + /// One or more tools have invalid x-mcp-header annotations. + public virtual void AddKnownTools(IEnumerable tools) + { + Throw.IfNull(tools); + throw new NotSupportedException($"{GetType().Name} does not support adding known tools."); + } + + /// + /// Removes one or more previously registered tool definitions from the client's tool cache by name. + /// + /// The names of the tools to remove. + /// + /// + /// This removes the specified tools from both the known-tools set and the internal tool cache. + /// After removal, those tools will no longer survive + /// cache clears, and Mcp-Param-* headers will no longer be sent for them unless the server + /// re-discovers them via . + /// + /// + /// Removing a tool name that was not previously added via is a no-op. + /// + /// + /// is . + public virtual void RemoveKnownTools(IEnumerable toolNames) + { + Throw.IfNull(toolNames); + throw new NotSupportedException($"{GetType().Name} does not support removing known tools."); + } + + /// + /// Removes all previously registered tool definitions from the client's tool cache. + /// + /// + /// + /// This clears all tools that were added via from both the known-tools + /// set and the internal tool cache. Server-discovered tools that are not also known tools are not affected + /// and will remain in the cache until the next call. + /// + /// + public virtual void ClearKnownTools() + { + throw new NotSupportedException($"{GetType().Name} does not support clearing known tools."); + } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 0d5803559..0000cbf22 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -24,6 +24,7 @@ internal sealed partial class McpClientImpl : McpClient private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _registeredToolNames = new(StringComparer.Ordinal); private ServerCapabilities? _serverCapabilities; private Implementation? _serverInfo; @@ -72,7 +73,23 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio ToolDiscovered = tool => _toolCache[tool.Name] = tool; ToolRejected = (tool, reason) => LogToolRejected(tool.Name, reason); - ToolCacheClearing = () => _toolCache.Clear(); + ToolCacheClearing = () => + { + if (_registeredToolNames.IsEmpty) + { + _toolCache.Clear(); + return; + } + + // Only remove server-discovered tools; preserve manually registered tools. + foreach (var key in _toolCache.Keys) + { + if (!_registeredToolNames.ContainsKey(key)) + { + _toolCache.TryRemove(key, out _); + } + } + }; } private void RegisterHandlers(McpClientOptions options, NotificationHandlers notificationHandlers, RequestHandlers requestHandlers) @@ -637,6 +654,69 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) LogClientSessionResumed(_endpointName); } + /// + public override void AddKnownTools(IEnumerable tools) + { + Throw.IfNull(tools); + + var snapshot = tools as IReadOnlyCollection ?? [.. tools]; + + List? rejections = null; + foreach (var tool in snapshot) + { + Throw.IfNull(tool); + + if (!McpHeaderExtractor.ValidateToolSchema(tool, out var rejectionReason)) + { + ToolRejected?.Invoke(tool, rejectionReason!); + (rejections ??= []).Add($"{tool.Name}: {rejectionReason}"); + } + } + + if (rejections is { Count: > 0 }) + { + throw new ArgumentException( + "One or more tools failed x-mcp-header validation: " + string.Join("; ", rejections), + nameof(tools)); + } + + foreach (var tool in snapshot) + { + _registeredToolNames[tool.Name] = 0; + _toolCache[tool.Name] = tool; + } + } + + /// + public override void RemoveKnownTools(IEnumerable toolNames) + { + Throw.IfNull(toolNames); + + var snapshot = toolNames as IReadOnlyCollection ?? [.. toolNames]; + + foreach (var name in snapshot) + { + Throw.IfNull(name); + } + + foreach (var name in snapshot) + { + _registeredToolNames.TryRemove(name, out _); + _toolCache.TryRemove(name, out _); + } + } + + /// + public override void ClearKnownTools() + { + foreach (var name in _registeredToolNames.Keys) + { + _toolCache.TryRemove(name, out _); + } + + _registeredToolNames.Clear(); + } + /// public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { @@ -645,12 +725,18 @@ public override Task SendRequestAsync(JsonRpcRequest request, C if (request.Method == RequestMethods.ToolsCall && request.Params is System.Text.Json.Nodes.JsonObject paramsObj && paramsObj.TryGetPropertyValue("name", out var nameNode) && - nameNode?.GetValue() is { } toolName && - _toolCache.TryGetValue(toolName, out var tool)) + nameNode?.GetValue() is { } toolName) { - request.Context ??= new(); - request.Context.Items ??= new Dictionary(); - request.Context.Items[McpHttpHeaders.ToolContextKey] = tool; + if (_toolCache.TryGetValue(toolName, out var tool)) + { + request.Context ??= new(); + request.Context.Items ??= new Dictionary(); + request.Context.Items[McpHttpHeaders.ToolContextKey] = tool; + } + else + { + LogToolCacheMiss(toolName); + } } return _sessionHandler.SendRequestAsync(request, cancellationToken); @@ -707,6 +793,9 @@ public override async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")] private partial void LogClientSessionResumed(string endpointName); + [LoggerMessage(Level = LogLevel.Debug, 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.")] + private partial void LogToolCacheMiss(string toolName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")] private partial void LogToolRejected(string toolName, string reason); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs new file mode 100644 index 000000000..4a7edc37d --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AddKnownToolsHeaderTests.cs @@ -0,0 +1,359 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Tests that allows sending Mcp-Param-* headers +/// without a prior call. +/// +public class AddKnownToolsHeaderTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + /// + /// Captured headers from tools/call requests, keyed by JSON-RPC request id. + /// + private readonly ConcurrentDictionary> _capturedHeaders = new(); + + private async Task StartAsync() + { + Builder.Services.Configure(options => + { + options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!); + }); + _app = Builder.Build(); + + _app.MapPost("/mcp", (JsonRpcMessage message, HttpContext context) => + { + if (message is not JsonRpcRequest request) + { + return Results.Accepted(); + } + + if (request.Method == "initialize") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new() { Tools = new() }, + ServerInfo = new Implementation { Name = "header-capture-test", Version = "1.0" }, + }, McpJsonUtilities.DefaultOptions) + }); + } + + if (request.Method == "tools/call") + { + // Capture all Mcp-Param-* headers from the incoming HTTP request + var paramHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in context.Request.Headers) + { + if (header.Key.StartsWith("Mcp-Param-", StringComparison.OrdinalIgnoreCase)) + { + paramHeaders[header.Key] = header.Value.ToString(); + } + } + + _capturedHeaders[request.Id.ToString()!] = paramHeaders; + + var parameters = JsonSerializer.Deserialize(request.Params, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams; + + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new CallToolResult + { + Content = [new TextContentBlock { Text = $"ok" }], + }, McpJsonUtilities.DefaultOptions), + }); + } + + if (request.Method == "tools/list") + { + return Results.Json(new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult + { + Tools = [], + }, McpJsonUtilities.DefaultOptions), + }); + } + + return Results.Accepted(); + }); + + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private static Tool CreateToolWithHeaders() + { + var schemaJson = """ + { + "type": "object", + "properties": { + "region": { + "type": "string", + "x-mcp-header": "Region" + }, + "priority": { + "type": "integer", + "x-mcp-header": "Priority" + } + }, + "required": ["region", "priority"] + } + """; + + return new Tool + { + Name = "my_tool", + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Fact] + public async Task AddKnownTools_ThenCallTool_SendsMcpParamHeaders_WithoutListToolsAsync() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register the tool WITHOUT calling ListToolsAsync first — this is the core scenario from issue #1577 + client.AddKnownTools([CreateToolWithHeaders()]); + + // Call the tool + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "us-west-2", ["priority"] = 42 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify that Mcp-Param-* headers were captured by the server + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.True(headers.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region header to be sent"); + Assert.Equal("us-west-2", headers["Mcp-Param-Region"]); + Assert.True(headers.ContainsKey("Mcp-Param-Priority"), "Expected Mcp-Param-Priority header to be sent"); + Assert.Equal("42", headers["Mcp-Param-Priority"]); + } + + [Fact] + public async Task CallToolWithoutRegisterOrList_DoesNotSendMcpParamHeaders() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Call the tool without AddKnownTools or ListToolsAsync — no Mcp-Param-* headers should be sent + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "us-west-2", ["priority"] = 42 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify that NO Mcp-Param-* headers were sent + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.Empty(headers); + } + + [Fact] + public async Task AddKnownTools_SurvivesListToolsAsync_HeadersStillSent() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register the tool first + client.AddKnownTools([CreateToolWithHeaders()]); + + // Call ListToolsAsync — server returns empty list, but registered tool should survive + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Call the registered tool — Mcp-Param-* headers should still be sent + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "eu-central-1", ["priority"] = 99 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify headers were sent + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.True(headers.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region header after ListToolsAsync"); + Assert.Equal("eu-central-1", headers["Mcp-Param-Region"]); + Assert.True(headers.ContainsKey("Mcp-Param-Priority"), "Expected Mcp-Param-Priority header after ListToolsAsync"); + Assert.Equal("99", headers["Mcp-Param-Priority"]); + } + + [Fact] + public async Task RemoveKnownTools_ThenCallTool_NoMcpParamHeaders() + { + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register then remove — headers should no longer be sent + client.AddKnownTools([CreateToolWithHeaders()]); + client.RemoveKnownTools(["my_tool"]); + + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "us-east-1", ["priority"] = 1 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + // Verify no Mcp-Param-* headers were sent after removal + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.Empty(headers); + } + + private static Tool CreateToolWithSingleHeader(string toolName, string headerName) + { + var schemaJson = $$""" + { + "type": "object", + "properties": { + "value": { + "type": "string", + "x-mcp-header": "{{headerName}}" + } + }, + "required": ["value"] + } + """; + + return new Tool + { + Name = toolName, + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Fact] + public async Task AddKnownTools_ServerReturnsEmptyList_RegisteredToolStillUsedForHeaders() + { + // Staleness test: register foo → server returns [] → ListToolsAsync → call foo → headers still sent + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register tool, then ListToolsAsync returns empty list from server + client.AddKnownTools([CreateToolWithHeaders()]); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Call the registered tool — headers should still be sent (sticky registration) + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["region"] = "ap-southeast-1", ["priority"] = 5 }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + Assert.True(headers.ContainsKey("Mcp-Param-Region"), "Expected Mcp-Param-Region after server returned empty list"); + Assert.Equal("ap-southeast-1", headers["Mcp-Param-Region"]); + Assert.True(headers.ContainsKey("Mcp-Param-Priority"), "Expected Mcp-Param-Priority after server returned empty list"); + Assert.Equal("5", headers["Mcp-Param-Priority"]); + } + + [Fact] + public async Task AddKnownTools_ReRegisterOverwrite_LastWriteWinsHeaders() + { + // Last-write-wins: register foo with schema A → register foo with schema B → call → headers reflect schema B + await StartAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Register with header "SchemaA", then overwrite with "SchemaB" + client.AddKnownTools([CreateToolWithSingleHeader("my_tool", "SchemaA")]); + client.AddKnownTools([CreateToolWithSingleHeader("my_tool", "SchemaB")]); + + var result = await client.CallToolAsync( + "my_tool", + new Dictionary { ["value"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + Assert.Single(_capturedHeaders); + var headers = _capturedHeaders.Values.First(); + // SchemaA header should NOT be present + Assert.False(headers.ContainsKey("Mcp-Param-SchemaA"), "SchemaA header should have been overwritten"); + // SchemaB header SHOULD be present (last write wins) + Assert.True(headers.ContainsKey("Mcp-Param-SchemaB"), "Expected Mcp-Param-SchemaB from overwritten registration"); + Assert.Equal("test", headers["Mcp-Param-SchemaB"]); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs new file mode 100644 index 000000000..30dbc10f4 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientAddKnownToolsTests.cs @@ -0,0 +1,411 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientAddKnownToolsTests : ClientServerTestBase +{ + private const string ServerToolName = "ServerTool"; + private const string ServerToolName2 = "ServerTool2"; + + public McpClientAddKnownToolsTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([ + McpServerTool.Create( + (string input) => $"echo {input}", + new() { Name = ServerToolName }), + McpServerTool.Create( + (string input) => $"echo2 {input}", + new() { Name = ServerToolName2 }), + ]); + } + + private static Tool CreateTool(string name, string? headerAnnotation = null) + { + string schemaJson = headerAnnotation is not null + ? $$""" + { + "type": "object", + "properties": { + "param1": { + "type": "string", + "x-mcp-header": "{{headerAnnotation}}" + } + } + } + """ + : """ + { + "type": "object", + "properties": { + "param1": { + "type": "string" + } + } + } + """; + + return new Tool + { + Name = name, + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + private static Tool CreateInvalidTool(string name) + { + // Colon in header name is invalid + var schemaJson = """ + { + "type": "object", + "properties": { + "param1": { + "type": "string", + "x-mcp-header": "Invalid:Header" + } + } + } + """; + + return new Tool + { + Name = name, + InputSchema = JsonDocument.Parse(schemaJson).RootElement.Clone(), + }; + } + + [Fact] + public async Task AddKnownTools_ThenListToolsAsync_ServerToolsStillReturned() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var registeredTool = CreateTool("MyRegisteredTool", "X-Custom"); + + // Act — register without calling ListToolsAsync first, then list + client.AddKnownTools([registeredTool]); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — ListToolsAsync returns server tools (registered tools stay in cache for header generation + // but are not returned by ListToolsAsync which only returns server-reported tools) + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + Assert.Equal(2, tools.Count); + } + + [Fact] + public async Task AddKnownTools_ThenMultipleListToolsAsync_ServerToolsAlwaysRepopulated() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("MyRegisteredTool", "X-Custom")]); + + // Act — ListToolsAsync clears non-registered tools and repopulates from server + var tools1 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools2 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server tools repopulated correctly after each clear + Assert.Contains(tools1, t => t.Name == ServerToolName); + Assert.Contains(tools1, t => t.Name == ServerToolName2); + Assert.Contains(tools2, t => t.Name == ServerToolName); + Assert.Contains(tools2, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task ListToolsAsync_ThenRegisterTool_ServerToolsStillRepopulated() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + + // Act — list first, then register, then list again + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, tools.Count); + + client.AddKnownTools([CreateTool("MyRegisteredTool", "X-Custom")]); + + var tools2 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server tools still repopulated after registering + Assert.Contains(tools2, t => t.Name == ServerToolName); + Assert.Contains(tools2, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task RegisterTool_ListToolsAsync_RegisterTool_ServerToolsIntact() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + + // Act — register, list, register again + client.AddKnownTools([CreateTool("FirstRegistered", "X-First")]); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + client.AddKnownTools([CreateTool("SecondRegistered", "X-Second")]); + + // Another ListToolsAsync — server tools should still be repopulated + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_WithSameNameAsServerTool_ServerDefinitionReturned() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var registeredTool = CreateTool(ServerToolName, "X-Override"); + + // Act — register a tool with the same name as a server tool, then list + client.AddKnownTools([registeredTool]); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server's definition is returned by ListToolsAsync + Assert.Contains(tools, t => t.Name == ServerToolName); + + // After another ListToolsAsync, the tool is still present (pinned as registered + server tool) + var tools2 = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools2, t => t.Name == ServerToolName); + } + + [Fact] + public async Task AddKnownTools_WithInvalidSchema_ThrowsArgumentException() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var invalidTool = CreateInvalidTool("BadTool"); + var validTool = CreateTool("GoodTool", "X-Good"); + + // Act & Assert — all-or-nothing: neither tool should be added + var ex = Assert.Throws(() => client.AddKnownTools([invalidTool, validTool])); + Assert.Contains("BadTool", ex.Message); + + // Server tools still work normally + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_DuplicateRegistration_DoesNotBreakCache() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + var tool1 = CreateTool("MyTool", "X-First"); + var tool2 = CreateTool("MyTool", "X-Second"); + + // Act — register same name twice; second should overwrite + client.AddKnownTools([tool1]); + client.AddKnownTools([tool2]); + + // Assert — cache clearing still works; server tools repopulated + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_NullArgument_ThrowsArgumentNullException() + { + await using var client = await CreateMcpClientForServer(); + Assert.Throws(() => client.AddKnownTools(null!)); + } + + [Fact] + public async Task MultipleListToolsAsync_WithRegisteredTools_ServerToolsAlwaysRepopulated() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("PinnedTool", "X-Pinned")]); + + // Act — call ListToolsAsync multiple times + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert — server tools repopulated each time despite registered tool in cache + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_WithNoHeaderAnnotation_StillAccepted() + { + // Arrange — a tool without x-mcp-header is still valid and should be cached + await using var client = await CreateMcpClientForServer(); + var tool = CreateTool("PlainTool"); + + // Act — register a tool with no x-mcp-header; should not throw + client.AddKnownTools([tool]); + + // Assert — server tools still repopulated after cache clears + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task AddKnownTools_ThenCallTool_RegisteredToolUsedForCacheLookup() + { + // Arrange — register a tool with the same name as a server tool so CallToolAsync succeeds server-side + await using var client = await CreateMcpClientForServer(); + var tool = CreateTool(ServerToolName, "X-Custom"); + + // Act — register without ListToolsAsync, then call the tool directly + client.AddKnownTools([tool]); + + // The tool is in the cache, so SendRequestAsync will find it for header attachment. + // The server has a tool with this name, so the call succeeds. + var result = await client.CallToolAsync( + ServerToolName, + new Dictionary { ["input"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert — call succeeded (tool was found in cache, request was processed by server) + Assert.NotNull(result); + Assert.Contains(result.Content, c => c is TextContentBlock text && text.Text == "echo test"); + } + + [Fact] + public async Task RemoveKnownTools_RemovedToolNoLongerSurvivesListToolsAsync() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("MyTool", "X-Custom")]); + + // Act — remove the known tool + client.RemoveKnownTools(["MyTool"]); + + // Assert — server tools still repopulated, removed tool doesn't interfere + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task RemoveKnownTools_NonExistentName_IsNoOp() + { + await using var client = await CreateMcpClientForServer(); + + // Should not throw + client.RemoveKnownTools(["NonExistentTool"]); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + } + + [Fact] + public async Task RemoveKnownTools_NullArgument_ThrowsArgumentNullException() + { + await using var client = await CreateMcpClientForServer(); + Assert.Throws(() => client.RemoveKnownTools(null!)); + } + + [Fact] + public async Task RemoveKnownTools_PartialRemove_OtherToolsSurvive() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([ + CreateTool("ToolA", "X-A"), + CreateTool("ToolB", "X-B"), + ]); + + // Act — remove only ToolA + client.RemoveKnownTools(["ToolA"]); + + // Assert — ToolB still survives cache clears, server tools repopulated + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task ClearKnownTools_RemovesAllKnownToolsFromCache() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([ + CreateTool("ToolA", "X-A"), + CreateTool("ToolB", "X-B"), + ]); + + // Act + client.ClearKnownTools(); + + // Assert — server tools still work after clearing known tools + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Contains(tools, t => t.Name == ServerToolName2); + } + + [Fact] + public async Task ClearKnownTools_WhenEmpty_IsNoOp() + { + await using var client = await CreateMcpClientForServer(); + + // Should not throw when nothing is registered + client.ClearKnownTools(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + } + + [Fact] + public async Task ClearKnownTools_ThenAddKnownTools_WorksCorrectly() + { + // Arrange + await using var client = await CreateMcpClientForServer(); + client.AddKnownTools([CreateTool("ToolA", "X-A")]); + + // Act — clear then add new tools + client.ClearKnownTools(); + client.AddKnownTools([CreateTool("ToolC", "X-C")]); + + // Assert — server tools repopulated, new tool doesn't interfere + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + } + + [Fact] + public async Task AddKnownTools_PartialFailure_NothingRegistered() + { + // Arrange — [valid, invalid, valid] should register nothing (all-or-nothing) + await using var client = await CreateMcpClientForServer(); + var valid1 = CreateTool("Valid1", "X-One"); + var invalid = CreateInvalidTool("BadTool"); + var valid2 = CreateTool("Valid2", "X-Two"); + + // Act & Assert — throws, no tools registered + Assert.Throws(() => client.AddKnownTools([valid1, invalid, valid2])); + + // Server tools still work; none of the valid tools were cached + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == ServerToolName); + Assert.Equal(2, tools.Count); + } + + [Fact] + public async Task AddKnownTools_NullElementInMiddle_NothingRegistered() + { + // Arrange — null element at index 1; elements before it should not be cached + await using var client = await CreateMcpClientForServer(); + var valid = CreateTool("Valid", "X-Valid"); + + // Act & Assert — throws ArgumentNullException on null element, nothing cached + Assert.Throws(() => client.AddKnownTools([valid, null!, CreateTool("Other", "X-Other")])); + + // Server tools still work; valid tool was NOT cached due to atomicity + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, tools.Count); + } +}