diff --git a/README.md b/README.md index 6eac802..0a7d1a6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The `core` profile exposes these tools: | Sessions | `launch_app`, `attach_to_app`, `detach_session`, `close_app`, `terminate_app`, `close_session`, `list_sessions` | | Windows and screenshots | `list_windows`, `set_active_window`, `take_screenshot`, `take_screenshot_sequence` | | Inspection | `get_visual_tree`, `find_elements`, `resolve_element`, `get_element_properties`, `get_uia_locators`, `get_uia_tree`, `capture_diagnostic_snapshot` | -| WPF diagnostics | `get_binding_info`, `get_command_info`, `get_binding_errors`, `get_validation_errors`, `get_data_context`, `get_computed_properties`, `get_layout_context` | +| WPF diagnostics | `get_binding_info`, `get_command_info`, `get_binding_errors`, `get_validation_errors`, `get_data_context`, `get_computed_properties`, `get_computed_properties_batch`, `get_layout_context` | | Interaction | `click_element`, `invoke`, `type_text`, `send_keys`, `set_value`, `select_item`, `realize_item`, `scroll_to_element`, `drag`, `wait_for` | The `diagnostics` profile adds: @@ -198,8 +198,11 @@ screenshots run in separate phases, and the response reports timing skew rather than claiming cross-backend atomicity. `get_layout_context` reports bounds, transforms, clipping, nearby visual-tree -context, and Grid allocation. `get_computed_properties` can include dependency -property value-source evidence in the diagnostics profile. These tools report +context, and Grid allocation. `get_computed_properties_batch` reads named +dependency properties for a bounded list of WPF element IDs in one call. +`get_data_context` accepts dotted `propertyPaths` when only a few nested values +are needed. The diagnostics profile can also include dependency-property +value-source evidence in `get_computed_properties`. These tools report unavailable evidence and truncation instead of filling gaps with inferred values. diff --git a/docs/tool-guide.md b/docs/tool-guide.md index f886c55..1bfba0c 100644 --- a/docs/tool-guide.md +++ b/docs/tool-guide.md @@ -81,8 +81,8 @@ then inspect a subtree if the first response is too broad. Use `find_elements` when you know some identity text or control type. Search is bounded and returns stable ordering, match counts, scan counts, truncation, and -optional element IDs. It is usually cheaper and easier to interpret than a deep -tree dump. +optional element IDs. Pass `root` to keep the scan inside a known subtree. It is +usually cheaper and easier to interpret than a deep tree dump. `get_element_properties` reads bounded UIA properties and supported patterns. Use the WPF-specific tools for data that UIA cannot expose: @@ -94,6 +94,7 @@ Use the WPF-specific tools for data that UIA cannot expose: | What validation error is active? | `get_validation_errors` | | What object is behind this view? | `get_data_context` | | Where did this dependency-property value come from? | `get_computed_properties` | +| What are the same properties across several elements? | `get_computed_properties_batch` | | Why is this element clipped, offset, or allocated this size? | `get_layout_context` | | Which style or template is applied? | `get_style_chain`, `get_template_info` in `diagnostics` | | Which WPF elements lack useful automation peers? | `uia_coverage_report` in `diagnostics` | @@ -103,6 +104,13 @@ formatters can run application code. These tools are read-only in the sense that they do not intentionally invoke a command or send input. They cannot promise that target-defined getters are free of side effects. +For repeated controls, call `find_elements` once and pass the returned WPF +element IDs to `get_computed_properties_batch`. The batch reports its applied +element and property limits, scanned and returned counts, and truncation. +For a nested view-model value, pass dotted `propertyPaths` to `get_data_context` +instead of serializing the surrounding object graph. Both operations have hard +request limits and reject malformed or unbounded input. + ## Use locators deliberately Core locators expose `automationId`, `name`, `nameContains`, `className`, `type`, diff --git a/src/WpfToolsMcp.Agent/AgentServer.cs b/src/WpfToolsMcp.Agent/AgentServer.cs index 791f85a..83c7009 100644 --- a/src/WpfToolsMcp.Agent/AgentServer.cs +++ b/src/WpfToolsMcp.Agent/AgentServer.cs @@ -505,6 +505,21 @@ internal static async Task HandleAsync( Ok: true, Result: JsonSerializer.SerializeToNode(response, JsonOptions)); }, request.Id, cancellationToken); + case AgentProtocolCapabilities.GetComputedPropertiesBatch: + return await RunOnUiAsync(() => + { + var typedRequest = request.Params?.Deserialize(JsonOptions) + ?? new GetComputedPropertiesBatchRequest(); + + var response = WpfVisualTreeInspector.GetComputedPropertiesBatch( + ownerId, + typedRequest, + cancellationToken); + return new AgentResponse( + request.Id, + Ok: true, + Result: JsonSerializer.SerializeToNode(response, JsonOptions)); + }, request.Id, cancellationToken); case AgentProtocolCapabilities.GetLayoutContext: return await RunOnUiAsync(() => { diff --git a/src/WpfToolsMcp.Agent/WpfVisualTreeInspector.cs b/src/WpfToolsMcp.Agent/WpfVisualTreeInspector.cs index 80f7d61..7b0a3d1 100644 --- a/src/WpfToolsMcp.Agent/WpfVisualTreeInspector.cs +++ b/src/WpfToolsMcp.Agent/WpfVisualTreeInspector.cs @@ -4262,6 +4262,241 @@ private sealed class DataContextTruncationTracker } } + private static IReadOnlyList NormalizeDataContextPropertyPaths( + IReadOnlyList? requestedPaths, + int maxDepth) + { + if (requestedPaths is null || requestedPaths.Count == 0) + { + return Array.Empty(); + } + + if (maxDepth < 1) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: propertyPaths requires maxDepth of at least 1.", + nameof(GetDataContextRequest.MaxDepth)); + } + + if (requestedPaths.Count > DataContextPropertyPathLimits.MaxPaths) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: propertyPaths supports at most {DataContextPropertyPathLimits.MaxPaths} paths.", + nameof(GetDataContextRequest.PropertyPaths)); + } + + var maxSegments = Math.Min(maxDepth, DataContextPropertyPathLimits.MaxSegments); + var normalized = new List(requestedPaths.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var requestedPath in requestedPaths) + { + if (string.IsNullOrWhiteSpace(requestedPath)) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: propertyPaths cannot contain blank values.", + nameof(GetDataContextRequest.PropertyPaths)); + } + + var path = requestedPath.Trim(); + if (path.Length > DataContextPropertyPathLimits.MaxPathLength || !IsDottedIdentifierPath(path)) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: property path '{path}' must contain at most {DataContextPropertyPathLimits.MaxSegments} dotted identifiers and {DataContextPropertyPathLimits.MaxPathLength} characters.", + nameof(GetDataContextRequest.PropertyPaths)); + } + + if (path.Split('.').Length > maxSegments) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: property path '{path}' exceeds maxDepth={maxDepth}.", + nameof(GetDataContextRequest.PropertyPaths)); + } + + if (seen.Add(path)) + { + normalized.Add(path); + } + } + + for (var left = 0; left < normalized.Count; left++) + { + for (var right = left + 1; right < normalized.Count; right++) + { + if (normalized[left].StartsWith(normalized[right] + ".", StringComparison.Ordinal) || + normalized[right].StartsWith(normalized[left] + ".", StringComparison.Ordinal)) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: propertyPaths cannot contain overlapping paths '{normalized[left]}' and '{normalized[right]}'.", + nameof(GetDataContextRequest.PropertyPaths)); + } + } + } + + return normalized; + } + + private static JsonObject BuildDataContextPropertyPathProjection( + object dataContext, + IReadOnlyList propertyPaths, + DataContextSerializationOptions options, + DataContextTruncationTracker truncation, + List warnings, + CancellationToken cancellationToken) + { + var projection = new JsonObject(); + var prefixCache = new Dictionary(StringComparer.Ordinal); + foreach (var path in propertyPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + var segments = path.Split('.'); + object? current = dataContext; + var resolved = true; + var prefix = ""; + + foreach (var segment in segments) + { + cancellationToken.ThrowIfCancellationRequested(); + prefix = prefix.Length == 0 ? segment : $"{prefix}.{segment}"; + if (prefixCache.TryGetValue(prefix, out var cached)) + { + if (!cached.Resolved) + { + warnings.Add($"{cached.WarningCode}: '{path}' {cached.WarningDetail}"); + resolved = false; + break; + } + + current = cached.Value; + continue; + } + + if (current is null) + { + var failure = new DataContextPropertyPathPrefix( + Resolved: false, + Value: null, + WarningCode: "property_path_null", + WarningDetail: $"could not continue at '{segment}'."); + prefixCache[prefix] = failure; + warnings.Add($"{failure.WarningCode}: '{path}' {failure.WarningDetail}"); + resolved = false; + break; + } + + PropertyInfo? property; + try + { + property = current.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(candidate => + candidate.CanRead && + candidate.GetMethod?.IsPublic == true && + candidate.GetIndexParameters().Length == 0 && + string.Equals(candidate.Name, segment, StringComparison.Ordinal)); + } + catch (Exception ex) + { + var failure = new DataContextPropertyPathPrefix( + Resolved: false, + Value: null, + WarningCode: "property_path_error", + WarningDetail: $"metadata read failed with {ex.GetType().Name}."); + prefixCache[prefix] = failure; + warnings.Add($"{failure.WarningCode}: '{path}' {failure.WarningDetail}"); + resolved = false; + break; + } + + if (property is null) + { + var failure = new DataContextPropertyPathPrefix( + Resolved: false, + Value: null, + WarningCode: "property_path_not_found", + WarningDetail: $"stopped at '{segment}'."); + prefixCache[prefix] = failure; + warnings.Add($"{failure.WarningCode}: '{path}' {failure.WarningDetail}"); + resolved = false; + break; + } + + try + { + current = property.GetValue(current); + } + catch (Exception ex) + { + var failure = ex is TargetInvocationException { InnerException: not null } + ? ex.InnerException + : ex; + var failedPrefix = new DataContextPropertyPathPrefix( + Resolved: false, + Value: null, + WarningCode: "property_path_error", + WarningDetail: $"getter failed with {failure.GetType().Name}."); + prefixCache[prefix] = failedPrefix; + warnings.Add($"{failedPrefix.WarningCode}: '{path}' {failedPrefix.WarningDetail}"); + resolved = false; + break; + } + + prefixCache[prefix] = new DataContextPropertyPathPrefix( + Resolved: true, + Value: current, + WarningCode: null, + WarningDetail: null); + } + + if (!resolved) + { + continue; + } + + var value = SerializeDataContextValueSummary( + current, + options, + remainingDepth: 0, + new HashSet(ReferenceEqualityComparer.Instance), + truncation, + warnings, + applyAllowList: false, + cancellationToken); + InsertDataContextPropertyPath(projection, segments, value); + } + + return projection; + } + + private sealed record DataContextPropertyPathPrefix( + bool Resolved, + object? Value, + string? WarningCode, + string? WarningDetail); + + private static void InsertDataContextPropertyPath(JsonObject projection, string[] segments, JsonNode? value) + { + var current = projection; + for (var index = 0; index < segments.Length - 1; index++) + { + var segment = segments[index]; + if (current[segment] is not JsonObject child) + { + child = new JsonObject(); + current[segment] = child; + } + + current = child; + } + + current[segments[^1]] = value; + } + private static JsonValue SerializeDoubleJson(double value) { if (double.IsNaN(value)) @@ -4881,6 +5116,22 @@ private static GetDataContextResponse GetDataContext( var maxPropertiesPerObject = Math.Clamp(request.MaxPropertiesPerObject, 1, 5000); var maxStringLength = Math.Clamp(request.MaxStringLength, 0, 200_000); maxNodes = Math.Clamp(maxNodes, 1, 200_000); + var propertyPaths = NormalizeDataContextPropertyPaths(request.PropertyPaths, maxDepth); + if (propertyPaths.Count > 0 && request.Mode == DataContextMode.Full) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: propertyPaths cannot be combined with mode=Full.", + nameof(request.PropertyPaths)); + } + + if (propertyPaths.Count > 0 && request.PropertyAllowList is { Count: > 0 }) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: propertyPaths cannot be combined with propertyAllowList.", + nameof(request.PropertyPaths)); + } var window = ResolveWindow(request.WindowHandle); using var treeService = new VisualTreeService(); @@ -4942,6 +5193,37 @@ private static GetDataContextResponse GetDataContext( truncation: truncation, warnings: warnings); + if (propertyPaths.Count > 0) + { + var options = new DataContextSerializationOptions( + MaxDepth: 0, + MaxPropertiesPerObject: maxPropertiesPerObject, + MaxStringLength: maxStringLength, + IncludeNulls: true, + IncludeFrameworkProperties: false, + PropertyAllowList: null); + var projection = BuildDataContextPropertyPathProjection( + dataContext, + propertyPaths, + options, + truncation, + warnings, + cancellationToken); + var truncatedReasons = truncation.GetReasons(); + + return new GetDataContextResponse( + DataContextType: dataContextType, + Data: projection, + Summary: summary, + Truncated: truncation.Truncated, + Warnings: warnings.Count > 0 ? warnings : null) + { + Element = elementRef, + WindowHandleUsed = windowHandleUsed, + TruncatedReasons = truncatedReasons + }; + } + if (request.Mode == DataContextMode.Full) { var options = new DataContextSerializationOptions( @@ -5033,6 +5315,138 @@ public static GetComputedPropertiesResponse GetComputedProperties( CancellationToken cancellationToken) => GetComputedProperties(ownerId, request, cancellationToken, visibleOnly: true); + public static GetComputedPropertiesBatchResponse GetComputedPropertiesBatch( + string ownerId, + GetComputedPropertiesBatchRequest request, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerId); + ArgumentNullException.ThrowIfNull(request); + + if (request.WindowHandle is not long windowHandle || windowHandle == 0) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: windowHandle is required.", + nameof(request.WindowHandle)); + } + + if (request.ElementIds is not { Count: > 0 }) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: provide at least one elementId.", + nameof(request.ElementIds)); + } + + if (request.PropertyNames is not { Count: > 0 }) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + "invalid_request: provide at least one property name.", + nameof(request.PropertyNames)); + } + + if (request.ElementIds.Count > ComputedPropertiesBatchLimits.MaxInputElements || + request.PropertyNames.Count > ComputedPropertiesBatchLimits.MaxInputProperties) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: input supports at most {ComputedPropertiesBatchLimits.MaxInputElements} elementIds and {ComputedPropertiesBatchLimits.MaxInputProperties} propertyNames."); + } + + var elementIds = NormalizeBatchValues(request.ElementIds, "elementIds"); + var propertyNames = NormalizeBatchValues(request.PropertyNames, "propertyNames"); + var maxElements = Math.Clamp(request.MaxElements, 1, ComputedPropertiesBatchLimits.MaxElements); + var maxPropertiesPerElement = Math.Clamp( + request.MaxPropertiesPerElement, + 1, + ComputedPropertiesBatchLimits.MaxPropertiesPerElement); + var selectedElementIds = elementIds.Take(maxElements).ToArray(); + var selectedPropertyNames = propertyNames.Take(maxPropertiesPerElement).ToArray(); + var results = new List(selectedElementIds.Length); + + foreach (var elementId in selectedElementIds) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(GetComputedProperties( + ownerId, + new GetComputedPropertiesRequest( + WindowHandle: windowHandle, + ElementId: elementId, + PropertyNames: selectedPropertyNames, + IncludeSources: request.IncludeSources, + IncludeDefault: false, + IncludeUnset: false, + MaxProperties: selectedPropertyNames.Length, + ValueFormat: request.ValueFormat), + cancellationToken, + visibleOnly: true)); + } + + var truncatedReasons = new List(3); + if (elementIds.Count > selectedElementIds.Length) + { + truncatedReasons.Add("maxElements"); + } + + if (propertyNames.Count > selectedPropertyNames.Length) + { + truncatedReasons.Add("maxPropertiesPerElement"); + } + + if (results.Any(result => result.Truncated)) + { + truncatedReasons.Add("elementProperties"); + } + + return new GetComputedPropertiesBatchResponse( + Results: results, + RequestedElements: elementIds.Count, + RequestedProperties: propertyNames.Count, + ScannedElements: results.Count, + ReturnedElements: results.Count, + MaxElements: maxElements, + MaxPropertiesPerElement: maxPropertiesPerElement, + Truncated: truncatedReasons.Count > 0, + TruncatedReason: truncatedReasons.FirstOrDefault(), + TruncatedReasons: truncatedReasons.Count > 0 ? truncatedReasons : null) + { + WindowHandleUsed = windowHandle + }; + } + + private static IReadOnlyList NormalizeBatchValues( + IReadOnlyList values, + string parameterName) + { + var normalized = new List(values.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var rawValue in values) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: {parameterName} cannot contain blank values.", + parameterName); + } + + var value = rawValue.Trim(); + if (!seen.Add(value)) + { + throw AgentToolError.InvalidArgument( + "invalid_request", + $"invalid_request: {parameterName} cannot contain duplicates.", + parameterName); + } + + normalized.Add(value); + } + + return normalized; + } + private static GetComputedPropertiesResponse GetComputedProperties( string ownerId, GetComputedPropertiesRequest request, diff --git a/src/WpfToolsMcp.AgentProtocol/ProtocolMessages.cs b/src/WpfToolsMcp.AgentProtocol/ProtocolMessages.cs index ced8543..0aa8385 100644 --- a/src/WpfToolsMcp.AgentProtocol/ProtocolMessages.cs +++ b/src/WpfToolsMcp.AgentProtocol/ProtocolMessages.cs @@ -22,6 +22,8 @@ public static class AgentProtocolCapabilities public const string GetCapabilitiesMethod = "capabilities"; public const string ResolveElementDetailed = "wpf/resolve_element_detailed"; public const string FindElementsDiscoveryCounts = "wpf/find_elements:discovery-counts"; + public const string GetComputedPropertiesBatch = "wpf/get_computed_properties_batch:v1"; + public const string GetDataContextPropertyPaths = "wpf/get_data_context:property-paths-v1"; public const string GetLayoutContext = "wpf/get_layout_context"; public const string CaptureDiagnosticSnapshot = "wpf/capture_diagnostic_snapshot:v1"; public const string GetComputedPropertyProvenance = "wpf/get_computed_properties:provenance-v1"; @@ -39,6 +41,8 @@ public static class AgentProtocolCapabilities [ ResolveElementDetailed, FindElementsDiscoveryCounts, + GetComputedPropertiesBatch, + GetDataContextPropertyPaths, GetLayoutContext, CaptureDiagnosticSnapshot, GetComputedPropertyProvenance, diff --git a/src/WpfToolsMcp.Automation/AutomationController.Agent.cs b/src/WpfToolsMcp.Automation/AutomationController.Agent.cs index b6d7c6f..967116c 100644 --- a/src/WpfToolsMcp.Automation/AutomationController.Agent.cs +++ b/src/WpfToolsMcp.Automation/AutomationController.Agent.cs @@ -958,6 +958,7 @@ public async Task GetDataContextAsync( bool includeNulls = false, bool includeFrameworkProperties = false, IReadOnlyList? propertyAllowList = null, + IReadOnlyList? propertyPaths = null, CancellationToken cancellationToken = default) { var trace = BeginTraceSpan("get_data_context"); @@ -966,7 +967,13 @@ public async Task GetDataContextAsync( var target = PrepareWpfAgentTarget("get_data_context", locator, elementId, windowHandle); var client = await EnsureAgentConnectedAsync(cancellationToken); - EnsureInspectionResponseMetadataCapability(GetAgentCapabilities(client)); + var capabilities = GetAgentCapabilities(client); + EnsureInspectionResponseMetadataCapability(capabilities); + if (propertyPaths is { Count: > 0 }) + { + EnsureDataContextPropertyPathsCapability(capabilities); + } + var request = new GetDataContextRequest( WindowHandle: target.WindowHandle, Locator: target.Locator, @@ -977,7 +984,8 @@ public async Task GetDataContextAsync( MaxStringLength: maxStringLength, IncludeNulls: includeNulls, IncludeFrameworkProperties: includeFrameworkProperties, - PropertyAllowList: propertyAllowList); + PropertyAllowList: propertyAllowList, + PropertyPaths: propertyPaths); var fallbackRequest = target.RecoveryLocator is null ? null @@ -1109,6 +1117,189 @@ public async Task GetComputedPropertiesAsync( } } + public async Task GetComputedPropertiesBatchAsync( + IReadOnlyList elementIds, + IReadOnlyList propertyNames, + long? windowHandle = null, + int maxElements = 128, + int maxPropertiesPerElement = 16, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(elementIds); + ArgumentNullException.ThrowIfNull(propertyNames); + var trace = BeginTraceSpan("get_computed_properties_batch"); + try + { + if (elementIds.Count is 0 or > ComputedPropertiesBatchLimits.MaxInputElements) + { + throw new ArgumentOutOfRangeException( + nameof(elementIds), + $"Provide between 1 and {ComputedPropertiesBatchLimits.MaxInputElements} element IDs."); + } + + if (propertyNames.Count is 0 or > ComputedPropertiesBatchLimits.MaxInputProperties) + { + throw new ArgumentOutOfRangeException( + nameof(propertyNames), + $"Provide between 1 and {ComputedPropertiesBatchLimits.MaxInputProperties} property names."); + } + + var publicElementIds = NormalizeDistinctBatchValues(elementIds, nameof(elementIds)); + var normalizedPropertyNames = NormalizeDistinctBatchValues(propertyNames, nameof(propertyNames)); + var appliedMaxElements = Math.Clamp(maxElements, 1, ComputedPropertiesBatchLimits.MaxElements); + var selectedPublicElementIds = publicElementIds.Take(appliedMaxElements).ToArray(); + var handles = selectedPublicElementIds.Select(RequireHandle).ToArray(); + if (handles.Any(handle => handle.Backend != InspectionBackend.Wpf)) + { + throw new InvalidOperationException( + "get_computed_properties_batch requires WPF element IDs from find_elements or resolve_element."); + } + + var effectiveWindowHandle = handles[0].WindowHandle; + if (handles.Any(handle => handle.WindowHandle != effectiveWindowHandle)) + { + throw new ArgumentException("All elementIds must belong to the same window.", nameof(elementIds)); + } + + if (windowHandle is long requestedWindowHandle && requestedWindowHandle != effectiveWindowHandle) + { + throw new ArgumentException("windowHandle does not match the elementId window.", nameof(windowHandle)); + } + + var agentElementIds = handles + .Select(handle => handle.WpfAgentElementId) + .ToArray(); + if (agentElementIds.Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidOperationException( + "stale_element: get_computed_properties_batch requires current WPF element handles. Call find_elements or resolve_element again."); + } + + var normalizedAgentElementIds = agentElementIds + .Select(elementId => elementId!) + .ToArray(); + + var client = await EnsureAgentConnectedAsync(cancellationToken).ConfigureAwait(false); + EnsureComputedPropertiesBatchCapability(GetAgentCapabilities(client)); + GetComputedPropertiesBatchResponse response; + try + { + response = await client.CallAsync( + AgentProtocolCapabilities.GetComputedPropertiesBatch, + new GetComputedPropertiesBatchRequest( + WindowHandle: effectiveWindowHandle, + ElementIds: normalizedAgentElementIds, + PropertyNames: normalizedPropertyNames, + MaxElements: maxElements, + MaxPropertiesPerElement: maxPropertiesPerElement, + IncludeSources: true, + ValueFormat: "string"), + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsWpfAgentStaleOrNotFound(ex)) + { + throw new InvalidOperationException( + "stale_element: one or more batch element IDs are no longer available. Call find_elements or resolve_element again.", + ex); + } + + if (response.Results.Count != selectedPublicElementIds.Length || + response.ScannedElements != response.Results.Count || + response.ReturnedElements != response.Results.Count) + { + throw new InvalidOperationException( + "agent_protocol_error: get_computed_properties_batch returned inconsistent element counts."); + } + + var normalizedResults = new List(response.Results.Count); + for (var index = 0; index < response.Results.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (index >= selectedPublicElementIds.Length) + { + throw new InvalidOperationException("Agent returned more batch results than requested elements."); + } + + var result = response.Results[index]; + if (!string.Equals( + result.Element.ElementIdWpf, + normalizedAgentElementIds[index], + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "agent_protocol_error: get_computed_properties_batch returned elements out of order."); + } + + var element = await StripAgentElementIdAsync( + client, + result.Element, + selectedPublicElementIds[index]).ConfigureAwait(false); + normalizedResults.Add(result with + { + Element = element with { ElementId = selectedPublicElementIds[index] }, + WindowHandleUsed = effectiveWindowHandle + }); + } + + var truncatedReasons = response.TruncatedReasons?.ToList() + ?? (response.TruncatedReason is null ? [] : [response.TruncatedReason]); + if (publicElementIds.Count > selectedPublicElementIds.Length && + !truncatedReasons.Contains("maxElements", StringComparer.Ordinal)) + { + truncatedReasons.Insert(0, "maxElements"); + } + + response = response with + { + Results = normalizedResults, + RequestedElements = publicElementIds.Count, + MaxElements = appliedMaxElements, + Truncated = truncatedReasons.Count > 0, + TruncatedReason = truncatedReasons.FirstOrDefault(), + TruncatedReasons = truncatedReasons.Count > 0 ? truncatedReasons : null, + WindowHandleUsed = effectiveWindowHandle + }; + trace?.SetSummary( + $"elements={response.ReturnedElements}/{response.RequestedElements} " + + $"properties={response.RequestedProperties} truncated={response.Truncated}"); + return response; + } + catch (Exception ex) + { + trace?.SetError(ex); + throw; + } + finally + { + trace?.Dispose(); + } + } + + private static IReadOnlyList NormalizeDistinctBatchValues( + IReadOnlyList values, + string parameterName) + { + var normalized = new List(values.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var rawValue in values) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + throw new ArgumentException($"{parameterName} cannot contain blank values.", parameterName); + } + + var value = rawValue.Trim(); + if (!seen.Add(value)) + { + throw new ArgumentException($"{parameterName} cannot contain duplicates.", parameterName); + } + + normalized.Add(value); + } + + return normalized; + } + internal readonly record struct PreparedAgentPropertyNames( IReadOnlyList? Names, IReadOnlyList? TruncatedReasons) @@ -1610,6 +1801,16 @@ internal static InvalidOperationException CreateInspectionResponseMetadataCapabi "agent_capability_unavailable: truthful inspection response metadata requires the current WPF agent. " + "Restart the target application, start a new MCP session, and attach again so the current agent can be injected."); + internal static InvalidOperationException CreateComputedPropertiesBatchCapabilityException() => + new( + "agent_capability_unavailable: get_computed_properties_batch requires the current WPF agent. " + + "Restart the target application, start a new MCP session, and attach again so the current agent can be injected."); + + internal static InvalidOperationException CreateDataContextPropertyPathsCapabilityException() => + new( + "agent_capability_unavailable: get_data_context with propertyPaths requires the current WPF agent. " + + "Restart the target application, start a new MCP session, and attach again so the current agent can be injected."); + internal static void EnsureInspectionResponseMetadataCapability(AgentCapabilitiesResponse? capabilities) { if (capabilities is null || @@ -1621,6 +1822,28 @@ internal static void EnsureInspectionResponseMetadataCapability(AgentCapabilitie } } + internal static void EnsureComputedPropertiesBatchCapability(AgentCapabilitiesResponse? capabilities) + { + if (capabilities is null || + !capabilities.Capabilities.Contains( + AgentProtocolCapabilities.GetComputedPropertiesBatch, + StringComparer.Ordinal)) + { + throw CreateComputedPropertiesBatchCapabilityException(); + } + } + + internal static void EnsureDataContextPropertyPathsCapability(AgentCapabilitiesResponse? capabilities) + { + if (capabilities is null || + !capabilities.Capabilities.Contains( + AgentProtocolCapabilities.GetDataContextPropertyPaths, + StringComparer.Ordinal)) + { + throw CreateDataContextPropertyPathsCapabilityException(); + } + } + internal static Task CallGetLayoutContextWhenSupportedAsync( AgentCapabilitiesResponse? capabilities, Func> call) diff --git a/src/WpfToolsMcp.Contracts/Contracts.cs b/src/WpfToolsMcp.Contracts/Contracts.cs index 5c44dff..4431bb1 100644 --- a/src/WpfToolsMcp.Contracts/Contracts.cs +++ b/src/WpfToolsMcp.Contracts/Contracts.cs @@ -1650,7 +1650,8 @@ public sealed record GetDataContextRequest( int MaxStringLength = 2000, bool IncludeNulls = false, bool IncludeFrameworkProperties = false, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PropertyAllowList = null); + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PropertyAllowList = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PropertyPaths = null); public sealed record GetDataContextResponse( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? DataContextType, @@ -1689,6 +1690,30 @@ public sealed record GetComputedPropertiesRequest( bool IncludeProvenance = false, int MaxProvenanceCandidates = 20); +public sealed record GetComputedPropertiesBatchRequest( + long? WindowHandle = null, + IReadOnlyList? ElementIds = null, + IReadOnlyList? PropertyNames = null, + int MaxElements = 128, + int MaxPropertiesPerElement = 16, + bool IncludeSources = true, + string ValueFormat = "string"); + +public static class ComputedPropertiesBatchLimits +{ + public const int MaxInputElements = 500; + public const int MaxInputProperties = 200; + public const int MaxElements = 200; + public const int MaxPropertiesPerElement = 50; +} + +public static class DataContextPropertyPathLimits +{ + public const int MaxPaths = 32; + public const int MaxSegments = 16; + public const int MaxPathLength = 512; +} + public sealed record ComputedPropertyInfo( string Name, string OwnerType, @@ -1931,6 +1956,21 @@ public sealed record GetComputedPropertiesResponse( public IReadOnlyList? TruncatedReasons { get; init; } } +public sealed record GetComputedPropertiesBatchResponse( + IReadOnlyList Results, + int RequestedElements, + int RequestedProperties, + int ScannedElements, + int ReturnedElements, + int MaxElements, + int MaxPropertiesPerElement, + bool Truncated, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? TruncatedReason = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? TruncatedReasons = null) +{ + public long WindowHandleUsed { get; init; } +} + [JsonConverter(typeof(JsonStringEnumConverter))] public enum StyleChainKind { diff --git a/src/WpfToolsMcp.McpServer/Tools/AgentTools.cs b/src/WpfToolsMcp.McpServer/Tools/AgentTools.cs index 1f59133..db3f96e 100644 --- a/src/WpfToolsMcp.McpServer/Tools/AgentTools.cs +++ b/src/WpfToolsMcp.McpServer/Tools/AgentTools.cs @@ -170,6 +170,7 @@ public static Task GetDataContext( [Description("Include null values")] bool includeNulls = false, [Description("Include framework object properties (WPF/Dispatcher/etc) in summary mode")] bool includeFrameworkProperties = false, [Description("Optional root property allowlist (summary mode)")] string[]? propertyAllowList = null, + [Description("Optional dotted property paths (summary mode; hard limit 32, at most maxDepth segments each)")] string[]? propertyPaths = null, CancellationToken cancellationToken = default) => McpToolErrors.RunAsync(() => { @@ -187,6 +188,7 @@ public static Task GetDataContext( includeNulls: includeNulls, includeFrameworkProperties: includeFrameworkProperties, propertyAllowList: propertyAllowList, + propertyPaths: propertyPaths, cancellationToken: cancellationToken), cancellationToken); }); @@ -228,6 +230,30 @@ public static Task GetComputedProperties( cancellationToken); }); + [McpServerTool(Name = "get_computed_properties_batch", UseStructuredContent = true), Description("Inspect selected dependency properties for a bounded set of WPF element IDs in one agent call.")] + public static Task GetComputedPropertiesBatch( + SessionManager sessions, + [Description("Session ID")] string sessionId, + [Description("WPF element IDs from find_elements or resolve_element; hard limit 500 input IDs")] string[] elementIds, + [Description("Dependency property names to inspect; hard limit 200 input names")] string[] propertyNames, + [Description("Native window handle; all IDs must belong to this window")] long? windowHandle = null, + [Description("Maximum elements inspected; hard limit 200")] int maxElements = 128, + [Description("Maximum properties inspected per element; hard limit 50")] int maxPropertiesPerElement = 16, + CancellationToken cancellationToken = default) => + McpToolErrors.RunAsync(() => + { + var (automation, _) = sessions.GetController(sessionId, windowHandle); + return automation.RunExclusiveAsync( + () => automation.GetComputedPropertiesBatchAsync( + elementIds, + propertyNames, + windowHandle, + maxElements, + maxPropertiesPerElement, + cancellationToken), + cancellationToken); + }); + [McpServerTool(Name = "get_layout_context", UseStructuredContent = true), Description("Inspect WPF layout metrics, transforms, clipping, ancestors, nearby siblings, and Grid allocation via the injected agent.")] public static Task GetLayoutContext( SessionManager sessions, diff --git a/src/WpfToolsMcp.McpServer/Tools/CoreTools.cs b/src/WpfToolsMcp.McpServer/Tools/CoreTools.cs index fa459d3..01c6edd 100644 --- a/src/WpfToolsMcp.McpServer/Tools/CoreTools.cs +++ b/src/WpfToolsMcp.McpServer/Tools/CoreTools.cs @@ -307,6 +307,7 @@ public static Task FindElements( SessionManager sessions, [Description("Session ID")] string sessionId, [Description("Search query")] CoreFindQuery query, + [Description("Optional root locator for subtree")] CoreElementLocator? root = null, [Description("Optional native window handle")] long? windowHandle = null, [Description("Only include visible elements")] bool visibleOnly = true, [Description("Maximum returned matches")] int maxResults = 25, @@ -319,7 +320,7 @@ public static Task FindElements( () => automation.FindElementsAsync( InspectionBackend.Auto, effectiveWindowHandle, - root: null, + root?.ToElementLocator(), query.ToFindElementsQuery(), visibleOnly, includeOffViewport: true, @@ -526,6 +527,7 @@ public static Task GetDataContext( [Description("Optional native window handle")] long? windowHandle = null, [Description("Maximum object graph depth")] int maxDepth = 2, [Description("Optional root property allowlist")] string[]? properties = null, + [Description("Optional dotted property paths; at most 32 paths and maxDepth segments per path")] string[]? propertyPaths = null, CancellationToken cancellationToken = default) => McpToolErrors.RunAsync(() => { @@ -543,6 +545,7 @@ public static Task GetDataContext( includeNulls: false, includeFrameworkProperties: false, propertyAllowList: properties, + propertyPaths: propertyPaths, cancellationToken), cancellationToken); }); @@ -580,6 +583,30 @@ public static Task GetComputedProperties( cancellationToken); }); + [McpServerTool(Name = "get_computed_properties_batch", UseStructuredContent = true), Description("Inspect selected dependency properties for a bounded set of WPF element IDs in one agent call.")] + public static Task GetComputedPropertiesBatch( + SessionManager sessions, + [Description("Session ID")] string sessionId, + [Description("WPF element IDs from find_elements or resolve_element; hard limit 500 input IDs")] string[] elementIds, + [Description("Dependency property names to inspect; hard limit 200 input names")] string[] propertyNames, + [Description("Optional native window handle; all IDs must belong to this window")] long? windowHandle = null, + [Description("Maximum elements inspected; hard limit 200")] int maxElements = 128, + [Description("Maximum properties inspected per element; hard limit 50")] int maxPropertiesPerElement = 16, + CancellationToken cancellationToken = default) => + McpToolErrors.RunAsync(() => + { + var (automation, _) = sessions.GetController(sessionId, windowHandle); + return automation.RunExclusiveAsync( + () => automation.GetComputedPropertiesBatchAsync( + elementIds, + propertyNames, + windowHandle, + maxElements, + maxPropertiesPerElement, + cancellationToken), + cancellationToken); + }); + [McpServerTool(Name = "get_layout_context", UseStructuredContent = true), Description("Inspect bounded WPF layout metrics and nearby visual context for an element.")] public static Task GetLayoutContext( SessionManager sessions, diff --git a/tests/WpfToolsMcp.SnapshotTests/InspectionResponseContractTests.cs b/tests/WpfToolsMcp.SnapshotTests/InspectionResponseContractTests.cs index 67d59a6..951e549 100644 --- a/tests/WpfToolsMcp.SnapshotTests/InspectionResponseContractTests.cs +++ b/tests/WpfToolsMcp.SnapshotTests/InspectionResponseContractTests.cs @@ -115,6 +115,32 @@ public void Inspection_metadata_requires_an_agent_that_advertises_the_wire_shape }); } + [Test] + public void Bounded_inspection_features_require_explicit_agent_capabilities() + { + var previousAgent = new AgentCapabilitiesResponse( + AgentProtocolCapabilities.CurrentProtocolVersion, + [AgentProtocolCapabilities.InspectionResponseMetadata]); + var currentAgent = new AgentCapabilitiesResponse( + AgentProtocolCapabilities.CurrentProtocolVersion, + AgentProtocolCapabilities.Current); + + var batchException = Assert.Throws( + () => AutomationController.EnsureComputedPropertiesBatchCapability(previousAgent)); + var pathException = Assert.Throws( + () => AutomationController.EnsureDataContextPropertyPathsCapability(previousAgent)); + + Assert.Multiple(() => + { + Assert.That(batchException!.Message, Does.StartWith("agent_capability_unavailable:")); + Assert.That(pathException!.Message, Does.StartWith("agent_capability_unavailable:")); + Assert.DoesNotThrow( + () => AutomationController.EnsureComputedPropertiesBatchCapability(currentAgent)); + Assert.DoesNotThrow( + () => AutomationController.EnsureDataContextPropertyPathsCapability(currentAgent)); + }); + } + private static TakeScreenshotResponse CreateScreenshotResponse() => new( Path: "capture.png", diff --git a/tests/WpfToolsMcp.SnapshotTests/InspectionResponseMetadataWpfTests.cs b/tests/WpfToolsMcp.SnapshotTests/InspectionResponseMetadataWpfTests.cs index c53493e..41433fa 100644 --- a/tests/WpfToolsMcp.SnapshotTests/InspectionResponseMetadataWpfTests.cs +++ b/tests/WpfToolsMcp.SnapshotTests/InspectionResponseMetadataWpfTests.cs @@ -341,6 +341,71 @@ [new ThrowingDataContextKey()] = "payload" } } + [Test] + public void Data_context_property_paths_cache_shared_getters_and_report_unresolved_paths() + { + var ownerId = $"data-context-paths-{Guid.NewGuid():N}"; + var model = new CountingDataContextModel(); + var target = new Border + { + Name = "DataContextTarget", + Width = 100, + Height = 30, + DataContext = model + }; + AutomationProperties.SetAutomationId(target, "DataContextTarget"); + var window = CreateWindow(target); + + try + { + ShowAndLayout(window); + var response = WpfVisualTreeInspector.GetDataContext( + ownerId, + new GetDataContextRequest( + WindowHandle: GetWindowHandle(window), + Locator: new ElementLocator(AutomationId: "DataContextTarget"), + Mode: DataContextMode.Summary, + MaxDepth: 2, + PropertyPaths: ["Current.Kind", "Current.Key", "Absent.Kind", "Unknown.Value"]), + CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(model.CurrentReads, Is.EqualTo(1)); + Assert.That(response.Data?["Current"]?["Kind"]?.GetValue(), Is.EqualTo("Primary")); + Assert.That(response.Data?["Current"]?["Key"]?.GetValue(), Is.EqualTo("item-7")); + Assert.That(response.Warnings, Has.Some.StartsWith("property_path_null:")); + Assert.That(response.Warnings, Has.Some.StartsWith("property_path_not_found:")); + }); + + var tooManyPaths = Enumerable.Range(0, DataContextPropertyPathLimits.MaxPaths + 1) + .Select(index => $"Property{index}") + .ToArray(); + var exception = Assert.Throws(() => WpfVisualTreeInspector.GetDataContext( + ownerId, + new GetDataContextRequest( + WindowHandle: GetWindowHandle(window), + Locator: new ElementLocator(AutomationId: "DataContextTarget"), + PropertyPaths: tooManyPaths), + CancellationToken.None)); + Assert.That(exception!.Message, Does.Contain("supports at most 32 paths")); + + var depthException = Assert.Throws(() => WpfVisualTreeInspector.GetDataContext( + ownerId, + new GetDataContextRequest( + WindowHandle: GetWindowHandle(window), + Locator: new ElementLocator(AutomationId: "DataContextTarget"), + MaxDepth: 0, + PropertyPaths: ["Current"]), + CancellationToken.None)); + Assert.That(depthException!.Message, Does.Contain("maxDepth of at least 1")); + } + finally + { + CloseAndRelease(window, ownerId); + } + } + [Test] public void Style_and_template_nested_collections_report_one_item_lookahead() { @@ -628,6 +693,31 @@ private sealed class DataContextChild public string Value { get; set; } = string.Empty; } + private sealed class CountingDataContextModel + { + private readonly CountingDataContextChild _current = new(); + + public int CurrentReads { get; private set; } + + public CountingDataContextChild Current + { + get + { + CurrentReads++; + return _current; + } + } + + public CountingDataContextChild? Absent => null; + } + + private sealed class CountingDataContextChild + { + public string Kind => "Primary"; + + public string Key => "item-7"; + } + private sealed class ThrowingDataContextDictionary : Dictionary { public override string ToString() => diff --git a/tests/WpfToolsMcp.SnapshotTests/ToolProfileTests.cs b/tests/WpfToolsMcp.SnapshotTests/ToolProfileTests.cs index 8f49e61..8b98d16 100644 --- a/tests/WpfToolsMcp.SnapshotTests/ToolProfileTests.cs +++ b/tests/WpfToolsMcp.SnapshotTests/ToolProfileTests.cs @@ -26,6 +26,7 @@ public sealed class ToolProfileTests "get_binding_info", "get_command_info", "get_computed_properties", + "get_computed_properties_batch", "get_data_context", "get_element_properties", "get_layout_context", @@ -207,6 +208,18 @@ public async Task Inspection_output_schemas_expose_targeted_identity_and_complet "scannedProperties", "scanComplete", "truncatedReasons"); + AssertOutputContains( + tools["get_computed_properties_batch"], + "results", + "requestedElements", + "requestedProperties", + "scannedElements", + "returnedElements", + "maxElements", + "maxPropertiesPerElement", + "truncated", + "truncatedReasons", + "windowHandleUsed"); AssertOutputContains(tools["get_layout_context"], "windowHandleUsed"); if (!string.Equals(toolProfile, "diagnostics", StringComparison.Ordinal)) @@ -380,7 +393,7 @@ public async Task Default_profile_exposes_compact_schemas_for_noisy_tools() Assert.That(GetInputPropertyNames(tools["get_visual_tree"]), Is.EqualTo( new[] { "depth", "maxNodes", "root", "sessionId", "visibleOnly", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["find_elements"]), Is.EqualTo( - new[] { "maxResults", "query", "returnFields", "sessionId", "visibleOnly", "windowHandle" })); + new[] { "maxResults", "query", "returnFields", "root", "sessionId", "visibleOnly", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["get_uia_tree"]), Is.EqualTo( new[] { "depth", "maxNodes", "root", "sessionId", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["get_element_properties"]), Is.EqualTo( @@ -392,9 +405,11 @@ public async Task Default_profile_exposes_compact_schemas_for_noisy_tools() Assert.That(GetInputPropertyNames(tools["get_validation_errors"]), Is.EqualTo( new[] { "depth", "rootXPath", "sessionId", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["get_data_context"]), Is.EqualTo( - new[] { "elementId", "locator", "maxDepth", "properties", "sessionId", "windowHandle" })); + new[] { "elementId", "locator", "maxDepth", "properties", "propertyPaths", "sessionId", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["get_computed_properties"]), Is.EqualTo( new[] { "elementId", "locator", "propertyNames", "sessionId", "windowHandle" })); + Assert.That(GetInputPropertyNames(tools["get_computed_properties_batch"]), Is.EqualTo( + new[] { "elementIds", "maxElements", "maxPropertiesPerElement", "propertyNames", "sessionId", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["get_layout_context"]), Is.EqualTo( new[] { "elementId", "locator", "sessionId", "windowHandle" })); Assert.That(GetInputPropertyNames(tools["capture_diagnostic_snapshot"]), Is.EqualTo( @@ -451,6 +466,7 @@ public async Task Default_profile_exposes_compact_schemas_for_noisy_tools() "get_validation_errors", "get_data_context", "get_computed_properties", + "get_computed_properties_batch", "get_layout_context", "capture_diagnostic_snapshot", "click_element", @@ -1618,6 +1634,125 @@ public async Task Default_profile_supports_wpf_diagnostic_tools() } } + [Test] + public async Task Default_profile_supports_subtree_search_and_batch_property_inspection() + { + var serverExe = McpServerPaths.FindMcpServerExecutable(); + await using var mcp = await McpTestContext.StartAsync( + serverExe, + toolProfile: null, + environmentVariables: new Dictionary { ["WPF_TOOLS_MCP_TOOL_PROFILE"] = null }); + + var launch = await LaunchPrimaryTestAppAsync(mcp); + try + { + var subtreeMatches = await mcp.CallToolAsync("find_elements", new Dictionary + { + ["sessionId"] = launch.SessionId, + ["root"] = new Dictionary { ["automationId"] = "XPathDemo_Right_Button1" }, + ["query"] = new Dictionary { ["type"] = "TextBlock" }, + ["maxResults"] = 10 + }); + + Assert.Multiple(() => + { + Assert.That(subtreeMatches.BackendUsed, Is.EqualTo(InspectionBackend.Wpf)); + Assert.That(subtreeMatches.ReturnedMatches, Is.EqualTo(1)); + Assert.That(subtreeMatches.Matches[0].Type, Is.EqualTo("TextBlock")); + }); + + var matches = await mcp.CallToolAsync("find_elements", new Dictionary + { + ["sessionId"] = launch.SessionId, + ["query"] = new Dictionary { ["type"] = "Button" }, + ["maxResults"] = 3 + }); + var elementIds = matches.Matches + .Select(match => match.ElementId) + .Where(elementId => !string.IsNullOrWhiteSpace(elementId)) + .Select(elementId => elementId!) + .ToArray(); + Assert.That(elementIds, Has.Length.EqualTo(3)); + + var batch = await mcp.CallToolAsync("get_computed_properties_batch", new Dictionary + { + ["sessionId"] = launch.SessionId, + ["elementIds"] = elementIds, + ["propertyNames"] = new[] { "Content", "IsEnabled" }, + ["maxElements"] = 2, + ["maxPropertiesPerElement"] = 1 + }); + + Assert.Multiple(() => + { + Assert.That(batch.RequestedElements, Is.EqualTo(3)); + Assert.That(batch.RequestedProperties, Is.EqualTo(2)); + Assert.That(batch.ScannedElements, Is.EqualTo(2)); + Assert.That(batch.ReturnedElements, Is.EqualTo(2)); + Assert.That(batch.MaxElements, Is.EqualTo(2)); + Assert.That(batch.MaxPropertiesPerElement, Is.EqualTo(1)); + Assert.That(batch.Truncated, Is.True); + Assert.That(batch.TruncatedReasons, Is.EquivalentTo(new[] { "maxElements", "maxPropertiesPerElement" })); + Assert.That(batch.Results.Select(result => result.Element.ElementId), + Is.EqualTo(elementIds.Take(2))); + Assert.That(batch.Results.All(result => result.Properties.Count <= 1), Is.True); + }); + + var boundedBeforeResolution = await mcp.CallToolAsync("get_computed_properties_batch", new Dictionary + { + ["sessionId"] = launch.SessionId, + ["elementIds"] = new[] { elementIds[0], "not-inspected-because-of-limit" }, + ["propertyNames"] = new[] { "IsEnabled" }, + ["maxElements"] = 1 + }); + + Assert.Multiple(() => + { + Assert.That(boundedBeforeResolution.RequestedElements, Is.EqualTo(2)); + Assert.That(boundedBeforeResolution.ReturnedElements, Is.EqualTo(1)); + Assert.That(boundedBeforeResolution.Results[0].Element.ElementId, Is.EqualTo(elementIds[0])); + Assert.That(boundedBeforeResolution.TruncatedReasons, Does.Contain("maxElements")); + }); + } + finally + { + await CloseSessionBestEffortAsync(mcp, launch.SessionId); + } + } + + [Test] + public async Task Default_profile_supports_targeted_data_context_paths() + { + var serverExe = McpServerPaths.FindMcpServerExecutable(); + await using var mcp = await McpTestContext.StartAsync( + serverExe, + toolProfile: null, + environmentVariables: new Dictionary { ["WPF_TOOLS_MCP_TOOL_PROFILE"] = null }); + + var launch = await LaunchAppAsync(mcp, TestAppPaths.FindDataGridTestAppExecutable()); + try + { + var dataContext = await mcp.CallToolAsync("get_data_context", new Dictionary + { + ["sessionId"] = launch.SessionId, + ["locator"] = new Dictionary { ["automationId"] = "DataGrid_PeopleGrid" }, + ["maxDepth"] = 2, + ["propertyPaths"] = new[] { "People.Count" } + }); + + Assert.Multiple(() => + { + Assert.That(dataContext.DataContextType, Does.Contain("DataGridViewModel")); + Assert.That(dataContext.Data?["People"]?["Count"]?.GetValue(), Is.EqualTo(4)); + Assert.That(dataContext.Warnings, Is.Null.Or.Empty); + }); + } + finally + { + await CloseSessionBestEffortAsync(mcp, launch.SessionId); + } + } + private static string[] GetInputPropertyNames(McpClientTool tool) { var schema = tool.JsonSchema;