diff --git a/src/OpenClaw.Agent/OpenClaw.Agent.csproj b/src/OpenClaw.Agent/OpenClaw.Agent.csproj index c5890fb..eb8b3a9 100644 --- a/src/OpenClaw.Agent/OpenClaw.Agent.csproj +++ b/src/OpenClaw.Agent/OpenClaw.Agent.csproj @@ -14,6 +14,7 @@ + diff --git a/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs b/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs new file mode 100644 index 0000000..c33cea2 --- /dev/null +++ b/src/OpenClaw.Agent/Plugins/McpServerToolRegistry.cs @@ -0,0 +1,335 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using OpenClaw.Agent.Tools; +using OpenClaw.Core.Abstractions; +using OpenClaw.Core.Plugins; +using OpenClaw.Core.Security; + +namespace OpenClaw.Agent.Plugins; + +/// +/// Discovers tools from configured MCP servers and registers them as native OpenClaw tools. +/// +public sealed class McpServerToolRegistry : IDisposable +{ + private readonly McpPluginsConfig _config; + private readonly ILogger _logger; + private readonly SemaphoreSlim _loadSemaphore = new(1, 1); + private readonly List _tools = []; + private readonly List _clients = []; + private bool _loaded; + private bool _registered; + private bool _disposed; + + /// + /// Creates a registry for configured MCP servers. + /// + public McpServerToolRegistry(McpPluginsConfig config, ILogger logger) + { + _config = config; + _logger = logger; + } + + /// + /// Connects to configured MCP servers and registers discovered tools into the native registry. + /// + public async Task RegisterToolsAsync(NativePluginRegistry nativeRegistry, CancellationToken ct) + { + ThrowIfDisposed(); + await _loadSemaphore.WaitAsync(ct); + try + { + ThrowIfDisposed(); + if (_registered) + return; + + var tools = await LoadInternalAsync(ct); + foreach (var tool in tools) + nativeRegistry.RegisterExternalTool(tool.Tool, tool.PluginId, tool.Detail); + + _registered = true; + } + finally + { + _loadSemaphore.Release(); + } + } + + internal async Task> LoadAsync(CancellationToken ct) + { + ThrowIfDisposed(); + await _loadSemaphore.WaitAsync(ct); + try + { + ThrowIfDisposed(); + return _loaded ? _tools : await LoadInternalAsync(ct); + } + finally + { + _loadSemaphore.Release(); + } + } + + private async Task> LoadInternalAsync(CancellationToken ct) + { + if (_loaded) + return _tools; + + if (!_config.Enabled) + { + _loaded = true; + return _tools; + } + + var discoveredTools = new List(); + var discoveredClients = new List(); + + try + { + foreach (var (serverId, serverConfig) in _config.Servers ?? []) + { + if (!serverConfig.Enabled) + continue; + + var transport = CreateTransport(serverId, serverConfig); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(serverConfig.StartupTimeoutSeconds)); + var client = await McpClient.CreateAsync(transport, cancellationToken: timeoutCts.Token); + discoveredClients.Add(client); + + var displayName = string.IsNullOrWhiteSpace(serverConfig.Name) ? serverId : serverConfig.Name!; + var pluginId = $"mcp:{serverId}"; + + var tools = await LoadToolsFromClientAsync(client, serverId, pluginId, displayName, serverConfig, ct); + + foreach (var tool in tools) + { + discoveredTools.Add(new DiscoveredMcpTool( + pluginId, + new McpNativeTool(client, tool.LocalName, tool.RemoteName, tool.Description, tool.InputSchemaText), + displayName)); + } + } + + _clients.AddRange(discoveredClients); + _tools.AddRange(discoveredTools); + _loaded = true; + return _tools; + } + catch + { + foreach (var client in discoveredClients) + { + try + { + DisposeClient(client); + } + catch + { + } + } + throw; + } + } + + private async Task> LoadToolsFromClientAsync( + McpClient client, + string serverId, + string pluginId, + string displayName, + McpServerConfig config, + CancellationToken ct) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(config.RequestTimeoutSeconds)); + var response = await client.ListToolsAsync(cancellationToken: timeoutCts.Token); + + var tools = new List(); + foreach (var tool in response) + { + var remoteName = tool.Name; + if (string.IsNullOrWhiteSpace(remoteName)) + throw new InvalidOperationException($"MCP server '{displayName}' returned a tool entry with an empty name."); + + var localName = ResolveToolName(serverId, config.ToolNamePrefix, remoteName); + var description = !string.IsNullOrWhiteSpace(tool.Description) + ? $"{tool.Description} (from MCP server '{displayName}')" + : $"MCP tool '{remoteName}' from server '{displayName}'."; + var inputSchema = ResolveInputSchemaText(tool.JsonSchema); + tools.Add(new McpToolDescriptor(localName, remoteName, description, inputSchema)); + } + + _logger.LogInformation("MCP server enabled: {ServerId} ({DisplayName}) with {ToolCount} tool(s)", + serverId, displayName, tools.Count); + return tools; + } + + private static string ResolveToolName(string serverId, string? toolNamePrefix, string remoteName) + { + var prefix = toolNamePrefix; + if (prefix is null) + prefix = $"{SanitizePrefixPart(serverId)}."; + + return string.IsNullOrEmpty(prefix) ? remoteName : prefix + remoteName; + } + + private static string SanitizePrefixPart(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "mcp"; + + var sb = new StringBuilder(value.Length); + foreach (var ch in value) + { + if (char.IsLetterOrDigit(ch) || ch is '_' or '-' or '.') + sb.Append(char.ToLowerInvariant(ch)); + else + sb.Append('_'); + } + + return sb.Length == 0 ? "mcp" : sb.ToString(); + } + + private static string ResolveInputSchemaText(JsonElement inputSchema) + { + if (inputSchema.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + return "{}"; + + return inputSchema.GetRawText(); + } + + public void Dispose() + { + bool acquired = false; + try + { + acquired = _loadSemaphore.Wait(TimeSpan.FromSeconds(5)); + if (!acquired) + { + _logger.LogWarning("McpServerToolRegistry.Dispose() timed out waiting for load semaphore, waiting indefinitely to ensure load completes"); + _loadSemaphore.Wait(); + acquired = true; + } + } + catch (ObjectDisposedException) + { + _logger.LogWarning("McpServerToolRegistry.Dispose() encountered disposed semaphore, load may have completed concurrently"); + return; + } + + try + { + if (_disposed) + return; + + _disposed = true; + foreach (var client in _clients) + { + try + { + DisposeClient(client); + } + catch + { + } + } + _clients.Clear(); + } + finally + { + if (acquired) + _loadSemaphore.Release(); + } + } + + private static IClientTransport CreateTransport(string serverId, McpServerConfig config) + { + var transport = config.NormalizeTransport(); + return transport switch + { + "stdio" => new StdioClientTransport(new StdioClientTransportOptions + { + Command = config.Command!, + Arguments = config.Arguments ?? [], + WorkingDirectory = config.WorkingDirectory, + EnvironmentVariables = ResolveEnv(config.Environment), + Name = serverId, + }), + "http" => new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri(config.Url!), + AdditionalHeaders = ResolveHeaders(config.Headers), + Name = serverId, + }), + _ => throw new InvalidOperationException($"Unsupported MCP transport '{config.Transport}' for server '{serverId}'.") + }; + } + + private static Dictionary? ResolveEnv(Dictionary? environment) + { + if (environment is null || environment.Count == 0) + return null; + + var resolved = new Dictionary(StringComparer.Ordinal); + foreach (var (name, rawValue) in environment) + { + if (rawValue is null) + { + resolved[name] = null; + continue; + } + var value = SecretResolver.Resolve(rawValue); + if (value is null && rawValue.StartsWith("env:", StringComparison.Ordinal)) + throw new InvalidOperationException($"Environment variable '{name}' references unset env var '{rawValue[4..]}'"); + resolved[name] = value ?? rawValue; + } + + return resolved; + } + + private static Dictionary? ResolveHeaders(Dictionary? headers) + { + if (headers is null || headers.Count == 0) + return null; + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, rawValue) in headers) + { + if (rawValue is null) + { + resolved[name] = string.Empty; + continue; + } + var value = SecretResolver.Resolve(rawValue); + if (value is null && rawValue.StartsWith("env:", StringComparison.Ordinal)) + throw new InvalidOperationException($"Header '{name}' references unset env var '{rawValue[4..]}'"); + resolved[name] = value ?? rawValue; + } + + return resolved; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(McpServerToolRegistry)); + } + + private static void DisposeClient(McpClient client) + { + if (client is IAsyncDisposable asyncDisposable) + { + asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult(); + return; + } + + if (client is IDisposable disposable) + disposable.Dispose(); + } + + internal sealed record DiscoveredMcpTool(string PluginId, ITool Tool, string Detail); + private sealed record McpToolDescriptor(string LocalName, string RemoteName, string Description, string InputSchemaText); +} diff --git a/src/OpenClaw.Agent/Plugins/NativePluginRegistry.cs b/src/OpenClaw.Agent/Plugins/NativePluginRegistry.cs index 4aeecd0..1695a3d 100644 --- a/src/OpenClaw.Agent/Plugins/NativePluginRegistry.cs +++ b/src/OpenClaw.Agent/Plugins/NativePluginRegistry.cs @@ -14,6 +14,7 @@ public sealed class NativePluginRegistry : IDisposable { private readonly List _tools = []; private readonly Dictionary _nativeToolIds = new(StringComparer.Ordinal); + private readonly List _ownedResources = []; private readonly ILogger _logger; public NativePluginRegistry(NativePluginsConfig config, ILogger logger, ToolingConfig? toolingConfig = null) @@ -68,7 +69,25 @@ private void RegisterTool(ITool tool, string pluginId, string? detail = null) if (_nativeToolIds.ContainsKey(tool.Name)) { _logger.LogWarning("Duplicate native tool name '{ToolName}' from plugin '{PluginId}' — overwriting previous registration", tool.Name, pluginId); + var displacedTools = _tools.Where(t => t.Name == tool.Name).ToArray(); _tools.RemoveAll(t => t.Name == tool.Name); + foreach (var displaced in displacedTools) + { + if (!ReferenceEquals(displaced, tool) && displaced is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to dispose displaced native tool '{ToolName}' while registering plugin '{PluginId}'", + tool.Name, + pluginId); + } + } + } } _tools.Add(tool); @@ -77,6 +96,17 @@ private void RegisterTool(ITool tool, string pluginId, string? detail = null) pluginId, detail is not null ? $" ({detail})" : ""); } + public void RegisterExternalTool(ITool tool, string pluginId, string? detail = null) + => RegisterTool(tool, pluginId, detail); + + public void RegisterOwnedResource(IDisposable resource) + { + ArgumentNullException.ThrowIfNull(resource); + if (_ownedResources.Any(existing => ReferenceEquals(existing, resource))) + return; + _ownedResources.Add(resource); + } + /// /// All enabled native plugin tools. /// @@ -200,7 +230,28 @@ public void Dispose() foreach (var tool in _tools) { if (tool is IDisposable d) - d.Dispose(); + { + try + { + d.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispose native tool '{ToolName}' during registry shutdown", tool.Name); + } + } + } + + foreach (var resource in _ownedResources) + { + try + { + resource.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispose owned native-plugin resource during registry shutdown"); + } } } } diff --git a/src/OpenClaw.Agent/Tools/McpNativeTool.cs b/src/OpenClaw.Agent/Tools/McpNativeTool.cs new file mode 100644 index 0000000..1a2bdb4 --- /dev/null +++ b/src/OpenClaw.Agent/Tools/McpNativeTool.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using OpenClaw.Core.Abstractions; + +namespace OpenClaw.Agent.Tools; + +public sealed class McpNativeTool( + McpClient client, + string localName, + string remoteName, + string description, + string parameterSchema) : ITool +{ + public string Name => localName; + public string Description => description; + public string ParameterSchema => parameterSchema; + + public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + { + try + { + using var argsDoc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + if (argsDoc.RootElement.ValueKind != JsonValueKind.Object) + return $"Error: Invalid JSON arguments for MCP tool '{localName}': JSON root must be an object."; + var argsDict = new Dictionary(StringComparer.Ordinal); + foreach (var prop in argsDoc.RootElement.EnumerateObject()) + { + object? value = null; + var v = prop.Value; + switch (v.ValueKind) + { + case JsonValueKind.String: + value = v.GetString(); + break; + case JsonValueKind.Number: + value = v.Clone(); + break; + case JsonValueKind.True: + case JsonValueKind.False: + value = v.GetBoolean(); + break; + case JsonValueKind.Null: + value = null; + break; + default: + value = v.Clone(); + break; + } + argsDict[prop.Name] = value; + } + var response = await client.CallToolAsync(remoteName, argsDict, progress: null, cancellationToken: ct); + var text = FormatResponseContent(response); + var isError = response.IsError ?? false; + return isError ? $"Error: {text}" : text; + } + catch (JsonException ex) + { + return $"Error: Invalid JSON arguments for MCP tool '{localName}': {ex.Message}"; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return $"Error: MCP tool '{localName}' failed: {ex.Message}"; + } + } + + private static string FormatResponseContent(CallToolResult response) + { + var parts = new List(); + + foreach (var item in response.Content ?? []) + { + switch (item) + { + case TextContentBlock textBlock when !string.IsNullOrEmpty(textBlock.Text): + parts.Add(textBlock.Text); + break; + case EmbeddedResourceBlock { Resource: TextResourceContents resource } when !string.IsNullOrEmpty(resource.Text): + parts.Add(resource.Text); + break; + default: + parts.Add(JsonSerializer.Serialize(item, McpToolSerializerContext.Default.ContentBlock)); + break; + } + } + + if (response.StructuredContent is { } structuredContent && + structuredContent.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Null)) + { + parts.Add(structuredContent.GetRawText()); + } + + return string.Join("\n\n", parts); + } +} + +[JsonSerializable(typeof(ContentBlock))] +[JsonSerializable(typeof(TextContentBlock))] +[JsonSerializable(typeof(ImageContentBlock))] +[JsonSerializable(typeof(AudioContentBlock))] +[JsonSerializable(typeof(EmbeddedResourceBlock))] +[JsonSerializable(typeof(ResourceLinkBlock))] +[JsonSerializable(typeof(ToolUseContentBlock))] +[JsonSerializable(typeof(ToolResultContentBlock))] +[JsonSerializable(typeof(ResourceContents))] +[JsonSerializable(typeof(TextResourceContents))] +[JsonSerializable(typeof(BlobResourceContents))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false)] +internal sealed partial class McpToolSerializerContext : JsonSerializerContext; diff --git a/src/OpenClaw.Core/Plugins/PluginModels.cs b/src/OpenClaw.Core/Plugins/PluginModels.cs index 2b3e484..d51d65c 100644 --- a/src/OpenClaw.Core/Plugins/PluginModels.cs +++ b/src/OpenClaw.Core/Plugins/PluginModels.cs @@ -106,10 +106,53 @@ public sealed class PluginsConfig /// Configuration for native plugin replicas. public NativePluginsConfig Native { get; set; } = new(); + /// Configuration for MCP servers exposed as native tools. + public McpPluginsConfig Mcp { get; set; } = new(); + /// Configuration for in-process dynamic .NET plugins. JIT mode only. public NativeDynamicPluginsConfig DynamicNative { get; set; } = new(); } +public sealed class McpPluginsConfig +{ + public bool Enabled { get; set; } = false; + public Dictionary Servers { get; set; } = new(StringComparer.Ordinal); +} + +public sealed class McpServerConfig +{ + public bool Enabled { get; set; } = true; + public string? Name { get; set; } + public string? Transport { get; set; } + public string? Command { get; set; } + public string[] Arguments { get; set; } = []; + public string? WorkingDirectory { get; set; } + public Dictionary Environment { get; set; } = new(StringComparer.Ordinal); + public string? Url { get; set; } + public Dictionary Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public string? ToolNamePrefix { get; set; } + public int StartupTimeoutSeconds { get; set; } = 15; + public int RequestTimeoutSeconds { get; set; } = 60; +} + +public static class McpServerConfigExtensions +{ + public static string NormalizeTransport(this McpServerConfig config) + { + var transport = config.Transport?.Trim(); + if (string.IsNullOrWhiteSpace(transport)) + return string.IsNullOrWhiteSpace(config.Url) ? "stdio" : "http"; + + if (transport.Equals("streamable-http", StringComparison.OrdinalIgnoreCase) || + transport.Equals("streamable_http", StringComparison.OrdinalIgnoreCase)) + { + return "http"; + } + + return transport.ToLowerInvariant(); + } +} + /// /// Configuration for native (C#) replicas of popular OpenClaw plugins. /// Each property matches the canonical plugin id. diff --git a/src/OpenClaw.Core/Validation/ConfigValidator.cs b/src/OpenClaw.Core/Validation/ConfigValidator.cs index 9c39388..e290dfb 100644 --- a/src/OpenClaw.Core/Validation/ConfigValidator.cs +++ b/src/OpenClaw.Core/Validation/ConfigValidator.cs @@ -1,5 +1,6 @@ using OpenClaw.Core.Security; using OpenClaw.Core.Models; +using OpenClaw.Core.Plugins; namespace OpenClaw.Core.Validation; @@ -166,6 +167,46 @@ public static IReadOnlyList Validate(Models.GatewayConfig config) if (runtimeOrchestrator is not (RuntimeOrchestrator.Native or RuntimeOrchestrator.Maf)) errors.Add("Runtime.Orchestrator must be 'native' or 'maf'."); + // MCP plugin servers + if (config.Plugins.Mcp.Enabled) + { + if (config.Plugins.Mcp.Servers is null) + { + errors.Add("Plugins.Mcp.Servers must be provided when MCP is enabled."); + } + else + { + foreach (var (serverId, server) in config.Plugins.Mcp.Servers) + { + if (!server.Enabled) + continue; + + var transport = server.NormalizeTransport(); + if (transport is not ("stdio" or "http")) + { + errors.Add($"Plugins.Mcp.Servers.{serverId}.Transport must be 'stdio' or 'http'."); + continue; + } + + if (server.StartupTimeoutSeconds < 1) + errors.Add($"Plugins.Mcp.Servers.{serverId}.StartupTimeoutSeconds must be >= 1 (got {server.StartupTimeoutSeconds})."); + if (server.RequestTimeoutSeconds < 1) + errors.Add($"Plugins.Mcp.Servers.{serverId}.RequestTimeoutSeconds must be >= 1 (got {server.RequestTimeoutSeconds})."); + + if (transport == "stdio") + { + if (string.IsNullOrWhiteSpace(server.Command)) + errors.Add($"Plugins.Mcp.Servers.{serverId}.Command must be set when Transport='stdio'."); + } + else if (!Uri.TryCreate(server.Url, UriKind.Absolute, out var url) || + (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps)) + { + errors.Add($"Plugins.Mcp.Servers.{serverId}.Url must be an absolute http(s) URL when Transport='http'."); + } + } + } + } + // Channels if (config.Channels.Sms.Twilio.MaxInboundChars < 1) errors.Add($"Channels.Sms.Twilio.MaxInboundChars must be >= 1 (got {config.Channels.Sms.Twilio.MaxInboundChars})."); diff --git a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs index 5beaa1b..463ac9c 100644 --- a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs @@ -62,6 +62,7 @@ public static async Task InitializeOpenClawRuntimeAsync( var pipeline = app.Services.GetRequiredService(); var wsChannel = app.Services.GetRequiredService(); var nativeRegistry = app.Services.GetRequiredService(); + var mcpRegistry = app.Services.GetRequiredService(); var runtimeDiagnostics = new Dictionary>(StringComparer.Ordinal); var dynamicProviderOwners = new HashSet(StringComparer.Ordinal); var blockedPluginIds = pluginHealth.GetBlockedPluginIds(); @@ -119,6 +120,10 @@ public static async Task InitializeOpenClawRuntimeAsync( loggerFactory.CreateLogger()); var builtInTools = CreateBuiltInTools(config, memoryStore, sessionManager, pipeline, startup.WorkspacePath); + if (config.Plugins.Mcp.Enabled) + { + await mcpRegistry.RegisterToolsAsync(nativeRegistry, app.Lifetime.ApplicationStopping); + } LlmClientFactory.ResetDynamicProviders(); try { diff --git a/src/OpenClaw.Gateway/Composition/ToolServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/ToolServicesExtensions.cs index e16efa0..4172f51 100644 --- a/src/OpenClaw.Gateway/Composition/ToolServicesExtensions.cs +++ b/src/OpenClaw.Gateway/Composition/ToolServicesExtensions.cs @@ -12,6 +12,10 @@ public static IServiceCollection AddOpenClawToolServices(this IServiceCollection startup.Config.Plugins.Native, sp.GetRequiredService().CreateLogger(), startup.Config.Tooling)); + services.AddSingleton(sp => + new McpServerToolRegistry( + startup.Config.Plugins.Mcp, + sp.GetRequiredService().CreateLogger())); return services; } diff --git a/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs b/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs index 2958d89..79190c9 100644 --- a/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs +++ b/src/OpenClaw.Gateway/Extensions/GatewaySecurityExtensions.cs @@ -28,7 +28,7 @@ public static void EnforcePublicBindHardening(GatewayConfig config, bool isNonLo "or explicitly opt in via OpenClaw:Security:AllowUnsafeToolingOnPublicBind=true."); } - if ((config.Plugins.Enabled || config.Plugins.DynamicNative.Enabled) && !config.Security.AllowPluginBridgeOnPublicBind) + if ((config.Plugins.Enabled || config.Plugins.DynamicNative.Enabled || config.Plugins.Mcp.Enabled) && !config.Security.AllowPluginBridgeOnPublicBind) { throw new InvalidOperationException( "Refusing to start with third-party plugin execution enabled on a non-loopback bind. " + diff --git a/src/OpenClaw.Gateway/appsettings.Production.json b/src/OpenClaw.Gateway/appsettings.Production.json index 7d8bb81..dca5160 100644 --- a/src/OpenClaw.Gateway/appsettings.Production.json +++ b/src/OpenClaw.Gateway/appsettings.Production.json @@ -68,7 +68,10 @@ }, "Plugins": { - "Enabled": false + "Enabled": false, + "Mcp": { + "Enabled": false + } }, "MaxConcurrentSessions": 128, diff --git a/src/OpenClaw.Gateway/appsettings.json b/src/OpenClaw.Gateway/appsettings.json index 6194a6c..64d83cd 100644 --- a/src/OpenClaw.Gateway/appsettings.json +++ b/src/OpenClaw.Gateway/appsettings.json @@ -159,6 +159,18 @@ "ImapOperationTimeoutSeconds": 0, "MaxResponseLinesPerCommand": 10000 } + }, + "Mcp": { + "Enabled": false, + "Servers": { + "example": { + "Enabled": false, + "Name": "example", + "Transport": "http", + "Url": "http://127.0.0.1:3001/mcp", + "ToolNamePrefix": "example." + } + } } }, "Channels": { diff --git a/src/OpenClaw.Tests/ConfigValidatorTests.cs b/src/OpenClaw.Tests/ConfigValidatorTests.cs index 5081a7e..8ef2ec0 100644 --- a/src/OpenClaw.Tests/ConfigValidatorTests.cs +++ b/src/OpenClaw.Tests/ConfigValidatorTests.cs @@ -219,6 +219,92 @@ public void Validate_InvalidRuntimeOrchestrator_ReturnsError() Assert.Contains(errors, e => e.Contains("Runtime.Orchestrator", StringComparison.Ordinal)); } + [Fact] + public void Validate_McpHttpServerWithoutUrl_ReturnsError() + { + var config = new GatewayConfig + { + Plugins = new PluginsConfig + { + Mcp = new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = "" + } + } + } + } + }; + + var errors = ConfigValidator.Validate(config); + Assert.Contains(errors, e => e.Contains("Plugins.Mcp.Servers.demo.Url", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_McpStdioServerWithoutCommand_ReturnsError() + { + var config = new GatewayConfig + { + Plugins = new PluginsConfig + { + Mcp = new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "stdio", + Command = "" + } + } + } + } + }; + + var errors = ConfigValidator.Validate(config); + Assert.Contains(errors, e => e.Contains("Plugins.Mcp.Servers.demo.Command", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_DisabledMcpServerWithMissingRequiredFields_DoesNotReturnError() + { + var config = new GatewayConfig + { + Plugins = new PluginsConfig + { + Mcp = new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["stdio-disabled"] = new() + { + Enabled = false, + Transport = "stdio", + Command = "" + }, + ["http-disabled"] = new() + { + Enabled = false, + Transport = "http", + Url = "" + } + } + } + } + }; + + var errors = ConfigValidator.Validate(config); + Assert.DoesNotContain(errors, e => e.Contains("Plugins.Mcp.Servers.stdio-disabled", StringComparison.Ordinal)); + Assert.DoesNotContain(errors, e => e.Contains("Plugins.Mcp.Servers.http-disabled", StringComparison.Ordinal)); + } + [Fact] public void Validate_OpenSandboxProviderWithoutEndpoint_ReturnsError() { diff --git a/src/OpenClaw.Tests/McpServerToolRegistryTests.cs b/src/OpenClaw.Tests/McpServerToolRegistryTests.cs new file mode 100644 index 0000000..ed4f6cb --- /dev/null +++ b/src/OpenClaw.Tests/McpServerToolRegistryTests.cs @@ -0,0 +1,480 @@ +using System.Text; +using System.Text.Json; +using System.ComponentModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using OpenClaw.Agent.Plugins; +using OpenClaw.Core.Models; +using OpenClaw.Core.Plugins; +using Xunit; + +namespace OpenClaw.Tests; + +public sealed class McpServerToolRegistryTests : IAsyncDisposable +{ + private readonly List _apps = []; + + [Fact] + public async Task LoadAsync_HttpServer_DiscoversAndExecutesTools() + { + var (serverUrl, calls) = await StartMcpServerAsync(); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + var tool = Assert.Single(nativeRegistry.Tools); + Assert.Equal("demo.echo", tool.Name); + Assert.Contains("Demo echo tool", tool.Description, StringComparison.Ordinal); + using (var schemaDocument = JsonDocument.Parse(tool.ParameterSchema)) + { + var schemaRoot = schemaDocument.RootElement; + Assert.Equal(JsonValueKind.Object, schemaRoot.ValueKind); + Assert.True(schemaRoot.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("text", out var textProperty)); + Assert.Equal(JsonValueKind.Object, textProperty.ValueKind); + } + Assert.Equal("demo:hello", await tool.ExecuteAsync("""{"text":"hello"}""", CancellationToken.None)); + Assert.True(calls.InitializeCalls >= 1); + Assert.True(calls.ListCalls >= 1); + Assert.True(calls.CallCalls >= 1); + } + + [Fact] + public async Task LoadAsync_HttpServer_WithHeaders_ResolvesSecrets() + { + Environment.SetEnvironmentVariable("TEST_AUTH_TOKEN", "secret-token-123"); + try + { + var (serverUrl, calls, receivedHeaders) = await StartMcpServerWithHeaderCheckAsync(); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl, + Headers = new Dictionary(StringComparer.Ordinal) + { + ["Authorization"] = "env:TEST_AUTH_TOKEN", + ["X-Custom-Header"] = "raw:literal-value", + ["X-Direct-Value"] = "direct-value" + } + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + // Verify headers were resolved and sent correctly + Assert.True(receivedHeaders.ContainsKey("Authorization")); + Assert.Equal("secret-token-123", receivedHeaders["Authorization"]); + Assert.True(receivedHeaders.ContainsKey("X-Custom-Header")); + Assert.Equal("literal-value", receivedHeaders["X-Custom-Header"]); + Assert.True(receivedHeaders.ContainsKey("X-Direct-Value")); + Assert.Equal("direct-value", receivedHeaders["X-Direct-Value"]); + } + finally + { + Environment.SetEnvironmentVariable("TEST_AUTH_TOKEN", null); + } + } + + [Fact] + public async Task LoadAsync_HttpServer_WithStructuredContentOnly_ReturnsStructuredJson() + { + var (serverUrl, _) = await StartMcpServerAsync(); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + var tool = Assert.Single(nativeRegistry.Tools); + var result = await tool.ExecuteAsync("{}", CancellationToken.None); + using var document = JsonDocument.Parse(result); + Assert.Equal(123, document.RootElement.GetProperty("value").GetInt32()); + Assert.Equal("ok", document.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task LoadAsync_HttpServer_WithImageContentBlock_ReturnsSerializedContent() + { + var (serverUrl, _) = await StartMcpServerAsync(); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + var tool = Assert.Single(nativeRegistry.Tools); + var result = await tool.ExecuteAsync("{}", CancellationToken.None); + Assert.Contains("image/png", result, StringComparison.Ordinal); + Assert.Contains("type", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RegisterToolsAsync_MultipleCalls_DoesNotRegisterSelfAsOwnedResource() + { + var (serverUrl, _) = await StartMcpServerAsync(); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + Assert.Single(nativeRegistry.Tools); + + var ownedResourcesField = typeof(NativePluginRegistry).GetField("_ownedResources", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var ownedResources = Assert.IsType>(ownedResourcesField?.GetValue(nativeRegistry)); + Assert.Empty(ownedResources); + } + + [Fact] + public async Task LoadAsync_ConcurrentCalls_LoadsToolsOnce() + { + var (serverUrl, calls) = await StartMcpServerAsync(); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }, + NullLogger.Instance); + + var loads = await Task.WhenAll( + registry.LoadAsync(CancellationToken.None), + registry.LoadAsync(CancellationToken.None), + registry.LoadAsync(CancellationToken.None), + registry.LoadAsync(CancellationToken.None)); + + Assert.All(loads, tools => Assert.Single(tools)); + Assert.Equal(1, calls.ListCalls); + } + + [Fact] + public async Task LoadAsync_WhenFirstAttemptFails_AllowsRetryAndLoadsTools() + { + var (serverUrl, _) = await StartMcpServerAsync(); + var config = new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["broken"] = new() + { + Transport = "invalid-transport" + }, + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }; + using var registry = new McpServerToolRegistry(config, NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await Assert.ThrowsAsync(() => registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None)); + var clientsField = typeof(McpServerToolRegistry).GetField("_clients", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var clientsAfterFailure = Assert.IsType>(clientsField?.GetValue(registry)); + Assert.Empty(clientsAfterFailure); + + config.Servers["broken"].Enabled = false; + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + var clientsAfterSuccess = Assert.IsType>(clientsField?.GetValue(registry)); + Assert.Single(clientsAfterSuccess); + + var tool = Assert.Single(nativeRegistry.Tools); + Assert.Equal("demo.echo", tool.Name); + } + + [Fact] + public async Task LoadAsync_UsesRequestTimeoutForToolListing_NotStartupTimeout() + { + var (serverUrl, _) = await StartMcpServerAsync(TimeSpan.FromSeconds(2)); + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl, + StartupTimeoutSeconds = 1, + RequestTimeoutSeconds = 5 + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + var tool = Assert.Single(nativeRegistry.Tools); + Assert.Equal("demo.echo", tool.Name); + } + + [Fact] + public async Task LoadAsync_AfterDispose_ThrowsObjectDisposedException() + { + using var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + }, + NullLogger.Instance); + + registry.Dispose(); + + await Assert.ThrowsAsync(() => registry.LoadAsync(CancellationToken.None)); + } + + [Fact] + public async Task Dispose_MayBeCalledTwice_AfterToolRegistration() + { + var (serverUrl, _) = await StartMcpServerAsync(); + var registry = new McpServerToolRegistry( + new McpPluginsConfig + { + Enabled = true, + Servers = new Dictionary(StringComparer.Ordinal) + { + ["demo"] = new() + { + Transport = "http", + Url = serverUrl + } + } + }, + NullLogger.Instance); + using var nativeRegistry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance, new ToolingConfig()); + + await registry.RegisterToolsAsync(nativeRegistry, CancellationToken.None); + + var ex = Record.Exception(() => + { + registry.Dispose(); + registry.Dispose(); + }); + + Assert.Null(ex); + } + + public async ValueTask DisposeAsync() + { + foreach (var app in _apps) + await app.DisposeAsync(); + } + + private async Task<(string ServerUrl, McpCallTracker Tracker)> StartMcpServerAsync(TimeSpan? toolsListDelay = null) + where TTools : class + { + var tracker = new McpCallTracker(); + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(tracker); + builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = "demo", + Version = "1.0.0" + }; + }) + .WithHttpTransport(options => { options.Stateless = true; }) + .WithTools(); + var app = builder.Build(); + app.Use(async (context, next) => + { + await TrackMcpMethodAsync(context, tracker, toolsListDelay); + await next(); + }); + app.MapMcp("/mcp"); + + await app.StartAsync(); + _apps.Add(app); + var address = app.Urls.Single(); + return ($"{address.TrimEnd('/')}/mcp", tracker); + } + + private async Task<(string ServerUrl, McpCallTracker Tracker, Dictionary ReceivedHeaders)> StartMcpServerWithHeaderCheckAsync() + where TTools : class + { + var tracker = new McpCallTracker(); + var receivedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(tracker); + builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = "demo", + Version = "1.0.0" + }; + }) + .WithHttpTransport(options => { options.Stateless = true; }) + .WithTools(); + var app = builder.Build(); + app.Use(async (context, next) => + { + if (receivedHeaders.Count == 0 && + context.Request.Path.StartsWithSegments("/mcp", StringComparison.Ordinal)) + { + foreach (var header in context.Request.Headers) + { + receivedHeaders[header.Key] = header.Value.ToString(); + } + } + await TrackMcpMethodAsync(context, tracker, null); + await next(); + }); + app.MapMcp("/mcp"); + + await app.StartAsync(); + _apps.Add(app); + var address = app.Urls.Single(); + return ($"{address.TrimEnd('/')}/mcp", tracker, receivedHeaders); + } + + private static async Task TrackMcpMethodAsync(HttpContext context, McpCallTracker tracker, TimeSpan? toolsListDelay) + { + if (!context.Request.Path.StartsWithSegments("/mcp", StringComparison.Ordinal)) + return; + if (!HttpMethods.IsPost(context.Request.Method)) + return; + + context.Request.EnableBuffering(); + using var document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted); + context.Request.Body.Position = 0; + + if (!document.RootElement.TryGetProperty("method", out var methodElement) || methodElement.ValueKind != JsonValueKind.String) + return; + var method = methodElement.GetString(); + switch (method) + { + case "initialize": + tracker.InitializeCalls++; + break; + case "tools/list": + if (toolsListDelay is { } delay && delay > TimeSpan.Zero) + await Task.Delay(delay, context.RequestAborted); + tracker.ListCalls++; + break; + case "tools/call": + tracker.CallCalls++; + break; + } + } + + private sealed class McpCallTracker + { + public int InitializeCalls { get; set; } + public int ListCalls { get; set; } + public int CallCalls { get; set; } + } + + [McpServerToolType] + private sealed class DemoMcpTools + { + [McpServerTool(Name = "echo", ReadOnly = true), Description("Demo echo tool")] + public string Echo([Description("text")] string text) + => $"demo:{text}"; + } + + [McpServerToolType] + private sealed class StructuredMcpTools + { + [McpServerTool(Name = "structured", ReadOnly = true), Description("Structured response tool")] + public CallToolResult Structured() + => new() + { + StructuredContent = JsonSerializer.SerializeToElement(new { value = 123, status = "ok" }) + }; + } + + [McpServerToolType] + private sealed class BinaryMcpTools + { + [McpServerTool(Name = "image", ReadOnly = true), Description("Image response tool")] + public CallToolResult Image() + => new() + { + Content = [ImageContentBlock.FromBytes("png-bytes"u8.ToArray(), "image/png")] + }; + } +} diff --git a/src/OpenClaw.Tests/NativePluginTests.cs b/src/OpenClaw.Tests/NativePluginTests.cs index 7803e9c..3c042ca 100644 --- a/src/OpenClaw.Tests/NativePluginTests.cs +++ b/src/OpenClaw.Tests/NativePluginTests.cs @@ -116,6 +116,92 @@ public void Dispose_DoesNotThrow() var registry = new NativePluginRegistry(config, NullLogger.Instance); registry.Dispose(); // should not throw } + + [Fact] + public void RegisterExternalTool_NameCollision_DisposesDisplacedDisposableTool() + { + using var registry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance); + var first = new DisposableFakeTool("dup_tool"); + var second = new DisposableFakeTool("dup_tool"); + + registry.RegisterExternalTool(first, "mcp:first"); + registry.RegisterExternalTool(second, "mcp:second"); + + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(0, second.DisposeCalls); + Assert.Single(registry.Tools); + Assert.Same(second, registry.Tools[0]); + } + + [Fact] + public void RegisterExternalTool_NameCollision_DisposeFailureDoesNotAbortRegistration() + { + using var registry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance); + var first = new ThrowingDisposableFakeTool("dup_tool"); + var second = new DisposableFakeTool("dup_tool"); + + registry.RegisterExternalTool(first, "mcp:first"); + var ex = Record.Exception(() => registry.RegisterExternalTool(second, "mcp:second")); + + Assert.Null(ex); + Assert.Equal(1, first.DisposeCalls); + Assert.Single(registry.Tools); + Assert.Same(second, registry.Tools[0]); + } + + [Fact] + public void RegisterOwnedResource_Null_ThrowsArgumentNullException() + { + using var registry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance); + Assert.Throws(() => registry.RegisterOwnedResource(null!)); + } + + [Fact] + public void RegisterOwnedResource_SameInstanceTwice_DisposesOnce() + { + var registry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance); + var resource = new DisposableOwnedResource(); + + registry.RegisterOwnedResource(resource); + registry.RegisterOwnedResource(resource); + registry.Dispose(); + + Assert.Equal(1, resource.DisposeCalls); + } + + [Fact] + public void Dispose_ToolDisposeFailure_DoesNotPreventOwnedResourceCleanup() + { + var registry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance); + var tool = new ThrowingDisposableFakeTool("dup_tool"); + var resource = new DisposableOwnedResource(); + + registry.RegisterExternalTool(tool, "mcp:test"); + registry.RegisterOwnedResource(resource); + + var ex = Record.Exception(() => registry.Dispose()); + + Assert.Null(ex); + Assert.Equal(1, tool.DisposeCalls); + Assert.Equal(1, resource.DisposeCalls); + } + + [Fact] + public void Dispose_OwnedResourceDisposeFailure_DoesNotAbortRemainingCleanup() + { + var registry = new NativePluginRegistry(new NativePluginsConfig(), NullLogger.Instance); + var first = new ThrowingOwnedResource(); + var second = new DisposableOwnedResource(); + + registry.RegisterOwnedResource(first); + registry.RegisterOwnedResource(second); + + var ex = Record.Exception(() => registry.Dispose()); + + Assert.Null(ex); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } } public class PluginPreferenceTests @@ -771,6 +857,50 @@ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct => ValueTask.FromResult("ok"); } +file sealed class DisposableFakeTool(string name) : ITool, IDisposable +{ + public int DisposeCalls { get; private set; } + public string Name => name; + public string Description => "disposable-fake"; + public string ParameterSchema => "{}"; + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("ok"); + public void Dispose() + => DisposeCalls++; +} + +file sealed class ThrowingDisposableFakeTool(string name) : ITool, IDisposable +{ + public int DisposeCalls { get; private set; } + public string Name => name; + public string Description => "throwing-disposable-fake"; + public string ParameterSchema => "{}"; + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("ok"); + public void Dispose() + { + DisposeCalls++; + throw new InvalidOperationException("dispose failed"); + } +} + +file sealed class DisposableOwnedResource : IDisposable +{ + public int DisposeCalls { get; private set; } + public void Dispose() + => DisposeCalls++; +} + +file sealed class ThrowingOwnedResource : IDisposable +{ + public int DisposeCalls { get; private set; } + public void Dispose() + { + DisposeCalls++; + throw new InvalidOperationException("owned resource dispose failed"); + } +} + /// Minimal ILogger for tests. file sealed class NullLogger : Microsoft.Extensions.Logging.ILogger {