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
{