diff --git a/Directory.Packages.props b/Directory.Packages.props index 9e5bf7a4..e40545ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,10 +11,12 @@ + + diff --git a/XAMLTest.Mcp/.mcp/server.json b/XAMLTest.Mcp/.mcp/server.json new file mode 100644 index 00000000..f5b27027 --- /dev/null +++ b/XAMLTest.Mcp/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/XAMLTest.Mcp/AppServiceManager.cs b/XAMLTest.Mcp/AppServiceManager.cs new file mode 100644 index 00000000..db2b2021 --- /dev/null +++ b/XAMLTest.Mcp/AppServiceManager.cs @@ -0,0 +1,57 @@ + +using XamlTest; + +namespace XAMLTest.Mcp; + +public sealed class AppServiceManager : IAsyncDisposable +{ + private Dictionary RunningApps { get; } = []; + + public async ValueTask DisposeAsync() + { + List apps; + lock (RunningApps) + { + apps = [.. RunningApps.Values]; + RunningApps.Clear(); + } + await Task.WhenAll(apps.Select(app => app.DisposeAsync().AsTask())); + } + + public bool TryGetApp(string appId, [NotNullWhen(true)] out IApp? app) + { + lock (RunningApps) + { + return RunningApps.TryGetValue(appId, out app); + } + } + + public async Task ShutdownApp(string appId) + { + IApp? app; + lock (RunningApps) + { + if (RunningApps.TryGetValue(appId, out app)) + { + RunningApps.Remove(appId); + } + } + if (app is not null) + { + await app.DisposeAsync(); + return true; + } + return false; + } + + public string RegisterApp(IApp app) + { + string appId; + lock (RunningApps) + { + appId = $"app{RunningApps.Count + 1}"; + RunningApps[appId] = app; + } + return appId; + } +} \ No newline at end of file diff --git a/XAMLTest.Mcp/Program.cs b/XAMLTest.Mcp/Program.cs new file mode 100644 index 00000000..af2579c1 --- /dev/null +++ b/XAMLTest.Mcp/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using XAMLTest.Mcp; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +builder.Services.AddSingleton(); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + +var app = builder.Build(); +await app.RunAsync(); diff --git a/XAMLTest.Mcp/README.md b/XAMLTest.Mcp/README.md new file mode 100644 index 00000000..5bf315d6 --- /dev/null +++ b/XAMLTest.Mcp/README.md @@ -0,0 +1,98 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "XAMLTest.Mcp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `XAMLTest.Mcp` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "XAMLTest.Mcp": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/XAMLTest.Mcp/SharedStrings.cs b/XAMLTest.Mcp/SharedStrings.cs new file mode 100644 index 00000000..f49564e9 --- /dev/null +++ b/XAMLTest.Mcp/SharedStrings.cs @@ -0,0 +1,47 @@ +namespace XAMLTest.Mcp; + +public static class SharedStrings +{ + public const string AppIdDescription = "The XAMLTest application id"; + + public const string XamlSnippetDescription = + """ + The XAML snippet to render in a WPF Window. + This should only include the content inside the Window tags. + """; + + public const string ElementQueryDescription = + """ + A query string to find a visual element. Supported formats: + ~ - Search by element Name (default if no prefix). + / - Search by type name (e.g. /Button). Supports base types and [Index] suffix (e.g. /Button[1]). + . - Navigate to a property value of the previous element. + [=] - Search for an element where the property matches the value. + Queries can be chained (e.g. /StackPanel/Button[0]). + """; + + public const string PropertyNameDescription = "The name of the property to access (e.g. Content, Text, IsEnabled, Width)."; + + public const string InputActionsJsonDescription = + """ + A JSON array of ordered interaction actions to execute on the target element. + Each action must include a `type` property. Supported action types: + - focus + - delay (milliseconds) + - mouse_move_to_element (position, xOffset, yOffset) + - mouse_move_relative (xOffset, yOffset) + - mouse_move_absolute (x, y) + - mouse_button_down (button: left|right|middle) + - mouse_button_up (button: left|right|middle) + - mouse_click (button, count, position, xOffset, yOffset, clickDelayMs) + - keyboard_text (text) + - keyboard_keys (keys: string[]) + Example: + [ + { "type": "focus" }, + { "type": "mouse_click", "button": "left", "position": "Center" }, + { "type": "keyboard_text", "text": "Hello world" }, + { "type": "keyboard_keys", "keys": ["Enter"] } + ] + """; +} diff --git a/XAMLTest.Mcp/Tools/AppTools.cs b/XAMLTest.Mcp/Tools/AppTools.cs new file mode 100644 index 00000000..210aef23 --- /dev/null +++ b/XAMLTest.Mcp/Tools/AppTools.cs @@ -0,0 +1,120 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using XamlTest; +using XAMLTest.Mcp; + +[McpServerToolType] +internal class AppTools(AppServiceManager appServiceManager) : BaseTools +{ + [McpServerTool] + [Description(""" + Starts a new WPF application using the provided application XAML, referenced assemblies, and creates a window with the specified XAML. + This return the XAMLTest Id of the application. + """)] + public async Task StartApp( + [Description(""" + The full XAML for a WPF window, including its contents. + """)] string windowXaml, + [Description(""" + This is the raw application XAML to initialize the WPF app with. + """)] string? appXaml = null, + [Description(""" + The full path to any additional assemblies to load into the application domain. + """)] string?[]? additionalAssemblies = null) + { + var app = await App.StartRemote(new AppOptions() + { + AllowVisualStudioDebuggerAttach = false, + MinimizeOtherWindows = false + }); + + if (!string.IsNullOrWhiteSpace(appXaml)) + { + string[] assemblies = []; + if (additionalAssemblies is not null) + { + assemblies = additionalAssemblies.Where(x => x is not null).ToArray()!; + } + await app.Initialize(appXaml, assemblies); + } + else + { + await app.InitializeWithDefaults(); + } + + var _ = await app.CreateWindow(windowXaml); + + return appServiceManager.RegisterApp(app); + } + + [McpServerTool] + [Description(""" + Starts a new WPF application and creates a window with the specified XAML snippet as its content. + This return the XAMLTest Id of the application. + """)] + public async Task StartAppWithXamlSnippet( + [Description(SharedStrings.XamlSnippetDescription)] string xamlSnippet) + { + var app = await App.StartRemote(new AppOptions() + { + AllowVisualStudioDebuggerAttach = false, + MinimizeOtherWindows = false + }); + + await app.InitializeWithDefaults(); + + var _ = await app.CreateWindowWithContent(xamlSnippet); + + return appServiceManager.RegisterApp(app); + } + + [McpServerTool] + [Description(""" + Shuts down the specified XAML Test application. + """)] + public async Task ShutdownApp( + [Description(SharedStrings.AppIdDescription)] string appId) + { + return (await appServiceManager.ShutdownApp(appId)) + ? Success() + : Failure($"No known app with id '{appId}' is running"); + } + + [McpServerTool] + [Description(""" + Captures a screenshot of the specified XAML Test application and returns it as inline BMP image content. + """)] + public async Task SaveScreenshot(string appId, + [Description("Optional file path locations for where to same the image")] + string? filePath = null) + { + if (!appServiceManager.TryGetApp(appId, out var app)) + { + return Failure($"No known app with id '{appId}' is running"); + } + + IImage screenshot = await app.GetScreenshot(); + + using MemoryStream bmpStream = new(); + await screenshot.Save(bmpStream); + bmpStream.Position = 0; + byte[] bmpBytes = bmpStream.ToArray(); + + if (filePath is not null) + { + await File.WriteAllBytesAsync(filePath, bmpBytes); + } + + return new CallToolResult + { + IsError = false, + Content = + [ + new TextContentBlock { Text = $"Screenshot for {appId}:" }, + ImageContentBlock.FromBytes(bmpBytes, "image/bmp") + ] + }; + } +} + diff --git a/XAMLTest.Mcp/Tools/BaseTools.cs b/XAMLTest.Mcp/Tools/BaseTools.cs new file mode 100644 index 00000000..d088f72e --- /dev/null +++ b/XAMLTest.Mcp/Tools/BaseTools.cs @@ -0,0 +1,24 @@ +using ModelContextProtocol.Protocol; + +internal abstract class BaseTools +{ + protected static CallToolResult Failure(string message) + => new() + { + IsError = true, + Content = [new TextContentBlock { Text = message }] + }; + + protected static CallToolResult Success(string? message = null) + { + IList content = message is null + ? [] + : [ new TextContentBlock { Text = message } ]; + return new() + { + IsError = false, + Content = content + }; + } +} + diff --git a/XAMLTest.Mcp/Tools/VisualElementTools.cs b/XAMLTest.Mcp/Tools/VisualElementTools.cs new file mode 100644 index 00000000..426526fd --- /dev/null +++ b/XAMLTest.Mcp/Tools/VisualElementTools.cs @@ -0,0 +1,477 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Windows.Input; +using XamlTest; +using XAMLTest.Mcp; + +[McpServerToolType] +internal class VisualElementTools(AppServiceManager appServiceManager) + : BaseTools +{ + [McpServerTool] + [Description(""" + Gets the visual tree of the main window in a running WPF application. + Returns an indented text representation showing element types, names, and hierarchy. + """)] + public async Task GetVisualTree( + [Description(SharedStrings.AppIdDescription)] string appId) + { + if (!appServiceManager.TryGetApp(appId, out var app)) + { + return Failure($"App with id '{appId}' is not running"); + } + + var window = await app.GetMainWindow(); + if (window is null) + { + return Failure("Could not get main window"); + } + + var tree = await window.GetVisualTree(); + return Success(tree.ToString()); + } + + [McpServerTool] + [Description(""" + Gets the value of a property from a visual element in a running WPF application. + Use GetVisualTree first to discover element names and types, then query by name or type. + """)] + public async Task GetElementProperty( + [Description(SharedStrings.AppIdDescription)] string appId, + [Description(SharedStrings.ElementQueryDescription)] string elementQuery, + [Description(SharedStrings.PropertyNameDescription)] string propertyName, + [Description("The assembly-qualified name of the type that owns the property. Usually not needed for standard properties.")] + string? ownerType = null) + { + if (!appServiceManager.TryGetApp(appId, out var app)) + { + return Failure($"App with id '{appId}' is not running"); + } + + var window = await app.GetMainWindow(); + if (window is null) + { + return Failure("Could not get main window"); + } + + var element = await window.FindElement(elementQuery); + if (element is null) + { + return Failure($"Could not find element matching query '{elementQuery}'"); + } + + var value = await element.GetProperty(propertyName, ownerType); + string result = $"Property: {propertyName}\nValueType: {value.ValueType}\nValue: {value.Value}"; + return Success(result); + } + + [McpServerTool] + [Description(""" + Sets the value of a property on a visual element in a running WPF application. + Use GetVisualTree first to discover element names and types, then query by name or type. + """)] + public async Task SetElementProperty( + [Description(SharedStrings.AppIdDescription)] string appId, + [Description(SharedStrings.ElementQueryDescription)] string elementQuery, + [Description(SharedStrings.PropertyNameDescription)] string propertyName, + [Description("The string representation of the value to set.")] string value, + [Description("The assembly-qualified type name of the value (e.g. 'System.String'). If omitted, the type will be inferred.")] + string? valueType = null, + [Description("The assembly-qualified name of the type that owns the property. Usually not needed for standard properties.")] + string? ownerType = null) + { + if (!appServiceManager.TryGetApp(appId, out var app)) + { + return Failure($"App with id '{appId}' is not running"); + } + + var window = await app.GetMainWindow(); + if (window is null) + { + return Failure("Could not get main window"); + } + + var element = await window.FindElement(elementQuery); + if (element is null) + { + return Failure($"Could not find element matching query '{elementQuery}'"); + } + + var result = await element.SetProperty(propertyName, value, valueType, ownerType); + string response = $"Property: {propertyName}\nValueType: {result.ValueType}\nValue: {result.Value}"; + return Success(response); + } + + [McpServerTool] + [Description(""" + Executes ordered direct UI interactions (mouse and keyboard) against a visual element in a running WPF application. + Use this single tool for mixed interaction flows such as click + typing + key presses. + """)] + public async Task Interact( + [Description(SharedStrings.AppIdDescription)] string appId, + [Description(""" + The query used to select the target element. + If omitted, the main window is used as the interaction target. + """)] + string? elementQuery, + [Description(SharedStrings.InputActionsJsonDescription)] string inputActionsJson) + { + if (!appServiceManager.TryGetApp(appId, out var app)) + { + return Failure($"App with id '{appId}' is not running"); + } + + var window = await app.GetMainWindow(); + if (window is null) + { + return Failure("Could not get main window"); + } + + IVisualElement targetElement = window; + if (!string.IsNullOrWhiteSpace(elementQuery)) + { + var foundElement = await window.FindElement(elementQuery); + if (foundElement is null) + { + return Failure($"Could not find element matching query '{elementQuery}'"); + } + targetElement = foundElement; + } + + JsonDocument actionsDocument; + try + { + actionsDocument = JsonDocument.Parse(inputActionsJson); + } + catch (JsonException ex) + { + return Failure($"Invalid JSON payload for inputActionsJson: {ex.Message}"); + } + + using (actionsDocument) + { + if (actionsDocument.RootElement.ValueKind != JsonValueKind.Array) + { + return Failure("inputActionsJson must be a JSON array of action objects."); + } + + int executedActions = 0; + foreach (var (action, index) in actionsDocument.RootElement.EnumerateArray().Select((value, idx) => (value, idx))) + { + if (action.ValueKind != JsonValueKind.Object) + { + return Failure($"Action at index {index} must be a JSON object."); + } + + try + { + await ExecuteAction(action, index, targetElement); + } + catch (InvalidOperationException ex) + { + return Failure(ex.Message); + } + + executedActions++; + } + + return Success($"Executed {executedActions} interaction action(s)."); + } + } + + [McpServerTool] + [Description(""" + Updates the XAML content of a running WPF application with the provided XAML snippet. + """)] + public async Task UpdateAppXaml( + [Description(SharedStrings.AppIdDescription)] string appId, + [Description(SharedStrings.XamlSnippetDescription)] string xamlSnippet) + { + if (!appServiceManager.TryGetApp(appId, out var existingApp)) + { + return Failure($"App with id '{appId}' is not running"); + } + + var window = await existingApp.GetMainWindow(); + if (window is null) + { + return Failure("Could not get main window"); + } + + await window.SetXamlContent(xamlSnippet); + return Success("XAML content updated successfully"); + } + + private static async Task ExecuteAction(JsonElement action, int index, IVisualElement targetElement) + { + string actionType = GetRequiredString(action, "type", index).ToLowerInvariant(); + switch (actionType) + { + case "focus": + await targetElement.MoveKeyboardFocus(); + return; + case "delay": + { + int milliseconds = GetRequiredInt(action, "milliseconds", index); + if (milliseconds < 0) + { + throw new InvalidOperationException($"Action at index {index} has invalid 'milliseconds'. Expected a non-negative integer."); + } + + await Task.Delay(milliseconds); + return; + } + case "mouse_move_to_element": + { + Position position = GetOptionalPosition(action, "position", Position.Center, index); + int xOffset = GetOptionalInt(action, "xOffset", 0, index); + int yOffset = GetOptionalInt(action, "yOffset", 0, index); + _ = await targetElement.MoveCursorTo(position, xOffset, yOffset); + return; + } + case "mouse_move_relative": + { + int xOffset = GetRequiredInt(action, "xOffset", index); + int yOffset = GetRequiredInt(action, "yOffset", index); + _ = await targetElement.SendInput(MouseInput.MoveRelative(xOffset, yOffset)); + return; + } + case "mouse_move_absolute": + { + int x = GetRequiredInt(action, "x", index); + int y = GetRequiredInt(action, "y", index); + _ = await targetElement.SendInput(MouseInput.MoveAbsolute(x, y)); + return; + } + case "mouse_button_down": + { + var button = GetOptionalMouseButton(action, "button", "left", index); + await targetElement.SendInput(GetButtonDown(button)); + return; + } + case "mouse_button_up": + { + var button = GetOptionalMouseButton(action, "button", "left", index); + await targetElement.SendInput(GetButtonUp(button)); + return; + } + case "mouse_click": + { + var button = GetOptionalMouseButton(action, "button", "left", index); + int count = GetOptionalInt(action, "count", 1, index); + if (count <= 0) + { + throw new InvalidOperationException($"Action at index {index} has invalid 'count'. Expected an integer > 0."); + } + + Position position = GetOptionalPosition(action, "position", Position.Center, index); + int xOffset = GetOptionalInt(action, "xOffset", 0, index); + int yOffset = GetOptionalInt(action, "yOffset", 0, index); + int? clickDelayMs = GetOptionalNullableInt(action, "clickDelayMs", index); + if (clickDelayMs is < 0) + { + throw new InvalidOperationException($"Action at index {index} has invalid 'clickDelayMs'. Expected a non-negative integer."); + } + + _ = await targetElement.MoveCursorTo(position, xOffset, yOffset); + for (int click = 0; click < count; click++) + { + await targetElement.SendInput(GetButtonDown(button)); + if (clickDelayMs is int delayMs and > 0) + { + await Task.Delay(delayMs); + } + await targetElement.SendInput(GetButtonUp(button)); + } + return; + } + case "keyboard_text": + { + string text = GetRequiredString(action, "text", index); + await targetElement.SendInput(new KeyboardInput(text)); + return; + } + case "keyboard_keys": + { + var keys = GetRequiredKeys(action, "keys", index); + await targetElement.SendInput(new KeyboardInput(keys)); + return; + } + default: + throw new InvalidOperationException( + $"Action at index {index} has unsupported type '{actionType}'. " + + "Supported types: focus, delay, mouse_move_to_element, mouse_move_relative, mouse_move_absolute, " + + "mouse_button_down, mouse_button_up, mouse_click, keyboard_text, keyboard_keys."); + } + } + + private static string GetRequiredString(JsonElement action, string propertyName, int index) + { + if (!action.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Action at index {index} is missing required string property '{propertyName}'."); + } + + string? value = property.GetString(); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Action at index {index} has an empty value for '{propertyName}'."); + } + + return value; + } + + private static int GetRequiredInt(JsonElement action, string propertyName, int index) + { + if (!action.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Number || !property.TryGetInt32(out int value)) + { + throw new InvalidOperationException( + $"Action at index {index} is missing required integer property '{propertyName}'."); + } + + return value; + } + + private static int GetOptionalInt(JsonElement action, string propertyName, int defaultValue, int index) + { + if (!action.TryGetProperty(propertyName, out var property)) + { + return defaultValue; + } + + if (property.ValueKind != JsonValueKind.Number || !property.TryGetInt32(out int value)) + { + throw new InvalidOperationException( + $"Action at index {index} has invalid integer property '{propertyName}'."); + } + + return value; + } + + private static int? GetOptionalNullableInt(JsonElement action, string propertyName, int index) + { + if (!action.TryGetProperty(propertyName, out var property)) + { + return null; + } + + if (property.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (property.ValueKind != JsonValueKind.Number || !property.TryGetInt32(out int value)) + { + throw new InvalidOperationException( + $"Action at index {index} has invalid integer property '{propertyName}'."); + } + + return value; + } + + private static Position GetOptionalPosition(JsonElement action, string propertyName, Position defaultPosition, int index) + { + if (!action.TryGetProperty(propertyName, out var property)) + { + return defaultPosition; + } + + if (property.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Action at index {index} has invalid '{propertyName}'. Expected a string Position value."); + } + + string? rawPosition = property.GetString(); + if (string.IsNullOrWhiteSpace(rawPosition) || !Enum.TryParse(rawPosition, true, out Position parsedPosition)) + { + throw new InvalidOperationException( + $"Action at index {index} has invalid '{propertyName}' value '{rawPosition}'."); + } + + return parsedPosition; + } + + private static string GetOptionalMouseButton(JsonElement action, string propertyName, string defaultButton, int index) + { + if (!action.TryGetProperty(propertyName, out var property)) + { + return defaultButton; + } + + if (property.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Action at index {index} has invalid '{propertyName}'. Expected one of left, right, or middle."); + } + + string? rawButton = property.GetString(); + return rawButton?.ToLowerInvariant() switch + { + "left" => "left", + "right" => "right", + "middle" => "middle", + _ => throw new InvalidOperationException( + $"Action at index {index} has invalid '{propertyName}' value '{rawButton}'. Expected left, right, or middle.") + }; + } + + private static MouseInput GetButtonDown(string button) => button switch + { + "left" => MouseInput.LeftDown(), + "right" => MouseInput.RightDown(), + "middle" => MouseInput.MiddleDown(), + _ => throw new InvalidOperationException($"Unknown mouse button '{button}'.") + }; + + private static MouseInput GetButtonUp(string button) => button switch + { + "left" => MouseInput.LeftUp(), + "right" => MouseInput.RightUp(), + "middle" => MouseInput.MiddleUp(), + _ => throw new InvalidOperationException($"Unknown mouse button '{button}'.") + }; + + private static Key[] GetRequiredKeys(JsonElement action, string propertyName, int index) + { + if (!action.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Action at index {index} is missing required array property '{propertyName}'."); + } + + List keys = []; + int keyIndex = 0; + foreach (var keyValue in property.EnumerateArray()) + { + if (keyValue.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + $"Action at index {index} has invalid key value at '{propertyName}[{keyIndex}]'. Expected a key name string."); + } + + string? rawKey = keyValue.GetString(); + if (string.IsNullOrWhiteSpace(rawKey) || !Enum.TryParse(rawKey, true, out Key key)) + { + throw new InvalidOperationException( + $"Action at index {index} has unknown key '{rawKey}' at '{propertyName}[{keyIndex}]'."); + } + + keys.Add(key); + keyIndex++; + } + + if (keys.Count == 0) + { + throw new InvalidOperationException( + $"Action at index {index} must specify at least one key in '{propertyName}'."); + } + + return [.. keys]; + } +} + diff --git a/XAMLTest.Mcp/XAMLTest.Mcp.csproj b/XAMLTest.Mcp/XAMLTest.Mcp.csproj new file mode 100644 index 00000000..00087a7f --- /dev/null +++ b/XAMLTest.Mcp/XAMLTest.Mcp.csproj @@ -0,0 +1,43 @@ + + + + net10.0-windows + win-x64;win-arm64 + Exe + + + + McpServer + + + + true + + + true + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + + + + + + diff --git a/XAMLTest.TestApp/XAMLTest.TestApp.csproj b/XAMLTest.TestApp/XAMLTest.TestApp.csproj index 83104ede..cc79fd70 100644 --- a/XAMLTest.TestApp/XAMLTest.TestApp.csproj +++ b/XAMLTest.TestApp/XAMLTest.TestApp.csproj @@ -2,7 +2,7 @@ WinExe - net10.0-windows;net9.0-windows + net10.0-windows true false false diff --git a/XAMLTest.slnx b/XAMLTest.slnx index bcb048ac..baeca8a2 100644 --- a/XAMLTest.slnx +++ b/XAMLTest.slnx @@ -13,6 +13,7 @@ + diff --git a/XAMLTest/Host/VisualTreeService.VisualTree.cs b/XAMLTest/Host/VisualTreeService.VisualTree.cs new file mode 100644 index 00000000..2496eaab --- /dev/null +++ b/XAMLTest/Host/VisualTreeService.VisualTree.cs @@ -0,0 +1,73 @@ +using Grpc.Core; +using System.Windows.Media; +using Window = System.Windows.Window; + +namespace XamlTest.Host; + +internal partial class VisualTreeService +{ + public override async Task GetVisualTree(GetVisualTreeQuery request, ServerCallContext context) + { + GetVisualTreeResult reply = new(); + + await Application.Dispatcher.InvokeAsync(() => + { + try + { + Window? window = GetCachedElement(request.WindowId); + if (window is null) + { + reply.ErrorMessages.Add("Failed to find window"); + return; + } + + reply.Root = BuildVisualTreeNode(window); + } + catch (Exception e) + { + reply.ErrorMessages.Add(e.ToString()); + } + }); + + return reply; + } + + private static VisualTreeNode BuildVisualTreeNode(DependencyObject element) + { + VisualTreeNode node = new() + { + Type = element.GetType().Name, + Name = (element as FrameworkElement)?.Name ?? "" + }; + + foreach (DependencyObject child in GetVisualChildren(element)) + { + node.Children.Add(BuildVisualTreeNode(child)); + } + + return node; + } + + private static IEnumerable GetVisualChildren(DependencyObject parent) + { + int childCount = VisualTreeHelper.GetChildrenCount(parent); + for (int i = 0; i < childCount; i++) + { + if (VisualTreeHelper.GetChild(parent, i) is DependencyObject child) + { + yield return child; + } + } + + if (childCount == 0) + { + foreach (object? logicalChild in LogicalTreeHelper.GetChildren(parent)) + { + if (logicalChild is DependencyObject child) + { + yield return child; + } + } + } + } +} diff --git a/XAMLTest/Host/XamlTestSpec.proto b/XAMLTest/Host/XamlTestSpec.proto index 8e0d6f78..90667938 100644 --- a/XAMLTest/Host/XamlTestSpec.proto +++ b/XAMLTest/Host/XamlTestSpec.proto @@ -72,6 +72,9 @@ service Protocol { //Highlight an element rpc HighlightElement(HighlightRequest) returns (HighlightResult); + + //Get the visual tree for a window + rpc GetVisualTree (GetVisualTreeQuery) returns (GetVisualTreeResult); } message GetWindowsQuery { @@ -332,4 +335,19 @@ message HighlightRequest { message HighlightResult { repeated string errorMessages = 1; +} + +message GetVisualTreeQuery { + string windowId = 1; +} + +message VisualTreeNode { + string type = 1; + string name = 2; + repeated VisualTreeNode children = 3; +} + +message GetVisualTreeResult { + repeated string errorMessages = 1; + VisualTreeNode root = 2; } \ No newline at end of file diff --git a/XAMLTest/IWindow.cs b/XAMLTest/IWindow.cs index e4fa83cc..8e2e7329 100644 --- a/XAMLTest/IWindow.cs +++ b/XAMLTest/IWindow.cs @@ -2,5 +2,9 @@ public interface IWindow : IVisualElement, IEquatable { - + /// + /// Gets the visual tree rooted at this window. + /// + /// A task that returns the root representing the window's visual tree. + Task GetVisualTree(); } diff --git a/XAMLTest/Internal/App.cs b/XAMLTest/Internal/App.cs index da295ffc..647974a0 100644 --- a/XAMLTest/Internal/App.cs +++ b/XAMLTest/Internal/App.cs @@ -1,4 +1,4 @@ -using Grpc.Core; +using Grpc.Core; using XamlTest.Host; namespace XamlTest.Internal; @@ -75,7 +75,7 @@ public async ValueTask DisposeAsync() } catch (OperationCanceledException) { } - catch(RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) { } finally { @@ -277,7 +277,7 @@ public async Task> GetWindows() { return reply.WindowIds.Select(x => new Window(Client, x, Context, LogMessage)).ToList(); } - return Array.Empty(); + return []; } public async Task GetScreenshot() diff --git a/XAMLTest/Internal/BitmapImage.cs b/XAMLTest/Internal/BitmapImage.cs index fe94d472..b1589a2c 100644 --- a/XAMLTest/Internal/BitmapImage.cs +++ b/XAMLTest/Internal/BitmapImage.cs @@ -1,6 +1,4 @@ -using System.IO; -using System.Threading.Tasks; -using Google.Protobuf; +using Google.Protobuf; namespace XamlTest.Internal; @@ -8,7 +6,7 @@ internal class BitmapImage : IImage { private ByteString Data { get; } - public BitmapImage(ByteString data) => Data = data ?? throw new System.ArgumentNullException(nameof(data)); + public BitmapImage(ByteString data) => Data = data ?? throw new ArgumentNullException(nameof(data)); public Task Save(Stream stream) { diff --git a/XAMLTest/Internal/VisualElement.cs b/XAMLTest/Internal/VisualElement.cs index 30a8c03f..498e7ce2 100644 --- a/XAMLTest/Internal/VisualElement.cs +++ b/XAMLTest/Internal/VisualElement.cs @@ -31,7 +31,7 @@ public VisualElement( private AppContext Context { get; } private Serializer Serializer => Context.Serializer; - private Protocol.ProtocolClient Client { get; } + protected Protocol.ProtocolClient Client { get; } public string Id { get; } public Type Type { get; } diff --git a/XAMLTest/Internal/Window.cs b/XAMLTest/Internal/Window.cs index 8fd00339..4b2c0282 100644 --- a/XAMLTest/Internal/Window.cs +++ b/XAMLTest/Internal/Window.cs @@ -19,4 +19,36 @@ protected override Host.ElementQuery GetFindElementQuery(string query) WindowId = Id, Query = query }; + + public async Task GetVisualTree() + { + LogMessage?.Invoke($"{nameof(GetVisualTree)}()"); + GetVisualTreeQuery request = new() + { + WindowId = Id + }; + if (await Client.GetVisualTreeAsync(request) is { } reply) + { + if (reply.ErrorMessages.Any()) + { + throw new XamlTestException(string.Join(Environment.NewLine, reply.ErrorMessages)); + } + if (reply.Root is { } root) + { + return MapNode(root); + } + throw new XamlTestException("Visual tree result did not contain a root node"); + } + throw new XamlTestException("Failed to receive a reply"); + } + + private static VisualTreeNodeInfo MapNode(VisualTreeNode node) + { + return new VisualTreeNodeInfo + { + Type = node.Type, + Name = node.Name, + Children = node.Children.Select(MapNode).ToList() + }; + } } diff --git a/XAMLTest/VTMixins.cs b/XAMLTest/VTMixins.cs index c7155a3d..8b44747e 100644 --- a/XAMLTest/VTMixins.cs +++ b/XAMLTest/VTMixins.cs @@ -1,7 +1,4 @@ -using System.IO; -using System.Threading.Tasks; - -namespace XamlTest; +namespace XamlTest; public static class VTMixins { diff --git a/XAMLTest/VisualTreeNodeInfo.cs b/XAMLTest/VisualTreeNodeInfo.cs new file mode 100644 index 00000000..ed80c62e --- /dev/null +++ b/XAMLTest/VisualTreeNodeInfo.cs @@ -0,0 +1,47 @@ +namespace XamlTest; + +/// +/// Represents a node in the WPF visual tree. +/// +public class VisualTreeNodeInfo +{ + /// + /// The type name of the element. + /// + public required string Type { get; init; } + + /// + /// The Name property of the element, if it is a FrameworkElement. + /// + public required string Name { get; init; } + + /// + /// The child nodes in the visual tree. + /// + public required IReadOnlyList Children { get; init; } + + /// + /// Returns an indented text representation of the visual tree. + /// + public override string ToString() + { + var sb = new System.Text.StringBuilder(); + AppendTo(sb, 0); + return sb.ToString(); + } + + private void AppendTo(System.Text.StringBuilder sb, int depth) + { + sb.Append(' ', depth * 2); + sb.Append(Type); + if (!string.IsNullOrEmpty(Name)) + { + sb.Append($" (Name=\"{Name}\")"); + } + sb.AppendLine(); + foreach (var child in Children) + { + child.AppendTo(sb, depth + 1); + } + } +} diff --git a/XAMLTest/XAMLTest.csproj b/XAMLTest/XAMLTest.csproj index d8933537..4c6f5792 100644 --- a/XAMLTest/XAMLTest.csproj +++ b/XAMLTest/XAMLTest.csproj @@ -2,7 +2,7 @@ WinExe - net10.0-windows7;net9.0-windows7 + net10.0-windows7 true true XamlTest @@ -24,7 +24,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - @@ -43,7 +42,7 @@ - +