From 1494d3e02328b427a801f98f86243743f4278b2d Mon Sep 17 00:00:00 2001 From: geffzhang Date: Wed, 30 Sep 2026 13:31:34 +0800 Subject: [PATCH 1/2] Forward MCP app session metadata in tool calls MCP App native tools now implement `IToolWithContext` and include `_meta.sessionId`/`_meta.userId` on `tools/call` requests. Metadata prefers explicit `ToolExecutionContext.Session` and falls back to an ambient session bridged from `AgentExecutionContext` through a new Core `ToolSessionContextScope` AsyncLocal scope. The tool call path was switched to explicit `SendRequestAsync` params so metadata can be attached without changing tool arguments. Tests and MCPAPP docs were updated to cover forwarding behavior, no-session behavior, and scope restoration. --- docs/MCPAPP.md | 7 +- src/OpenClaw.Agent/AgentExecutionContext.cs | 10 +- .../Abstractions/ToolSessionContextScope.cs | 22 +++++ src/OpenClaw.Tests/McpAppTests.cs | 99 +++++++++++++++++++ .../OpenClaw.McpApp/McpAppToolProvider.cs | 49 +++++---- 5 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 src/OpenClaw.Core/Abstractions/ToolSessionContextScope.cs diff --git a/docs/MCPAPP.md b/docs/MCPAPP.md index 6990db88..48f00e32 100644 --- a/docs/MCPAPP.md +++ b/docs/MCPAPP.md @@ -98,7 +98,7 @@ McpAppServer Produces an IMcpAppInfoProvider │ ▼ -McpAppNativeTool (implements ITool) +McpAppNativeTool (implements IToolWithContext) Registered in NativePluginRegistry Agent can call MCP App tools the same as built-in tools ``` @@ -269,15 +269,18 @@ Discovered ──→ Validated ──→ Loaded ──→ Running ## Tool Bridging -Every tool discovered from an MCP App is registered into the `NativePluginRegistry` via `McpAppNativeTool` (which implements `ITool`): +Every tool discovered from an MCP App is registered into the `NativePluginRegistry` via `McpAppNativeTool` (which implements `IToolWithContext`, extending `ITool`): - **Registration ID**: `mcpapp:{appId}` (e.g. `mcpapp:grocery-inventory`) - **Tool name prefix**: automatically applied from the manifest's `toolNamePrefix` +- **Session metadata**: Agent tool calls forward `_meta.sessionId` and `_meta.userId`, preferring the explicit `ToolExecutionContext` session over the ambient runtime session. `userId` uses `AuthenticatedUserId` when available, otherwise `SenderId`. These values are protocol metadata, not tool arguments; calls without a session do not inject either value. - **Visibility handling**: tools marked with MCP App visibility metadata such as `"ui": { "visibility": ["app"] }` stay available to the browser host but are not registered as model-visible Agent tools - **Error handling**: invalid JSON arguments and remote execution errors return result text prefixed with `Error:` - **Schema strictness**: tools with missing/defaulted `inputSchema` are skipped at enumeration time instead of silently normalized - **Structured output**: supports both text content and structured content from MCP tools, except that UI tools suppress `structuredContent` when OpenClaw feeds results back into the model +Session metadata forwarding uses a lightweight Core `AsyncLocal` scope bridged by the Agent runtime. MCP App retains its Core-only project dependency, with no reflection-based or JIT-only additions. + ```csharp // Internal flow — automatically executed during Gateway startup await appRegistry.RegisterMcpAppToolsAsync( diff --git a/src/OpenClaw.Agent/AgentExecutionContext.cs b/src/OpenClaw.Agent/AgentExecutionContext.cs index 2c4e068b..3409f301 100644 --- a/src/OpenClaw.Agent/AgentExecutionContext.cs +++ b/src/OpenClaw.Agent/AgentExecutionContext.cs @@ -37,11 +37,15 @@ public static IDisposable Push(AgentExecutionContext context) { var prior = CurrentValue.Value; CurrentValue.Value = context; - return new RestoreScope(prior); + return new RestoreScope(prior, ToolSessionContextScope.Push(context.Session)); } - private sealed class RestoreScope(AgentExecutionContext? prior) : IDisposable + private sealed class RestoreScope(AgentExecutionContext? prior, IDisposable sessionScope) : IDisposable { - public void Dispose() => CurrentValue.Value = prior; + public void Dispose() + { + CurrentValue.Value = prior; + sessionScope.Dispose(); + } } } diff --git a/src/OpenClaw.Core/Abstractions/ToolSessionContextScope.cs b/src/OpenClaw.Core/Abstractions/ToolSessionContextScope.cs new file mode 100644 index 00000000..0b563db8 --- /dev/null +++ b/src/OpenClaw.Core/Abstractions/ToolSessionContextScope.cs @@ -0,0 +1,22 @@ +using OpenClaw.Core.Models; + +namespace OpenClaw.Core.Abstractions; + +public static class ToolSessionContextScope +{ + private static readonly AsyncLocal CurrentValue = new(); + + public static Session? Current => CurrentValue.Value; + + public static IDisposable Push(Session session) + { + var prior = CurrentValue.Value; + CurrentValue.Value = session; + return new RestoreScope(prior); + } + + private sealed class RestoreScope(Session? prior) : IDisposable + { + public void Dispose() => CurrentValue.Value = prior; + } +} \ No newline at end of file diff --git a/src/OpenClaw.Tests/McpAppTests.cs b/src/OpenClaw.Tests/McpAppTests.cs index 0bfb1b7b..cea2f98c 100644 --- a/src/OpenClaw.Tests/McpAppTests.cs +++ b/src/OpenClaw.Tests/McpAppTests.cs @@ -10,9 +10,11 @@ using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using OpenClaw.Agent; using OpenClaw.Agent.Plugins; using OpenClaw.Core.Abstractions; using OpenClaw.Core.Models; +using OpenClaw.Core.Observability; using OpenClaw.Core.Plugins; using OpenClaw.Gateway.Mcp; using OpenClaw.McpApp; @@ -735,6 +737,11 @@ public async Task NativeTool_Execute_InvokesRemoteTool() var result = await nativeTool.ExecuteAsync("{}", TestContext.Current.CancellationToken); Assert.DoesNotContain("Error:", result); Assert.True(calls.CallCalls >= 1); + if (calls.LastCallParams!.Value.TryGetProperty("_meta", out var meta)) + { + Assert.False(meta.TryGetProperty("userId", out _)); + Assert.False(meta.TryGetProperty("sessionId", out _)); + } } finally { @@ -742,6 +749,96 @@ public async Task NativeTool_Execute_InvokesRemoteTool() } } + [Theory] + [InlineData(false, null)] + [InlineData(false, "oidc-user-42")] + [InlineData(true, null)] + [InlineData(true, "oidc-user-42")] + public async Task NativeTool_Execute_ForwardsSessionMetadata(bool useExplicitContext, string? authenticatedUserId) + { + var (serverUrl, calls) = await StartMcpServerAsync(); + var state = new McpAppInstallState + { + Manifest = new McpAppManifest + { + Id = "test-grocery", + Version = "1.0", + Transport = "http", + Url = serverUrl, + }, + ManifestPath = "/f/openclaw.mcpapp.json", + RootPath = "/f", + }; + await using var server = new McpAppServer(state, null, NullLogger.Instance); + var infoProvider = await server.ConnectAsync(TestContext.Current.CancellationToken); + var descriptor = Assert.Single(infoProvider.GetToolDescriptors(), t => t.RemoteName == "get_inventory"); + ITool nativeTool = new McpAppNativeTool( + infoProvider.Client!, + descriptor.LocalName, + descriptor.RemoteName, + descriptor.Description, + descriptor.InputSchemaText, + infoProvider); + var session = new Session + { + Id = "mcp-app-session", + ChannelId = "test-channel", + SenderId = "route-sender", + AuthenticatedUserId = authenticatedUserId, + }; + var ambientSession = useExplicitContext + ? new Session { Id = "ambient-session", ChannelId = "test-channel", SenderId = "ambient-user" } + : session; + using var scope = AgentExecutionContextScope.Push(CreateAgentContext(ambientSession)); + const string arguments = """{"storeId":1,"productId":1}"""; + + var result = useExplicitContext + ? await Assert.IsAssignableFrom(nativeTool).ExecuteAsync( + arguments, + new ToolExecutionContext { Session = session, TurnContext = new TurnContext() }, + TestContext.Current.CancellationToken) + : await nativeTool.ExecuteAsync(arguments, TestContext.Current.CancellationToken); + + Assert.DoesNotContain("Error:", result); + var callParams = calls.LastCallParams!.Value; + var meta = callParams.GetProperty("_meta"); + Assert.Equal(session.Id, meta.GetProperty("sessionId").GetString()); + Assert.Equal(authenticatedUserId ?? session.SenderId, meta.GetProperty("userId").GetString()); + var sentArguments = callParams.GetProperty("arguments"); + Assert.Equal(2, sentArguments.EnumerateObject().Count()); + Assert.Equal(1, sentArguments.GetProperty("storeId").GetInt32()); + Assert.Equal(1, sentArguments.GetProperty("productId").GetInt32()); + } + + [Fact] + public void NativeTool_SessionScope_RestoresPriorSession() + { + var prior = ToolSessionContextScope.Current; + var outer = new Session { Id = "outer", ChannelId = "test", SenderId = "outer-user" }; + var inner = new Session { Id = "inner", ChannelId = "test", SenderId = "inner-user" }; + + using (AgentExecutionContextScope.Push(CreateAgentContext(outer))) + { + Assert.Same(outer, ToolSessionContextScope.Current); + using (AgentExecutionContextScope.Push(CreateAgentContext(inner))) + { + Assert.Same(inner, ToolSessionContextScope.Current); + } + Assert.Same(outer, ToolSessionContextScope.Current); + } + Assert.Same(prior, ToolSessionContextScope.Current); + } + + private static AgentExecutionContext CreateAgentContext(Session session) => new() + { + Session = session, + TurnContext = new TurnContext(), + SystemPromptLength = 0, + SkillPromptLength = 0, + SessionTokenBudget = 0, + ToolInvocations = [], + }; + [Fact] public async Task NativeTool_Execute_WithArguments_InvokesCorrectly() { @@ -1775,6 +1872,7 @@ private static async Task TrackMcpMethodAsync(HttpContext context, McpAppCallTra break; case "tools/call": tracker.CallCalls++; + tracker.LastCallParams = document.RootElement.GetProperty("params").Clone(); break; case "resources/list": tracker.ResourceListCalls++; @@ -1790,6 +1888,7 @@ private sealed class McpAppCallTracker public int InitializeCalls { get; set; } public int ListCalls { get; set; } public int CallCalls { get; set; } + public JsonElement? LastCallParams { get; set; } public int ResourceListCalls { get; set; } public int PromptListCalls { get; set; } } diff --git a/src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs b/src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs index b67fa945..66220314 100644 --- a/src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs +++ b/src/mcpapp/OpenClaw.McpApp/McpAppToolProvider.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -12,7 +13,7 @@ namespace OpenClaw.McpApp; /// Each instance represents a single tool from a single MCP App. /// Registered into the . /// -public sealed class McpAppNativeTool : ITool +public sealed class McpAppNativeTool : IToolWithContext { private readonly McpClient _client; private readonly string _remoteName; @@ -47,6 +48,12 @@ public McpAppNativeTool( public IMcpAppInfoProvider App => _app; public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => await ExecuteCoreAsync(argumentsJson, context: null, ct); + + public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct) + => await ExecuteCoreAsync(argumentsJson, context, ct); + + private async ValueTask ExecuteCoreAsync(string argumentsJson, ToolExecutionContext? context, CancellationToken ct) { try { @@ -56,13 +63,34 @@ public async ValueTask ExecuteAsync(string argumentsJson, CancellationTo if (argsDoc.RootElement.ValueKind != JsonValueKind.Object) return $"Error: Invalid JSON arguments for MCP App tool '{Name}': root must be an object."; - var argsDict = new Dictionary(StringComparer.Ordinal); + var argsDict = new Dictionary(StringComparer.Ordinal); foreach (var prop in argsDoc.RootElement.EnumerateObject()) { - argsDict[prop.Name] = ConvertJsonElement(prop.Value); + argsDict[prop.Name] = prop.Value.Clone(); + } + + var session = context?.Session ?? ToolSessionContextScope.Current; + JsonObject? meta = null; + if (session is not null) + { + meta = new JsonObject + { + ["userId"] = JsonValue.Create(session.AuthenticatedUserId ?? session.SenderId), + ["sessionId"] = JsonValue.Create(session.Id), + }; } - var response = await _client.CallToolAsync(_remoteName, argsDict, progress: null, cancellationToken: ct); + var callParams = new CallToolRequestParams + { + Name = _remoteName, + Arguments = argsDict, + Meta = meta, + }; + + var response = await _client.SendRequestAsync( + RequestMethods.ToolsCall, + callParams, + cancellationToken: ct); var text = FormatResponseContent(response, _suppressStructuredContent); var isError = response.IsError ?? false; return isError ? $"Error: {text}" : text; @@ -81,19 +109,6 @@ public async ValueTask ExecuteAsync(string argumentsJson, CancellationTo } } - private static object? ConvertJsonElement(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.Clone(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => element.Clone(), - }; - } - private static string FormatResponseContent(CallToolResult response, bool suppressStructuredContent) { var parts = new List(); From 5bb26921b8c61ebda21ec6733cfa8188fbf184ad Mon Sep 17 00:00:00 2001 From: geffzhang Date: Wed, 30 Sep 2026 14:04:16 +0800 Subject: [PATCH 2/2] Document MCP app test suite This patch adds XML documentation comments throughout McpAppTests and its helper fixtures to describe the purpose of each test, helper, and MCP server abstraction. The changes improve IDE IntelliSense and make the behavior of discovery, manifest validation, lifecycle transitions, tool registration, and server setup easier to follow without altering runtime behavior. --- src/OpenClaw.Tests/McpAppTests.cs | 99 +++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/OpenClaw.Tests/McpAppTests.cs b/src/OpenClaw.Tests/McpAppTests.cs index cea2f98c..63cd8749 100644 --- a/src/OpenClaw.Tests/McpAppTests.cs +++ b/src/OpenClaw.Tests/McpAppTests.cs @@ -31,6 +31,7 @@ public sealed class McpAppTests : IAsyncDisposable // ── McpAppManifest ────────────────────────────────────────── + /// Verifies that manifest serialization preserves configured fields. [Fact] public void Manifest_SerializeAndDeserialize_RoundTrips() { @@ -73,6 +74,7 @@ public void Manifest_SerializeAndDeserialize_RoundTrips() Assert.Equal("test.", deserialized.ToolNamePrefix); } + /// Verifies default manifest values when optional JSON fields are omitted. [Fact] public void Manifest_Deserialize_MinimalJson_DefaultsSensibly() { @@ -89,6 +91,7 @@ public void Manifest_Deserialize_MinimalJson_DefaultsSensibly() Assert.Contains("tools", manifest.Capabilities); } + /// Verifies deserialization of the stdio command and its arguments. [Fact] public void Manifest_Deserialize_WithStdioTransport_ReadsCommandAndArgs() { @@ -121,6 +124,7 @@ public void Manifest_Deserialize_WithStdioTransport_ReadsCommandAndArgs() Assert.Equal("test-key", manifest.Headers["x-api-key"]); } + /// Verifies that malformed manifest JSON raises a JSON parsing error. [Fact] public void Manifest_Deserialize_InvalidJson_ThrowsJsonException() { @@ -131,6 +135,7 @@ public void Manifest_Deserialize_InvalidJson_ThrowsJsonException() // ── McpAppInstallState ────────────────────────────────────── + /// Verifies the lifecycle and metadata defaults of a new installation state. [Fact] public void InstallState_NewState_HasCorrectDefaults() { @@ -153,6 +158,7 @@ public void InstallState_NewState_HasCorrectDefaults() Assert.Equal(0, state.DiscoveredPromptCount); } + /// Verifies that lifecycle transitions record their corresponding timestamps. [Fact] public void InstallState_LifecycleTransitions_UpdateTimestamps() { @@ -176,6 +182,7 @@ public void InstallState_LifecycleTransitions_UpdateTimestamps() Assert.Equal(discoveredAt, state.DiscoveredAt); // DiscoveredAt never changes } + /// Verifies that an installation records validation failures before loading. [Fact] public void InstallState_ValidationErrors_PreventLoading() { @@ -196,6 +203,7 @@ public void InstallState_ValidationErrors_PreventLoading() // ── McpAppInfoProvider ────────────────────────────────────── + /// Verifies that app information exposes the manifest's identity and configuration. [Fact] public void InfoProvider_BasicProperties_ReflectManifest() { @@ -228,6 +236,7 @@ public void InfoProvider_BasicProperties_ReflectManifest() Assert.Empty(provider.GetPromptDescriptors()); } + /// Verifies that an unnamed app uses its identifier as its display name. [Fact] public void InfoProvider_NameFallback_UsesIdWhenNameIsNull() { @@ -243,6 +252,7 @@ public void InfoProvider_NameFallback_UsesIdWhenNameIsNull() Assert.Equal("no-name-app", provider.DisplayName); } + /// Verifies descriptor storage and counts for tools, resources, and prompts. [Fact] public void InfoProvider_DescriptorManagement_TracksCounts() { @@ -335,6 +345,7 @@ public void InfoProvider_DescriptorManagement_TracksCounts() // ── McpAppDiscovery ───────────────────────────────────────── + /// Verifies that disabled discovery does not return any apps. [Fact] public void Discovery_Disabled_ReturnsEmptyList() { @@ -346,6 +357,7 @@ public void Discovery_Disabled_ReturnsEmptyList() Assert.Empty(results); } + /// Verifies that nonexistent discovery paths are ignored. [Fact] public void Discovery_NonExistentPath_ReturnsEmptyList() { @@ -361,6 +373,7 @@ public void Discovery_NonExistentPath_ReturnsEmptyList() Assert.Empty(results); } + /// Verifies discovery and validation of an app with a valid manifest. [Fact] public void Discovery_ValidManifest_DiscoversApp() { @@ -399,6 +412,7 @@ public void Discovery_ValidManifest_DiscoversApp() } } + /// Verifies that discovery finds manifests in nested app directories. [Fact] public void Discovery_MultipleAppsInNestedDirectories_FindsAll() { @@ -445,6 +459,7 @@ public void Discovery_MultipleAppsInNestedDirectories_FindsAll() } } + /// Verifies validation errors for manifests missing required fields. [Fact] public void Discovery_MissingRequiredFields_ReturnsInvalidState() { @@ -481,6 +496,7 @@ public void Discovery_MissingRequiredFields_ReturnsInvalidState() } } + /// Verifies rejection of an unknown manifest transport. [Fact] public void Discovery_InvalidTransport_ReturnsValidationError() { @@ -512,6 +528,7 @@ public void Discovery_InvalidTransport_ReturnsValidationError() } } + /// Verifies rejection of the unsupported in-process transport. [Fact] public void Discovery_InProcessTransport_ReturnsValidationError() { @@ -544,6 +561,7 @@ public void Discovery_InProcessTransport_ReturnsValidationError() } } + /// Verifies that discovery skips malformed manifest JSON. [Fact] public void Discovery_InvalidJson_IsSkipped() { @@ -571,6 +589,7 @@ public void Discovery_InvalidJson_IsSkipped() // ── McpAppDiscovery - Allow/Deny Filtering ───────────────── + /// Verifies that an allowlist admits matching app identifiers. [Fact] public void IsAppAllowed_AllowlistAllowsSpecificApps() { @@ -589,6 +608,7 @@ public void IsAppAllowed_AllowlistAllowsSpecificApps() Assert.Equal(McpAppLifecycle.Disabled, denied.Lifecycle); } + /// Verifies that a wildcard allowlist admits any app identifier. [Fact] public void IsAppAllowed_WildcardAllowlist_AllowsAll() { @@ -603,6 +623,7 @@ public void IsAppAllowed_WildcardAllowlist_AllowsAll() Assert.True(discovery.IsAppAllowed(CreateState("whatever"))); } + /// Verifies that a deny rule overrides a matching allow rule. [Fact] public void IsAppAllowed_DenyWinsOverAllow() { @@ -619,6 +640,7 @@ public void IsAppAllowed_DenyWinsOverAllow() Assert.False(discovery.IsAppAllowed(CreateState("also-blocked"))); } + /// Verifies wildcard matching in app allowlist patterns. [Fact] public void IsAppAllowed_GlobPatterns_MatchWildcards() { @@ -634,6 +656,7 @@ public void IsAppAllowed_GlobPatterns_MatchWildcards() Assert.True(discovery.IsAppAllowed(CreateState("normal-app"))); } + /// Verifies that an app-specific disabled entry prevents loading. [Fact] public void IsAppAllowed_EntryConfigDisablesApp() { @@ -652,6 +675,7 @@ public void IsAppAllowed_EntryConfigDisablesApp() Assert.Equal(McpAppLifecycle.Disabled, disabledState.Lifecycle); } + /// Verifies that empty legacy allow and deny lists admit all apps. [Fact] public void IsAppAllowed_EmptyAllowAndDeny_AllowsAll() { @@ -661,6 +685,7 @@ public void IsAppAllowed_EmptyAllowAndDeny_AllowsAll() Assert.True(discovery.IsAppAllowed(CreateState("anything"))); } + /// Verifies that an empty strict allowlist denies all apps. [Fact] public void IsAppAllowed_StrictEmptyAllowlist_DeniesAll() { @@ -679,6 +704,7 @@ public void IsAppAllowed_StrictEmptyAllowlist_DeniesAll() e => e.Contains("allowlist", StringComparison.OrdinalIgnoreCase)); } + /// Verifies fail-closed behavior for unsupported allowlist semantics. [Fact] public void IsAppAllowed_UnsupportedAllowlistSemantics_DeniesApp() { @@ -699,6 +725,7 @@ public void IsAppAllowed_UnsupportedAllowlistSemantics_DeniesApp() // ── McpAppNativeTool (with HTTP MCP server) ──────────────── + /// Verifies remote execution without injecting user or session metadata when no context exists. [Fact] public async Task NativeTool_Execute_InvokesRemoteTool() { @@ -749,6 +776,9 @@ public async Task NativeTool_Execute_InvokesRemoteTool() } } + /// Verifies session metadata forwarding, identity precedence, and separation from tool arguments. + /// Whether to supply an explicit session that overrides the ambient session. + /// The authenticated identity, or null to test fallback to the sender identifier. [Theory] [InlineData(false, null)] [InlineData(false, "oidc-user-42")] @@ -810,6 +840,7 @@ public async Task NativeTool_Execute_ForwardsSessionMetadata(bool useExplicitCon Assert.Equal(1, sentArguments.GetProperty("productId").GetInt32()); } + /// Verifies that disposing nested Agent scopes restores each preceding tool session. [Fact] public void NativeTool_SessionScope_RestoresPriorSession() { @@ -829,6 +860,9 @@ public void NativeTool_SessionScope_RestoresPriorSession() Assert.Same(prior, ToolSessionContextScope.Current); } + /// Creates a minimal Agent execution context for session-scoping tests. + /// The session to expose through the Agent scope. + /// An execution context with empty invocation and token-budget state. private static AgentExecutionContext CreateAgentContext(Session session) => new() { Session = session, @@ -839,6 +873,7 @@ public void NativeTool_SessionScope_RestoresPriorSession() ToolInvocations = [], }; + /// Verifies that JSON tool arguments are accepted by the remote MCP tool. [Fact] public async Task NativeTool_Execute_WithArguments_InvokesCorrectly() { @@ -882,6 +917,7 @@ public async Task NativeTool_Execute_WithArguments_InvokesCorrectly() } } + /// Verifies that malformed tool arguments produce an error result. [Fact] public async Task NativeTool_Execute_InvalidJson_ReturnsError() { @@ -924,6 +960,7 @@ public async Task NativeTool_Execute_InvalidJson_ReturnsError() } } + /// Verifies that an array is rejected where a tool argument object is required. [Fact] public async Task NativeTool_Execute_ArrayJson_ReturnsError() { @@ -966,6 +1003,7 @@ public async Task NativeTool_Execute_ArrayJson_ReturnsError() } } + /// Verifies preservation of UI metadata in connected tool descriptors. [Fact] public async Task Server_ConnectAsync_PreservesToolUiMetadata() { @@ -999,6 +1037,7 @@ public async Task Server_ConnectAsync_PreservesToolUiMetadata() } } + /// Verifies that local tool names are sanitized for model-facing use. [Fact] public async Task Server_ConnectAsync_SanitizesLlmToolNames() { @@ -1034,6 +1073,7 @@ public async Task Server_ConnectAsync_SanitizesLlmToolNames() } } + /// Verifies normalization and retention of tools with missing input schemas. [Fact] public async Task Server_ConnectAsync_ToolMissingInputSchema_IsNormalizedAndRetained() { @@ -1067,6 +1107,7 @@ public async Task Server_ConnectAsync_ToolMissingInputSchema_IsNormalizedAndReta } } + /// Verifies that tools with non-object input schemas are omitted. [Fact] public async Task Server_ConnectAsync_ToolWithNonObjectSchema_IsSkipped() { @@ -1098,6 +1139,7 @@ public async Task Server_ConnectAsync_ToolWithNonObjectSchema_IsSkipped() } } + /// Verifies that browser-only tools are not registered for Agent use. [Fact] public async Task RegisterMcpAppToolsAsync_SkipsAppOnlyTools() { @@ -1134,6 +1176,7 @@ public async Task RegisterMcpAppToolsAsync_SkipsAppOnlyTools() } } + /// Verifies that registered UI tools suppress structured output sent to the model. [Fact] public async Task RegisterMcpAppToolsAsync_UiToolsSuppressStructuredContent() { @@ -1175,6 +1218,7 @@ public async Task RegisterMcpAppToolsAsync_UiToolsSuppressStructuredContent() // ── McpAppServer ──────────────────────────────────────────── + /// Verifies capability enumeration when an MCP App connects. [Fact] public async Task Server_ConnectAsync_EnumeratesAllCapabilities() { @@ -1209,6 +1253,7 @@ public async Task Server_ConnectAsync_EnumeratesAllCapabilities() } } + /// Verifies that repeated connections reuse the existing app information provider. [Fact] public async Task Server_ConnectAsync_Twice_ReturnsSameProvider() { @@ -1241,6 +1286,7 @@ public async Task Server_ConnectAsync_Twice_ReturnsSameProvider() } } + /// Verifies that disconnecting an app transitions its lifecycle to stopped. [Fact] public async Task Server_DisconnectAsync_TransitionsToStopped() { @@ -1277,6 +1323,7 @@ public async Task Server_DisconnectAsync_TransitionsToStopped() } } + /// Verifies that an invalid stdio command fails and records a failed lifecycle state. [Fact] public async Task Server_ConnectWithInvalidCommand_FailsWithFailedState() { @@ -1304,6 +1351,7 @@ await Assert.ThrowsAnyAsync(() => Assert.NotNull(state.LastError); } + /// Verifies that disposing an app server disconnects its MCP client. [Fact] public async Task Server_Dispose_DisconnectsClient() { @@ -1332,6 +1380,7 @@ public async Task Server_Dispose_DisconnectsClient() Assert.Null(infoProvider.Client); } + /// Verifies application of the manifest's local tool name prefix. [Fact] public async Task Server_ToolNamePrefix_IsAppliedFromManifest() { @@ -1365,6 +1414,7 @@ public async Task Server_ToolNamePrefix_IsAppliedFromManifest() } } + /// Verifies that an entry-configured tool prefix overrides the manifest prefix. [Fact] public async Task Server_ToolNamePrefix_EntryConfigOverridesManifest() { @@ -1399,6 +1449,7 @@ public async Task Server_ToolNamePrefix_EntryConfigOverridesManifest() } } + /// Verifies case-insensitive handling of transport overrides. [Fact] public async Task Server_TransportOverride_IsCaseInsensitive() { @@ -1434,6 +1485,7 @@ public async Task Server_TransportOverride_IsCaseInsensitive() // ── McpAppRegistry ───────────────────────────────────────── + /// Verifies that the registry loads apps discovered from valid manifests. [Fact] public async Task Registry_LoadAllAsync_WithValidManifests_LoadsApps() { @@ -1470,6 +1522,7 @@ public async Task Registry_LoadAllAsync_WithValidManifests_LoadsApps() } } + /// Verifies that looking up an unknown app returns null. [Fact] public async Task Registry_GetApp_ReturnsNullForUnknownApp() { @@ -1482,6 +1535,7 @@ public async Task Registry_GetApp_ReturnsNullForUnknownApp() Assert.Null(registry.GetApp("non-existent")); } + /// Verifies that repeated registry loads do not reconnect already loaded apps. [Fact] public async Task Registry_LoadAllAsync_OnlyLoadsOnce() { @@ -1527,6 +1581,7 @@ public async Task Registry_LoadAllAsync_OnlyLoadsOnce() } } + /// Verifies server cleanup and safe repeated disposal of the registry. [Fact] public async Task Registry_Dispose_CleansUpServers() { @@ -1543,6 +1598,7 @@ public async Task Registry_Dispose_CleansUpServers() // ── Config Models ─────────────────────────────────────────── + /// Verifies default discovery, enablement, and allowlist settings. [Fact] public void McpAppsConfig_DefaultSettings() { @@ -1556,6 +1612,7 @@ public void McpAppsConfig_DefaultSettings() Assert.Empty(config.Entries); } + /// Verifies default enablement and unset per-app configuration overrides. [Fact] public void McpAppEntryConfig_DefaultSettings() { @@ -1573,6 +1630,7 @@ public void McpAppEntryConfig_DefaultSettings() // ── Descriptor Models ─────────────────────────────────────── + /// Verifies the default input schema and absent UI resource URI of a tool descriptor. [Fact] public void ToolDescriptor_DefaultSchemaText() { @@ -1587,6 +1645,7 @@ public void ToolDescriptor_DefaultSchemaText() Assert.Null(descriptor.UiResourceUri); } + /// Verifies explicit UI resource classification and its default value. [Fact] public void ResourceDescriptor_IsUiResource() { @@ -1610,6 +1669,7 @@ public void ResourceDescriptor_IsUiResource() Assert.False(jsonResource.IsUiResource); } + /// Verifies prompt argument storage and required-argument flags. [Fact] public void PromptDescriptor_WithArguments() { @@ -1633,6 +1693,8 @@ public void PromptDescriptor_WithArguments() // ── Helpers ───────────────────────────────────────────────── + /// Creates an isolated directory for test manifests. + /// The path of the newly created temporary directory. private static string CreateTempDir() { var path = Path.Combine(Path.GetTempPath(), "openclaw-mcpapp-tests", Guid.NewGuid().ToString("N")); @@ -1640,6 +1702,10 @@ private static string CreateTempDir() return path; } + /// Creates a temporary app directory containing the supplied manifest. + /// The app subdirectory name. + /// The manifest to serialize into the directory. + /// The path of the app directory. private static string CreateTempManifestDir(string dirName, McpAppManifest manifest) { var dir = Path.Combine(CreateTempDir(), dirName); @@ -1648,6 +1714,9 @@ private static string CreateTempManifestDir(string dirName, McpAppManifest manif return dir; } + /// Writes a manifest using the app discovery filename and generated JSON serializer. + /// The existing destination directory. + /// The manifest to serialize. private static void WriteManifestFile(string directory, McpAppManifest manifest) { var path = Path.Combine(directory, McpAppDiscovery.ManifestFileName); @@ -1655,6 +1724,8 @@ private static void WriteManifestFile(string directory, McpAppManifest manifest) File.WriteAllText(path, json); } + /// Attempts recursive test-directory cleanup without propagating cleanup failures. + /// The directory to remove if it exists. private static void TryDeleteDirectory(string path) { try @@ -1668,6 +1739,9 @@ private static void TryDeleteDirectory(string path) } } + /// Creates a minimal installation state with synthetic paths for an app identifier. + /// The identifier used in the manifest and synthetic paths. + /// An installation state suitable for tests that do not read its manifest file. private static McpAppInstallState CreateState(string appId) => new() { @@ -1678,6 +1752,9 @@ private static McpAppInstallState CreateState(string appId) // ── MCP Server test infrastructure ────────────────────────── + /// Starts a stateless HTTP MCP test server and tracks its protocol requests. + /// The tool class to register with the server. + /// The MCP endpoint URL and its request tracker. private async Task<(string ServerUrl, McpAppCallTracker Tracker)> StartMcpServerAsync() where TTools : class { @@ -1709,6 +1786,8 @@ private static McpAppInstallState CreateState(string appId) return ($"{address.TrimEnd('/')}/mcp", tracker); } + /// Starts a server advertising model-visible and app-only UI tool metadata. + /// The MCP endpoint URL. private async Task StartMetadataMcpServerAsync() { var builder = WebApplication.CreateSlimBuilder(); @@ -1769,6 +1848,8 @@ private async Task StartMetadataMcpServerAsync() return app.Urls.Single().TrimEnd('/') + "/mcp"; } + /// Starts a server advertising tools with a valid schema and a missing schema. + /// The MCP endpoint URL. private async Task StartMcpServerWithMissingToolInputSchemaAsync() { var builder = WebApplication.CreateSlimBuilder(); @@ -1808,6 +1889,8 @@ private async Task StartMcpServerWithMissingToolInputSchemaAsync() return app.Urls.Single().TrimEnd('/') + "/mcp"; } + /// Starts a server advertising object and non-object tool input schemas. + /// The MCP endpoint URL. private async Task StartMcpServerWithMixedInputSchemasAsync() { var builder = WebApplication.CreateSlimBuilder(); @@ -1848,6 +1931,9 @@ private async Task StartMcpServerWithMixedInputSchemasAsync() return app.Urls.Single().TrimEnd('/') + "/mcp"; } + /// Counts MCP POST methods and captures tool parameters without consuming the request body. + /// The incoming HTTP request context. + /// The counters and last tool-call parameters to update. private static async Task TrackMcpMethodAsync(HttpContext context, McpAppCallTracker tracker) { if (!context.Request.Path.StartsWithSegments("/mcp", StringComparison.Ordinal)) @@ -1893,6 +1979,7 @@ private sealed class McpAppCallTracker public int PromptListCalls { get; set; } } + /// Disposes all HTTP test servers started by this test instance. public async ValueTask DisposeAsync() { foreach (var app in _apps) @@ -1905,6 +1992,9 @@ public async ValueTask DisposeAsync() [McpServerToolType] file sealed class GroceryMcpTools { + /// Returns a fixed store fixture, or a single-store fixture when an identifier is supplied. + /// An optional identifier that selects the single-store fixture. + /// A JSON array containing fixture stores. [McpServerTool(Name = "get_stores", ReadOnly = true)] [Description("Get stores in the chain. Pass id to fetch a single store, or omit it to list all stores.")] public static string GetStores(int? id = null) @@ -1914,6 +2004,9 @@ public static string GetStores(int? id = null) return """[{"id":1,"name":"Downtown Market","city":"Portland","state":"OR"},{"id":2,"name":"Uptown Grocery","city":"Seattle","state":"WA"}]"""; } + /// Returns a fixed product fixture, or a single-product fixture when an identifier is supplied. + /// An optional identifier that selects the single-product fixture. + /// A JSON array containing fixture products. [McpServerTool(Name = "get_products", ReadOnly = true)] [Description("Get products. Pass id to fetch a single product, or omit it to list products.")] public static string GetProducts(int? id = null) @@ -1923,6 +2016,10 @@ public static string GetProducts(int? id = null) return """[{"id":1,"name":"Organic Milk","sku":"MILK-001","unitPrice":4.99},{"id":2,"name":"Whole Wheat Bread","sku":"BREAD-002","unitPrice":3.49}]"""; } + /// Returns a fixed inventory fixture regardless of the supplied filters. + /// An optional store filter accepted for argument-binding tests. + /// An optional product filter accepted for argument-binding tests. + /// A JSON array containing the fixture inventory row. [McpServerTool(Name = "get_inventory", ReadOnly = true)] [Description("Get inventory rows. Pass storeId and/or productId to filter.")] public static string GetInventory(int? storeId = null, int? productId = null) @@ -1930,6 +2027,8 @@ public static string GetInventory(int? storeId = null, int? productId = null) return """[{"storeId":1,"productId":1,"quantityOnHand":42,"reorderThreshold":10,"lastRestocked":"2025-06-01T00:00:00Z"}]"""; } + /// Returns a dashboard fixture with text and structured content. + /// An MCP result containing a fixed dashboard snapshot. [McpServerTool(Name = "show_store_inventory_dashboard")] [Description("Render an interactive multi-store inventory dashboard.")] public static CallToolResult ShowDashboard()