From 71c8fb81d4f5ccc6551401b0f30b235e6b1249ad Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Fri, 19 Dec 2025 01:21:06 -0800 Subject: [PATCH 1/7] Adds XAMLTest MCP server Adds a new XAMLTest.Mcp project to implement a Model Context Protocol server providing tools to manage XAMLTest applications, updates the C# language version to 14.0, includes new NuGet packages for ModelContextProtocol and Microsoft.Extensions.Hosting, introduces a `ProcessId` property to the `IApp` interface, and updates the Visual Studio version in the solution file. --- XAMLTest.Mcp/.mcp/server.json | 22 ++++++ XAMLTest.Mcp/AppServiceManager.cs | 57 ++++++++++++++ XAMLTest.Mcp/Program.cs | 20 +++++ XAMLTest.Mcp/README.md | 98 ++++++++++++++++++++++++ XAMLTest.Mcp/SharedStrings.cs | 13 ++++ XAMLTest.Mcp/Tools/AppTools.cs | 86 +++++++++++++++++++++ XAMLTest.Mcp/Tools/BaseTools.cs | 24 ++++++ XAMLTest.Mcp/Tools/VisualElementTools.cs | 34 ++++++++ XAMLTest.Mcp/XAMLTest.Mcp.csproj | 42 ++++++++++ XAMLTest.Tests/Simulators/App.cs | 2 + XAMLTest/IApp.cs | 5 ++ XAMLTest/Internal/App.cs | 19 ++++- XAMLTest/Internal/BitmapImage.cs | 6 +- XAMLTest/VTMixins.cs | 5 +- XAMLTest/XAMLTest.csproj | 2 +- 15 files changed, 424 insertions(+), 11 deletions(-) create mode 100644 XAMLTest.Mcp/.mcp/server.json create mode 100644 XAMLTest.Mcp/AppServiceManager.cs create mode 100644 XAMLTest.Mcp/Program.cs create mode 100644 XAMLTest.Mcp/README.md create mode 100644 XAMLTest.Mcp/SharedStrings.cs create mode 100644 XAMLTest.Mcp/Tools/AppTools.cs create mode 100644 XAMLTest.Mcp/Tools/BaseTools.cs create mode 100644 XAMLTest.Mcp/Tools/VisualElementTools.cs create mode 100644 XAMLTest.Mcp/XAMLTest.Mcp.csproj 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..2a8dde98 --- /dev/null +++ b/XAMLTest.Mcp/AppServiceManager.cs @@ -0,0 +1,57 @@ + +using XamlTest; + +namespace XAMLTest.Mcp; + +public 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..39c2293e --- /dev/null +++ b/XAMLTest.Mcp/SharedStrings.cs @@ -0,0 +1,13 @@ +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. + """; + +} diff --git a/XAMLTest.Mcp/Tools/AppTools.cs b/XAMLTest.Mcp/Tools/AppTools.cs new file mode 100644 index 00000000..df15ecd6 --- /dev/null +++ b/XAMLTest.Mcp/Tools/AppTools.cs @@ -0,0 +1,86 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json.Nodes; +using XamlTest; +using XAMLTest.Mcp; + +[McpServerToolType] +internal class AppTools(AppServiceManager appServiceManager) : BaseTools +{ + [McpServerTool] + [Description(""" + Generates a random number between the specified minimum and maximum values. + 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 an embedded resource. + """)] + public async Task SaveScreenshot( + [Description(SharedStrings.AppIdDescription)] string appId + ) + { + if (appServiceManager.TryGetApp(appId, out var app)) + { + IImage screenshot = await app.GetScreenshot(); + if (await app.GetMainWindow() is { } window) + { + //TOD: expose Activate window + + } + ; //TODO handle multiple windows + using MemoryStream memoryStream = new(); + await screenshot.Save(memoryStream); + + string filename = $"screenshot_{appId}_{DateTime.Now:yyyyMMdd_HHmmss}.jpg"; + byte[] imageData = memoryStream.ToArray(); + + EmbeddedResourceBlock resourceBlock = new() + { + Resource = new BlobResourceContents + { + Uri = $"file:///{filename}", + MimeType = System.Net.Mime.MediaTypeNames.Image.Jpeg, + Blob = Convert.ToBase64String(imageData) + } + }; + + return new() + { + IsError = false, + Content = [resourceBlock] + }; + } + return Failure($"No known app with id '{appId}' is running"); + } +} + 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..31085d08 --- /dev/null +++ b/XAMLTest.Mcp/Tools/VisualElementTools.cs @@ -0,0 +1,34 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using XamlTest; +using XAMLTest.Mcp; + +[McpServerToolType] +internal class VisualElementTools(AppServiceManager appServiceManager) + : BaseTools +{ + [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"); + } +} + diff --git a/XAMLTest.Mcp/XAMLTest.Mcp.csproj b/XAMLTest.Mcp/XAMLTest.Mcp.csproj new file mode 100644 index 00000000..4ed2a90e --- /dev/null +++ b/XAMLTest.Mcp/XAMLTest.Mcp.csproj @@ -0,0 +1,42 @@ + + + + net10.0-windows + win-x64;win-arm64 + Exe + + + true + 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.Tests/Simulators/App.cs b/XAMLTest.Tests/Simulators/App.cs index 8d8c096d..166fb3aa 100644 --- a/XAMLTest.Tests/Simulators/App.cs +++ b/XAMLTest.Tests/Simulators/App.cs @@ -19,6 +19,8 @@ public void Dispose() public IList DefaultXmlNamespaces => throw new NotImplementedException(); + public int ProcessId { get; } + public ValueTask DisposeAsync() => Completed; public Task GetMainWindow() diff --git a/XAMLTest/IApp.cs b/XAMLTest/IApp.cs index cd3a3f98..b587e779 100644 --- a/XAMLTest/IApp.cs +++ b/XAMLTest/IApp.cs @@ -5,6 +5,11 @@ /// public interface IApp : IAsyncDisposable, IDisposable { + /// + /// The ID of the process that the application is running in. + /// + int ProcessId { get; } + /// /// Initializes the application with the specified App.xaml content and referenced assemblies. /// diff --git a/XAMLTest/Internal/App.cs b/XAMLTest/Internal/App.cs index da295ffc..b780349d 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; @@ -20,6 +20,21 @@ internal sealed class App( public IList DefaultXmlNamespaces => Context.DefaultNamespaces; + public int ProcessId + { + get + { + try + { + return Process.Id; + } + catch (InvalidOperationException) + { + return -1; + } + } + } + public void Dispose() { ShutdownRequest request = new() @@ -75,7 +90,7 @@ public async ValueTask DisposeAsync() } catch (OperationCanceledException) { } - catch(RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) + catch (RpcException rpcException) when (rpcException.StatusCode == StatusCode.Unavailable) { } finally { 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/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/XAMLTest.csproj b/XAMLTest/XAMLTest.csproj index d8933537..e0fedaa9 100644 --- a/XAMLTest/XAMLTest.csproj +++ b/XAMLTest/XAMLTest.csproj @@ -43,7 +43,7 @@ - + From b16184064eb423f636fd26688638606a2a7e0962 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Fri, 19 Dec 2025 02:03:22 -0800 Subject: [PATCH 2/7] Adds new application startup tool and removes process ID - Introduces `StartApp` MCP server tool for flexible WPF application initialization. - Corrects `StartAppWithXamlSnippet` tool description for clarity. - Removes the `ProcessId` property from `IApp` and its implementations. - Removes unused `System.Text.Json.Nodes` using statement. --- XAMLTest.Mcp/Tools/AppTools.cs | 52 ++++++++++++++++++++++++++++---- XAMLTest.Tests/Simulators/App.cs | 2 -- XAMLTest/IApp.cs | 5 --- XAMLTest/Internal/App.cs | 15 --------- 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/XAMLTest.Mcp/Tools/AppTools.cs b/XAMLTest.Mcp/Tools/AppTools.cs index df15ecd6..c34efc65 100644 --- a/XAMLTest.Mcp/Tools/AppTools.cs +++ b/XAMLTest.Mcp/Tools/AppTools.cs @@ -1,7 +1,6 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; -using System.Text.Json.Nodes; using XamlTest; using XAMLTest.Mcp; @@ -10,7 +9,48 @@ internal class AppTools(AppServiceManager appServiceManager) : BaseTools { [McpServerTool] [Description(""" - Generates a random number between the specified minimum and maximum values. + 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( @@ -36,8 +76,8 @@ Shuts down the specified XAML Test application. public async Task ShutdownApp( [Description(SharedStrings.AppIdDescription)] string appId) { - return (await appServiceManager.ShutdownApp(appId)) - ? Success() + return (await appServiceManager.ShutdownApp(appId)) + ? Success() : Failure($"No known app with id '{appId}' is running"); } @@ -60,10 +100,10 @@ public async Task SaveScreenshot( ; //TODO handle multiple windows using MemoryStream memoryStream = new(); await screenshot.Save(memoryStream); - + string filename = $"screenshot_{appId}_{DateTime.Now:yyyyMMdd_HHmmss}.jpg"; byte[] imageData = memoryStream.ToArray(); - + EmbeddedResourceBlock resourceBlock = new() { Resource = new BlobResourceContents diff --git a/XAMLTest.Tests/Simulators/App.cs b/XAMLTest.Tests/Simulators/App.cs index 166fb3aa..8d8c096d 100644 --- a/XAMLTest.Tests/Simulators/App.cs +++ b/XAMLTest.Tests/Simulators/App.cs @@ -19,8 +19,6 @@ public void Dispose() public IList DefaultXmlNamespaces => throw new NotImplementedException(); - public int ProcessId { get; } - public ValueTask DisposeAsync() => Completed; public Task GetMainWindow() diff --git a/XAMLTest/IApp.cs b/XAMLTest/IApp.cs index b587e779..cd3a3f98 100644 --- a/XAMLTest/IApp.cs +++ b/XAMLTest/IApp.cs @@ -5,11 +5,6 @@ /// public interface IApp : IAsyncDisposable, IDisposable { - /// - /// The ID of the process that the application is running in. - /// - int ProcessId { get; } - /// /// Initializes the application with the specified App.xaml content and referenced assemblies. /// diff --git a/XAMLTest/Internal/App.cs b/XAMLTest/Internal/App.cs index b780349d..aaff058e 100644 --- a/XAMLTest/Internal/App.cs +++ b/XAMLTest/Internal/App.cs @@ -20,21 +20,6 @@ internal sealed class App( public IList DefaultXmlNamespaces => Context.DefaultNamespaces; - public int ProcessId - { - get - { - try - { - return Process.Id; - } - catch (InvalidOperationException) - { - return -1; - } - } - } - public void Dispose() { ShutdownRequest request = new() From 25c39cea096992579e4de85670f79b356cfedff5 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Wed, 25 Mar 2026 14:17:08 -0700 Subject: [PATCH 3/7] Updates target framework to .NET 10.0 and removes System.Drawing.Common - Drops support for .NET 9.0 across projects, targeting .NET 10.0 exclusively. - Removes the `System.Drawing.Common` NuGet package and its associated project references. --- XAMLTest.TestApp/XAMLTest.TestApp.csproj | 2 +- XAMLTest/XAMLTest.csproj | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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/XAMLTest.csproj b/XAMLTest/XAMLTest.csproj index e0fedaa9..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 - From bc74e3df42d6ea09297a5e886af1327a6a1be8d7 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Wed, 25 Mar 2026 17:20:42 -0700 Subject: [PATCH 4/7] Adds visual tree and element property tools to MCP server - Adds `GetVisualTree`, `GetElementProperty`, and `SetElementProperty` tools to the MCP server for WPF interaction. - Implements a gRPC endpoint and host service to traverse and serialize the WPF visual tree. - Introduces a query syntax for finding elements by name, type, or property values. - Updates `IWindow` and internal `VisualElement` logic to support remote tree inspection. - Fixes base64 encoding for screenshot blobs in `AppTools`. --- Directory.Packages.props | 2 + XAMLTest.Mcp/SharedStrings.cs | 13 ++- XAMLTest.Mcp/Tools/AppTools.cs | 2 +- XAMLTest.Mcp/Tools/VisualElementTools.cs | 94 +++++++++++++++++++ XAMLTest.slnx | 1 + XAMLTest/Host/VisualTreeService.VisualTree.cs | 73 ++++++++++++++ XAMLTest/Host/XamlTestSpec.proto | 18 ++++ XAMLTest/IWindow.cs | 6 +- XAMLTest/Internal/VisualElement.cs | 2 +- XAMLTest/Internal/Window.cs | 32 +++++++ XAMLTest/VisualTreeNodeInfo.cs | 47 ++++++++++ 11 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 XAMLTest/Host/VisualTreeService.VisualTree.cs create mode 100644 XAMLTest/VisualTreeNodeInfo.cs 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/SharedStrings.cs b/XAMLTest.Mcp/SharedStrings.cs index 39c2293e..fad03eac 100644 --- a/XAMLTest.Mcp/SharedStrings.cs +++ b/XAMLTest.Mcp/SharedStrings.cs @@ -9,5 +9,16 @@ public static class SharedStrings 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)."; } diff --git a/XAMLTest.Mcp/Tools/AppTools.cs b/XAMLTest.Mcp/Tools/AppTools.cs index c34efc65..bdf0ad00 100644 --- a/XAMLTest.Mcp/Tools/AppTools.cs +++ b/XAMLTest.Mcp/Tools/AppTools.cs @@ -110,7 +110,7 @@ public async Task SaveScreenshot( { Uri = $"file:///{filename}", MimeType = System.Net.Mime.MediaTypeNames.Image.Jpeg, - Blob = Convert.ToBase64String(imageData) + Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(imageData)) } }; diff --git a/XAMLTest.Mcp/Tools/VisualElementTools.cs b/XAMLTest.Mcp/Tools/VisualElementTools.cs index 31085d08..ad80f15d 100644 --- a/XAMLTest.Mcp/Tools/VisualElementTools.cs +++ b/XAMLTest.Mcp/Tools/VisualElementTools.cs @@ -8,6 +8,100 @@ 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(""" Updates the XAML content of a running WPF application with the provided XAML snippet. 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/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/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); + } + } +} From d09c6870320cf1e36130d9ada06c437e63301456 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Fri, 15 May 2026 00:35:13 -0700 Subject: [PATCH 5/7] Adds unified Interact MCP tool for mouse and keyboard input - Adds Interact tool to VisualElementTools that accepts an ordered JSON action list for executing mixed mouse/keyboard flows against a target element or main window. - Supported action 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. - Adds InputActionsJsonDescription to SharedStrings with schema docs and an example payload. - Validates all actions with per-index error messages for bad payloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- XAMLTest.Mcp/SharedStrings.cs | 23 ++ XAMLTest.Mcp/Tools/VisualElementTools.cs | 349 +++++++++++++++++++++++ 2 files changed, 372 insertions(+) diff --git a/XAMLTest.Mcp/SharedStrings.cs b/XAMLTest.Mcp/SharedStrings.cs index fad03eac..f49564e9 100644 --- a/XAMLTest.Mcp/SharedStrings.cs +++ b/XAMLTest.Mcp/SharedStrings.cs @@ -21,4 +21,27 @@ 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/VisualElementTools.cs b/XAMLTest.Mcp/Tools/VisualElementTools.cs index ad80f15d..426526fd 100644 --- a/XAMLTest.Mcp/Tools/VisualElementTools.cs +++ b/XAMLTest.Mcp/Tools/VisualElementTools.cs @@ -1,6 +1,8 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; +using System.Text.Json; +using System.Windows.Input; using XamlTest; using XAMLTest.Mcp; @@ -102,6 +104,83 @@ public async Task SetElementProperty( 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. @@ -124,5 +203,275 @@ public async Task UpdateAppXaml( 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]; + } } From fe81764ec168a17738acc43723dc292506a4f97f Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Thu, 21 May 2026 22:47:26 -0700 Subject: [PATCH 6/7] Enhances `SaveScreenshot` tool with inline BMP output and file saving - The `SaveScreenshot` MCP tool now returns the captured image as inline BMP content, improving direct usability for callers. - Adds an optional `filePath` parameter to allow saving the screenshot to a specified location on disk. - Marks `AppServiceManager` as `sealed` to prevent unintended inheritance. - Adopts C# 12 collection expressions for empty arrays. --- XAMLTest.Mcp/AppServiceManager.cs | 2 +- XAMLTest.Mcp/Tools/AppTools.cs | 56 ++++++++++++++----------------- XAMLTest/Internal/App.cs | 2 +- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/XAMLTest.Mcp/AppServiceManager.cs b/XAMLTest.Mcp/AppServiceManager.cs index 2a8dde98..db2b2021 100644 --- a/XAMLTest.Mcp/AppServiceManager.cs +++ b/XAMLTest.Mcp/AppServiceManager.cs @@ -3,7 +3,7 @@ namespace XAMLTest.Mcp; -public class AppServiceManager : IAsyncDisposable +public sealed class AppServiceManager : IAsyncDisposable { private Dictionary RunningApps { get; } = []; diff --git a/XAMLTest.Mcp/Tools/AppTools.cs b/XAMLTest.Mcp/Tools/AppTools.cs index bdf0ad00..210aef23 100644 --- a/XAMLTest.Mcp/Tools/AppTools.cs +++ b/XAMLTest.Mcp/Tools/AppTools.cs @@ -83,44 +83,38 @@ public async Task ShutdownApp( [McpServerTool] [Description(""" - Captures a screenshot of the specified XAML Test application and returns it as an embedded resource. + Captures a screenshot of the specified XAML Test application and returns it as inline BMP image content. """)] - public async Task SaveScreenshot( - [Description(SharedStrings.AppIdDescription)] string appId - ) + 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)) + if (!appServiceManager.TryGetApp(appId, out var app)) { - IImage screenshot = await app.GetScreenshot(); - if (await app.GetMainWindow() is { } window) - { - //TOD: expose Activate window - - } - ; //TODO handle multiple windows - using MemoryStream memoryStream = new(); - await screenshot.Save(memoryStream); + return Failure($"No known app with id '{appId}' is running"); + } - string filename = $"screenshot_{appId}_{DateTime.Now:yyyyMMdd_HHmmss}.jpg"; - byte[] imageData = memoryStream.ToArray(); + IImage screenshot = await app.GetScreenshot(); - EmbeddedResourceBlock resourceBlock = new() - { - Resource = new BlobResourceContents - { - Uri = $"file:///{filename}", - MimeType = System.Net.Mime.MediaTypeNames.Image.Jpeg, - Blob = System.Text.Encoding.UTF8.GetBytes(Convert.ToBase64String(imageData)) - } - }; + using MemoryStream bmpStream = new(); + await screenshot.Save(bmpStream); + bmpStream.Position = 0; + byte[] bmpBytes = bmpStream.ToArray(); - return new() - { - IsError = false, - Content = [resourceBlock] - }; + if (filePath is not null) + { + await File.WriteAllBytesAsync(filePath, bmpBytes); } - return Failure($"No known app with id '{appId}' is running"); + + return new CallToolResult + { + IsError = false, + Content = + [ + new TextContentBlock { Text = $"Screenshot for {appId}:" }, + ImageContentBlock.FromBytes(bmpBytes, "image/bmp") + ] + }; } } diff --git a/XAMLTest/Internal/App.cs b/XAMLTest/Internal/App.cs index aaff058e..647974a0 100644 --- a/XAMLTest/Internal/App.cs +++ b/XAMLTest/Internal/App.cs @@ -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() From 3e1c694a19d7f827cd32d8778521c8b7f52bfa39 Mon Sep 17 00:00:00 2001 From: Kevin Bost Date: Thu, 21 May 2026 23:06:30 -0700 Subject: [PATCH 7/7] Disable packing XAMLTest.Mcp as a global tool The XAMLTest.Mcp project is intended as a self-contained MCP server and not for distribution as a .NET Global Tool. --- XAMLTest.Mcp/XAMLTest.Mcp.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/XAMLTest.Mcp/XAMLTest.Mcp.csproj b/XAMLTest.Mcp/XAMLTest.Mcp.csproj index 4ed2a90e..00087a7f 100644 --- a/XAMLTest.Mcp/XAMLTest.Mcp.csproj +++ b/XAMLTest.Mcp/XAMLTest.Mcp.csproj @@ -6,7 +6,7 @@ Exe - true + McpServer @@ -27,6 +27,7 @@ +