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..63cd8749 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; @@ -29,6 +31,7 @@ public sealed class McpAppTests : IAsyncDisposable // ── McpAppManifest ────────────────────────────────────────── + /// Verifies that manifest serialization preserves configured fields. [Fact] public void Manifest_SerializeAndDeserialize_RoundTrips() { @@ -71,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() { @@ -87,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() { @@ -119,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() { @@ -129,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() { @@ -151,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() { @@ -174,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() { @@ -194,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() { @@ -226,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() { @@ -241,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() { @@ -333,6 +345,7 @@ public void InfoProvider_DescriptorManagement_TracksCounts() // ── McpAppDiscovery ───────────────────────────────────────── + /// Verifies that disabled discovery does not return any apps. [Fact] public void Discovery_Disabled_ReturnsEmptyList() { @@ -344,6 +357,7 @@ public void Discovery_Disabled_ReturnsEmptyList() Assert.Empty(results); } + /// Verifies that nonexistent discovery paths are ignored. [Fact] public void Discovery_NonExistentPath_ReturnsEmptyList() { @@ -359,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() { @@ -397,6 +412,7 @@ public void Discovery_ValidManifest_DiscoversApp() } } + /// Verifies that discovery finds manifests in nested app directories. [Fact] public void Discovery_MultipleAppsInNestedDirectories_FindsAll() { @@ -443,6 +459,7 @@ public void Discovery_MultipleAppsInNestedDirectories_FindsAll() } } + /// Verifies validation errors for manifests missing required fields. [Fact] public void Discovery_MissingRequiredFields_ReturnsInvalidState() { @@ -479,6 +496,7 @@ public void Discovery_MissingRequiredFields_ReturnsInvalidState() } } + /// Verifies rejection of an unknown manifest transport. [Fact] public void Discovery_InvalidTransport_ReturnsValidationError() { @@ -510,6 +528,7 @@ public void Discovery_InvalidTransport_ReturnsValidationError() } } + /// Verifies rejection of the unsupported in-process transport. [Fact] public void Discovery_InProcessTransport_ReturnsValidationError() { @@ -542,6 +561,7 @@ public void Discovery_InProcessTransport_ReturnsValidationError() } } + /// Verifies that discovery skips malformed manifest JSON. [Fact] public void Discovery_InvalidJson_IsSkipped() { @@ -569,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() { @@ -587,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() { @@ -601,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() { @@ -617,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() { @@ -632,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() { @@ -650,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() { @@ -659,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() { @@ -677,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() { @@ -697,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() { @@ -735,6 +764,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 +776,104 @@ 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")] + [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()); + } + + /// Verifies that disposing nested Agent scopes restores each preceding tool session. + [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); + } + + /// 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, + TurnContext = new TurnContext(), + SystemPromptLength = 0, + SkillPromptLength = 0, + SessionTokenBudget = 0, + ToolInvocations = [], + }; + + /// Verifies that JSON tool arguments are accepted by the remote MCP tool. [Fact] public async Task NativeTool_Execute_WithArguments_InvokesCorrectly() { @@ -785,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() { @@ -827,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() { @@ -869,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() { @@ -902,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() { @@ -937,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() { @@ -970,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() { @@ -1001,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() { @@ -1037,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() { @@ -1078,6 +1218,7 @@ public async Task RegisterMcpAppToolsAsync_UiToolsSuppressStructuredContent() // ── McpAppServer ──────────────────────────────────────────── + /// Verifies capability enumeration when an MCP App connects. [Fact] public async Task Server_ConnectAsync_EnumeratesAllCapabilities() { @@ -1112,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() { @@ -1144,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() { @@ -1180,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() { @@ -1207,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() { @@ -1235,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() { @@ -1268,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() { @@ -1302,6 +1449,7 @@ public async Task Server_ToolNamePrefix_EntryConfigOverridesManifest() } } + /// Verifies case-insensitive handling of transport overrides. [Fact] public async Task Server_TransportOverride_IsCaseInsensitive() { @@ -1337,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() { @@ -1373,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() { @@ -1385,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() { @@ -1430,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() { @@ -1446,6 +1598,7 @@ public async Task Registry_Dispose_CleansUpServers() // ── Config Models ─────────────────────────────────────────── + /// Verifies default discovery, enablement, and allowlist settings. [Fact] public void McpAppsConfig_DefaultSettings() { @@ -1459,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() { @@ -1476,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() { @@ -1490,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() { @@ -1513,6 +1669,7 @@ public void ResourceDescriptor_IsUiResource() Assert.False(jsonResource.IsUiResource); } + /// Verifies prompt argument storage and required-argument flags. [Fact] public void PromptDescriptor_WithArguments() { @@ -1536,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")); @@ -1543,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); @@ -1551,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); @@ -1558,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 @@ -1571,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() { @@ -1581,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 { @@ -1612,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(); @@ -1672,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(); @@ -1711,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(); @@ -1751,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)) @@ -1775,6 +1958,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,10 +1974,12 @@ 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; } } + /// Disposes all HTTP test servers started by this test instance. public async ValueTask DisposeAsync() { foreach (var app in _apps) @@ -1806,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) @@ -1815,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) @@ -1824,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) @@ -1831,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() 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();