From 6957bf8bd220a4a645582b05b17c96fe02eb8c5a Mon Sep 17 00:00:00 2001 From: Emanuel Fernandez Dell'Oca Date: Thu, 5 Mar 2026 17:57:33 -0500 Subject: [PATCH 1/5] Add MCP server transport for AI agent integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'maui-devflow mcp-serve' command that starts an MCP (Model Context Protocol) server over stdio. This enables VS Code Copilot Chat and other MCP-compatible AI hosts to interact with running MAUI apps through structured, typed tool responses — including inline screenshots. 27 MCP tools exposed: - maui_screenshot (returns image directly), maui_tree (structured JSON) - maui_logs, maui_network, maui_network_detail, maui_network_clear - maui_tap, maui_fill, maui_clear, maui_scroll - maui_set_property, maui_get_property - maui_navigate, maui_focus, maui_resize - maui_query, maui_query_css, maui_element, maui_hittest - maui_list_agents, maui_status, maui_wait, maui_select_agent - maui_cdp_evaluate, maui_cdp_screenshot, maui_cdp_source, maui_cdp_webviews The existing CLI is unchanged — MCP is purely additive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 53 +++++++++ src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj | 2 + src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs | 112 ++++++++++++++++++ src/MauiDevFlow.CLI/Mcp/McpServerHost.cs | 38 ++++++ src/MauiDevFlow.CLI/Mcp/Tools/AgentTools.cs | 102 ++++++++++++++++ src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs | 108 +++++++++++++++++ .../Mcp/Tools/InteractionTools.cs | 66 +++++++++++ src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs | 56 +++++++++ .../Mcp/Tools/NavigationTools.cs | 50 ++++++++ src/MauiDevFlow.CLI/Mcp/Tools/NetworkTool.cs | 72 +++++++++++ .../Mcp/Tools/PropertyTools.cs | 52 ++++++++ src/MauiDevFlow.CLI/Mcp/Tools/QueryTools.cs | 81 +++++++++++++ .../Mcp/Tools/ScreenshotTool.cs | 30 +++++ src/MauiDevFlow.CLI/Mcp/Tools/TreeTool.cs | 83 +++++++++++++ src/MauiDevFlow.CLI/Program.cs | 5 + 15 files changed, 910 insertions(+) create mode 100644 src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/McpServerHost.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/AgentTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/NavigationTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/NetworkTool.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/QueryTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/TreeTool.cs diff --git a/README.md b/README.md index 7bbaab5..6e28f63 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,59 @@ directory (or current directory if `--output` is not specified). Existing files The file list is discovered dynamically from the repository, so new reference docs are picked up automatically. +## MCP Server + +MauiDevFlow includes an MCP (Model Context Protocol) server for integration with AI coding agents in VS Code Copilot Chat, Claude Desktop, and other MCP-compatible hosts. The MCP server returns structured JSON and inline images — enabling AI agents to see screenshots directly and query the visual tree without text parsing. + +### Configuration + +Add to your VS Code MCP settings (`.vscode/mcp.json`): + +```json +{ + "servers": { + "maui-devflow": { + "command": "maui-devflow", + "args": ["mcp-serve"], + "transportType": "stdio" + } + } +} +``` + +### Available Tools + +| Tool | Description | +|------|-------------| +| `maui_screenshot` | Capture screenshot — **returns image directly** to the AI agent | +| `maui_tree` | Visual tree as structured JSON with element IDs, types, bounds, properties | +| `maui_logs` | Structured log entries with level filtering | +| `maui_network` | Captured HTTP requests with status, timing, sizes | +| `maui_network_detail` | Full request/response detail including headers and body | +| `maui_tap` | Tap a UI element by ID | +| `maui_fill` | Fill text into an Entry/Editor/SearchBar | +| `maui_clear` | Clear text from an input element | +| `maui_scroll` | Scroll a ScrollView element | +| `maui_navigate` | Navigate to a Shell route | +| `maui_focus` | Set focus to an element | +| `maui_resize` | Resize the app window | +| `maui_set_property` | Set a property value at runtime | +| `maui_get_property` | Get a property value | +| `maui_query` | Query elements by type, AutomationId, or text | +| `maui_query_css` | Query Blazor WebView elements via CSS selector | +| `maui_element` | Get detailed info for a single element | +| `maui_hittest` | Find element at screen coordinates | +| `maui_list_agents` | List connected MAUI apps | +| `maui_status` | Agent status (platform, version, app name) | +| `maui_wait` | Wait for an agent to connect | +| `maui_select_agent` | Set default agent for the session | +| `maui_cdp_evaluate` | Execute JavaScript in a Blazor WebView | +| `maui_cdp_screenshot` | Capture WebView screenshot via CDP | +| `maui_cdp_source` | Get WebView HTML source | +| `maui_cdp_webviews` | List registered Blazor WebViews | + +All tools accept an optional `agentPort` parameter. When omitted, the server auto-discovers the connected agent via the broker — same as the CLI. + ## License MIT diff --git a/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj b/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj index a5059bd..c65bfb9 100644 --- a/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj +++ b/src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs b/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs new file mode 100644 index 0000000..eaf076d --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs @@ -0,0 +1,112 @@ +using System.Net.Sockets; +using MauiDevFlow.CLI.Broker; +using MauiDevFlow.Driver; + +namespace MauiDevFlow.CLI.Mcp; + +public class McpAgentSession +{ + public int? DefaultAgentPort { get; set; } + public string DefaultAgentHost { get; set; } = "localhost"; + + public async Task GetAgentClientAsync(int? agentPort = null) + { + var port = agentPort ?? DefaultAgentPort ?? await ResolveAgentPortAsync(); + return new AgentClient(DefaultAgentHost, port); + } + + public async Task GetBrokerPortAsync() + { + var port = BrokerClient.ReadBrokerPortPublic() ?? BrokerServer.DefaultPort; + if (!IsTcpAlive(port, timeout: 300)) + { + var started = await BrokerClient.EnsureBrokerRunningAsync(); + if (started.HasValue) + port = started.Value; + } + return port; + } + + public async Task ListAgentsAsync() + { + var brokerPort = await GetBrokerPortAsync(); + return await BrokerClient.ListAgentsAsync(brokerPort); + } + + private async Task ResolveAgentPortAsync() + { + var brokerPort = BrokerClient.ReadBrokerPortPublic() ?? BrokerServer.DefaultPort; + var brokerAlive = IsTcpAlive(brokerPort, timeout: 300); + + if (!brokerAlive) + { + var started = await BrokerClient.EnsureBrokerRunningAsync(); + if (started.HasValue) + { + brokerPort = started.Value; + brokerAlive = true; + } + } + + if (brokerAlive) + { + // Try project-specific resolution + var csprojPath = FindCsprojInCurrentDirectory(); + if (csprojPath is not null) + { + var resolved = await BrokerClient.ResolveAgentPortAsync(brokerPort, csprojPath); + if (resolved.HasValue) + return resolved.Value; + } + + // Try auto-select (single agent) + var auto = await BrokerClient.ResolveAgentPortAsync(brokerPort); + if (auto.HasValue) + return auto.Value; + } + + // Fall back to config file or default + return ReadConfigPort() ?? 9223; + } + + private static bool IsTcpAlive(int port, int timeout) + { + try + { + using var tcp = new TcpClient(); + var result = tcp.BeginConnect("localhost", port, null, null); + var connected = result.AsyncWaitHandle.WaitOne(timeout); + if (connected && tcp.Connected) + { + tcp.EndConnect(result); + return true; + } + return false; + } + catch + { + return false; + } + } + + private static string? FindCsprojInCurrentDirectory() + { + var dir = Directory.GetCurrentDirectory(); + var files = Directory.GetFiles(dir, "*.csproj"); + return files.Length > 0 ? files[0] : null; + } + + private static int? ReadConfigPort() + { + var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow"); + if (!File.Exists(configPath)) return null; + try + { + var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(configPath)); + if (json.RootElement.TryGetProperty("port", out var portEl) && portEl.TryGetInt32(out var p)) + return p; + } + catch { } + return null; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs new file mode 100644 index 0000000..9806316 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol; +using MauiDevFlow.CLI.Mcp.Tools; + +namespace MauiDevFlow.CLI.Mcp; + +public static class McpServerHost +{ + public static async Task RunAsync() + { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; + + var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); + + builder.Services.AddSingleton(); + + builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new() { Name = "maui-devflow", Version = version }; + }) + .WithStdioServerTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + + await builder.Build().RunAsync(); + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/AgentTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/AgentTools.cs new file mode 100644 index 0000000..a704a1f --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/AgentTools.cs @@ -0,0 +1,102 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; +using MauiDevFlow.CLI.Broker; +using MauiDevFlow.Driver; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class AgentTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + [McpServerTool(Name = "maui_list_agents"), Description("List all connected MAUI DevFlow agents (running apps). Shows app name, platform, port, and uptime.")] + public static async Task ListAgents(McpAgentSession session) + { + var agents = await session.ListAgentsAsync(); + if (agents == null || agents.Length == 0) + return "No agents connected. Build and run a MAUI app with MauiDevFlow.Agent configured."; + + var result = agents.Select(a => new + { + a.Id, + a.AppName, + a.Platform, + a.Tfm, + a.Port, + a.Version, + uptime = (DateTime.UtcNow - a.ConnectedAt).ToString(@"hh\:mm\:ss") + }); + + return JsonSerializer.Serialize(result, JsonOptions); + } + + [McpServerTool(Name = "maui_status"), Description("Get detailed status of a connected MAUI DevFlow agent including platform, device type, app name, and version.")] + public static async Task Status( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, + [Description("Window index for multi-window apps")] int? window = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var status = await agent.GetStatusAsync(window); + if (status == null) + return "Agent not responding. Is the app running?"; + + return JsonSerializer.Serialize(status, JsonOptions); + } + + [McpServerTool(Name = "maui_wait"), Description("Wait for a MAUI DevFlow agent to connect. Blocks until an agent registers with the broker or timeout is reached.")] + public static async Task Wait( + McpAgentSession session, + [Description("Timeout in seconds (default: 30)")] int timeout = 30, + [Description("Wait for a specific app name")] string? app = null) + { + var brokerPort = await session.GetBrokerPortAsync(); + var deadline = DateTime.UtcNow.AddSeconds(timeout); + + while (DateTime.UtcNow < deadline) + { + var agents = await BrokerClient.ListAgentsAsync(brokerPort); + if (agents != null && agents.Length > 0) + { + var match = app != null + ? agents.FirstOrDefault(a => a.AppName?.Contains(app, StringComparison.OrdinalIgnoreCase) == true) + : agents.FirstOrDefault(); + + if (match != null) + { + session.DefaultAgentPort = match.Port; + return JsonSerializer.Serialize(new + { + match.Id, + match.AppName, + match.Platform, + match.Tfm, + match.Port, + match.Version + }, JsonOptions); + } + } + + await Task.Delay(500); + } + + return $"Timeout after {timeout}s — no agent connected" + (app != null ? $" matching '{app}'" : "") + "."; + } + + [McpServerTool(Name = "maui_select_agent"), Description("Set the default agent for this MCP session. Subsequent tool calls will use this agent automatically without needing agentPort.")] + public static string SelectAgent( + McpAgentSession session, + [Description("Agent HTTP port to use as default")] int agentPort) + { + session.DefaultAgentPort = agentPort; + return $"Default agent set to port {agentPort}. All subsequent commands will use this agent."; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs new file mode 100644 index 0000000..563c61b --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs @@ -0,0 +1,108 @@ +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class CdpTools +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; + + [McpServerTool(Name = "maui_cdp_evaluate"), Description("Execute JavaScript in a Blazor WebView via Chrome DevTools Protocol. Returns the evaluation result.")] + public static async Task CdpEvaluate( + McpAgentSession session, + [Description("JavaScript expression to evaluate")] string expression, + [Description("WebView ID or index to target (optional if only one WebView)")] string? webviewId = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var url = $"{agent.BaseUrl}/api/cdp"; + if (webviewId != null) + url += $"?webview={Uri.EscapeDataString(webviewId)}"; + + var body = JsonSerializer.Serialize(new + { + method = "Runtime.evaluate", + @params = new { expression, returnByValue = true } + }); + + var response = await _http.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")); + var content = await response.Content.ReadAsStringAsync(); + + try + { + var json = JsonSerializer.Deserialize(content); + if (json.TryGetProperty("result", out var result) && + result.TryGetProperty("result", out var inner) && + inner.TryGetProperty("value", out var value)) + { + return value.ToString(); + } + return content; + } + catch + { + return content; + } + } + + [McpServerTool(Name = "maui_cdp_screenshot"), Description("Capture a screenshot of a Blazor WebView via Chrome DevTools Protocol. Returns the image directly.")] + public static async Task CdpScreenshot( + McpAgentSession session, + [Description("WebView ID or index to target (optional if only one WebView)")] string? webviewId = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var url = $"{agent.BaseUrl}/api/cdp"; + if (webviewId != null) + url += $"?webview={Uri.EscapeDataString(webviewId)}"; + + var body = JsonSerializer.Serialize(new + { + method = "Page.captureScreenshot", + @params = new { format = "png" } + }); + + var response = await _http.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")); + var content = await response.Content.ReadAsStringAsync(); + + var json = JsonSerializer.Deserialize(content); + if (json.TryGetProperty("result", out var result) && + result.TryGetProperty("data", out var data)) + { + var pngBytes = Convert.FromBase64String(data.GetString()!); + return [ + new TextContentBlock { Text = $"WebView screenshot captured ({pngBytes.Length} bytes)" }, + ImageContentBlock.FromBytes(pngBytes, "image/png") + ]; + } + + throw new McpException("Failed to capture WebView screenshot. Is a Blazor WebView active?"); + } + + [McpServerTool(Name = "maui_cdp_source"), Description("Get the HTML source of a Blazor WebView.")] + public static async Task CdpSource( + McpAgentSession session, + [Description("WebView ID or index to target (optional if only one WebView)")] string? webviewId = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var source = await agent.GetCdpSourceAsync(webviewId); + return string.IsNullOrEmpty(source) ? "No WebView source available." : source; + } + + [McpServerTool(Name = "maui_cdp_webviews"), Description("List all registered Blazor WebViews in the running app.")] + public static async Task CdpWebViews( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var webviews = await agent.GetCdpWebViewsAsync(); + return webviews.ToString(); + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs new file mode 100644 index 0000000..29c721b --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class InteractionTools +{ + [McpServerTool(Name = "maui_tap"), Description("Tap a UI element by its visual tree ID. Use maui_tree to discover element IDs.")] + public static async Task Tap( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.TapAsync(elementId); + return success + ? $"Tapped element '{elementId}' successfully." + : $"Failed to tap element '{elementId}'. Element may not exist or is not tappable."; + } + + [McpServerTool(Name = "maui_fill"), Description("Fill text into an Entry, Editor, or SearchBar element. Replaces existing text.")] + public static async Task Fill( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Text to fill into the element")] string text, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.FillAsync(elementId, text); + return success + ? $"Filled element '{elementId}' with text." + : $"Failed to fill element '{elementId}'. Element may not exist or is not a text input."; + } + + [McpServerTool(Name = "maui_clear"), Description("Clear text from an Entry, Editor, or SearchBar element.")] + public static async Task Clear( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.ClearAsync(elementId); + return success + ? $"Cleared element '{elementId}' successfully." + : $"Failed to clear element '{elementId}'."; + } + + [McpServerTool(Name = "maui_scroll"), Description("Scroll a ScrollView element by delta X and Y values.")] + public static async Task Scroll( + McpAgentSession session, + [Description("Element ID of the ScrollView")] string elementId, + [Description("Horizontal scroll delta in pixels")] double? x = null, + [Description("Vertical scroll delta in pixels")] double? y = null, + [Description("Whether to animate the scroll (default: true)")] bool? animated = null, + [Description("Window index for multi-window apps")] int? window = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.ScrollAsync(elementId, x ?? 0, y ?? 0, animated ?? true, window); + return success + ? $"Scrolled element '{elementId}' successfully." + : $"Failed to scroll element '{elementId}'. Element may not be a ScrollView."; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs new file mode 100644 index 0000000..487659c --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs @@ -0,0 +1,56 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class LogsTool +{ + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) }; + + [McpServerTool(Name = "maui_logs"), Description("Retrieve app logs (ILogger output and WebView console logs). Returns structured JSON log entries with timestamp, level, category, and message.")] + public static async Task Logs( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, + [Description("Maximum number of log entries to return (default: 50)")] int limit = 50, + [Description("Minimum log level: trace, debug, info, warning, error, critical")] string? minLevel = null, + [Description("Log source filter: native, webview, or all (default: all)")] string? source = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var url = $"{agent.BaseUrl}/api/logs?limit={limit}"; + if (!string.IsNullOrEmpty(source) && source != "all") + url += $"&source={source}"; + + var response = await _http.GetStringAsync(url); + + if (string.IsNullOrEmpty(minLevel)) + return response; + + var levelOrder = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["trace"] = 0, ["debug"] = 1, ["info"] = 2, ["information"] = 2, + ["warning"] = 3, ["warn"] = 3, ["error"] = 4, ["critical"] = 5, ["fatal"] = 5 + }; + + if (!levelOrder.TryGetValue(minLevel, out var minOrd)) + return response; + + var entries = JsonSerializer.Deserialize(response); + if (entries.ValueKind != JsonValueKind.Array) + return response; + + var filtered = entries.EnumerateArray() + .Where(e => + { + var level = e.TryGetProperty("l", out var l) ? l.GetString() : + e.TryGetProperty("level", out var lv) ? lv.GetString() : null; + if (level == null) return true; + return levelOrder.TryGetValue(level, out var ord) && ord >= minOrd; + }) + .ToList(); + + return JsonSerializer.Serialize(filtered); + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/NavigationTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/NavigationTools.cs new file mode 100644 index 0000000..9003728 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/NavigationTools.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class NavigationTools +{ + [McpServerTool(Name = "maui_navigate"), Description("Navigate to a Shell route in the MAUI app (e.g., '//home', '//settings', '//blazor').")] + public static async Task Navigate( + McpAgentSession session, + [Description("Shell route to navigate to (e.g., '//home', '//blazor')")] string route, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.NavigateAsync(route); + return success + ? $"Navigated to '{route}'." + : $"Failed to navigate to '{route}'. Route may not exist in the Shell."; + } + + [McpServerTool(Name = "maui_focus"), Description("Set focus to a UI element.")] + public static async Task Focus( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.FocusAsync(elementId); + return success + ? $"Focused element '{elementId}'." + : $"Failed to focus element '{elementId}'."; + } + + [McpServerTool(Name = "maui_resize"), Description("Resize the app window.")] + public static async Task Resize( + McpAgentSession session, + [Description("New window width in pixels")] int width, + [Description("New window height in pixels")] int height, + [Description("Window index for multi-window apps")] int? window = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.ResizeAsync(width, height, window); + return success + ? $"Resized window to {width}x{height}." + : "Failed to resize window."; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/NetworkTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/NetworkTool.cs new file mode 100644 index 0000000..05098b6 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/NetworkTool.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; +using MauiDevFlow.Driver; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class NetworkTool +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + [McpServerTool(Name = "maui_network"), Description("List captured HTTP network requests from the running app. Returns structured data with method, URL, status code, duration, and sizes.")] + public static async Task NetworkList( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, + [Description("Maximum number of requests to return (default: 50)")] int limit = 50, + [Description("Filter by host name")] string? host = null, + [Description("Filter by HTTP method (GET, POST, etc.)")] string? method = null, + [Description("Filter by status: '4xx', '5xx', '200', etc.")] string? statusFilter = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var requests = await agent.GetNetworkRequestsAsync(limit, host, method); + if (requests == null || requests.Count == 0) + return "No network requests captured. Ensure DevFlowHttpHandler is configured in the app."; + + if (!string.IsNullOrEmpty(statusFilter)) + { + requests = statusFilter.ToLowerInvariant() switch + { + "4xx" => requests.Where(r => r.StatusCode >= 400 && r.StatusCode < 500).ToList(), + "5xx" => requests.Where(r => r.StatusCode >= 500 && r.StatusCode < 600).ToList(), + "2xx" => requests.Where(r => r.StatusCode >= 200 && r.StatusCode < 300).ToList(), + "3xx" => requests.Where(r => r.StatusCode >= 300 && r.StatusCode < 400).ToList(), + _ when int.TryParse(statusFilter, out var code) => requests.Where(r => r.StatusCode == code).ToList(), + _ => requests + }; + } + + return JsonSerializer.Serialize(requests, JsonOptions); + } + + [McpServerTool(Name = "maui_network_detail"), Description("Get full details of a captured HTTP request including headers and body.")] + public static async Task NetworkDetail( + McpAgentSession session, + [Description("The request ID from maui_network results")] string requestId, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var detail = await agent.GetNetworkRequestDetailAsync(requestId); + if (detail == null) + return $"Network request '{requestId}' not found."; + + return JsonSerializer.Serialize(detail, JsonOptions); + } + + [McpServerTool(Name = "maui_network_clear"), Description("Clear all captured network requests from the buffer.")] + public static async Task NetworkClear( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.ClearNetworkRequestsAsync(); + return success ? "Network request buffer cleared." : "Failed to clear network requests."; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs new file mode 100644 index 0000000..8660492 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs @@ -0,0 +1,52 @@ +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class PropertyTools +{ + private static readonly HttpClient s_http = new() { Timeout = TimeSpan.FromSeconds(10) }; + + [McpServerTool(Name = "maui_get_property"), Description("Get the value of a property on a UI element (e.g., Text, IsVisible, BackgroundColor, SelectedIndex).")] + public static async Task GetProperty( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Property name (e.g., 'Text', 'IsVisible', 'BackgroundColor')")] string property, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var value = await agent.GetPropertyAsync(elementId, property); + return value ?? $"Property '{property}' not found on element '{elementId}'."; + } + + [McpServerTool(Name = "maui_set_property"), Description("Set a property value on a UI element at runtime (e.g., Text, IsVisible, BackgroundColor, SelectedIndex).")] + public static async Task SetProperty( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Property name (e.g., 'Text', 'IsVisible', 'BackgroundColor')")] string property, + [Description("New value for the property")] string value, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var json = JsonSerializer.Serialize(new { elementId, property, value }); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + try + { + var response = await s_http.PostAsync($"{agent.BaseUrl}/api/action/set-property", content); + var body = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(body); + if (result.TryGetProperty("success", out var s) && s.GetBoolean()) + return $"Set '{property}' = '{value}' on element '{elementId}'."; + var error = result.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error"; + return $"Failed to set property: {error}"; + } + catch (Exception ex) + { + return $"Failed to set property: {ex.Message}"; + } + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/QueryTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/QueryTools.cs new file mode 100644 index 0000000..1c63762 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/QueryTools.cs @@ -0,0 +1,81 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; +using MauiDevFlow.Driver; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class QueryTools +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + [McpServerTool(Name = "maui_query"), Description("Query visual tree elements by type, AutomationId, or text content. Returns matching elements with their IDs and properties.")] + public static async Task Query( + McpAgentSession session, + [Description("Element type filter (e.g., 'Button', 'Label', 'Entry')")] string? type = null, + [Description("AutomationId to search for")] string? automationId = null, + [Description("Text content to search for")] string? text = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + if (type == null && automationId == null && text == null) + return "At least one filter must be specified: type, automationId, or text."; + + var agent = await session.GetAgentClientAsync(agentPort); + var results = await agent.QueryAsync(type, automationId, text); + if (results == null || results.Count == 0) + return "No matching elements found."; + + return JsonSerializer.Serialize(results, JsonOptions); + } + + [McpServerTool(Name = "maui_query_css"), Description("Query Blazor WebView elements using CSS selectors. Returns matching elements.")] + public static async Task QueryCss( + McpAgentSession session, + [Description("CSS selector (e.g., '.my-class', '#myId', 'button.primary')")] string selector, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var results = await agent.QueryCssAsync(selector); + if (results == null || results.Count == 0) + return $"No elements matching selector '{selector}'."; + + return JsonSerializer.Serialize(results, JsonOptions); + } + + [McpServerTool(Name = "maui_element"), Description("Get detailed info about a single element by its visual tree ID.")] + public static async Task Element( + McpAgentSession session, + [Description("Element ID from the visual tree")] string elementId, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var element = await agent.GetElementAsync(elementId); + if (element == null) + return $"Element '{elementId}' not found."; + + return JsonSerializer.Serialize(element, JsonOptions); + } + + [McpServerTool(Name = "maui_hittest"), Description("Find which element is at specific screen coordinates (hit test).")] + public static async Task HitTest( + McpAgentSession session, + [Description("X coordinate in pixels")] double x, + [Description("Y coordinate in pixels")] double y, + [Description("Window index for multi-window apps")] int? window = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var elementId = await agent.HitTestAsync(x, y, window); + if (string.IsNullOrEmpty(elementId)) + return $"No element found at ({x}, {y})."; + + return $"Element at ({x}, {y}): {elementId}"; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs new file mode 100644 index 0000000..36f12a5 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class ScreenshotTool +{ + [McpServerTool(Name = "maui_screenshot"), Description("Capture a screenshot of the running MAUI app. Returns the image directly for visual verification of layout, colors, contrast, and rendering.")] + public static async Task Screenshot( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, + [Description("Window index for multi-window apps (default: 0)")] int? window = null, + [Description("Element ID to capture a specific element")] string? elementId = null, + [Description("CSS selector to capture (first match, Blazor WebViews only)")] string? selector = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var bytes = await agent.ScreenshotAsync(window, elementId, selector); + if (bytes == null || bytes.Length == 0) + throw new McpException("Screenshot failed — no image data returned. Is the agent connected and the app visible?"); + + return [ + new TextContentBlock { Text = $"Screenshot captured ({bytes.Length} bytes, PNG)" }, + ImageContentBlock.FromBytes(bytes, "image/png") + ]; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/TreeTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/TreeTool.cs new file mode 100644 index 0000000..fa82370 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/TreeTool.cs @@ -0,0 +1,83 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; +using MauiDevFlow.Driver; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class TreeTool +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + [McpServerTool(Name = "maui_tree"), Description("Inspect the visual tree of the running MAUI app. Returns structured JSON element hierarchy with IDs, types, bounds, visibility, and properties. Use element IDs from this tree for tap, fill, scroll, and other interaction commands.")] + public static async Task Tree( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, + [Description("Window index for multi-window apps (default: 0)")] int? window = null, + [Description("Max tree depth to return (default: 50)")] int depth = 50, + [Description("Filter to a specific element type, e.g. 'Label', 'Button', 'Entry'")] string? filter = null, + [Description("Return only the subtree rooted at this element ID")] string? elementId = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var tree = await agent.GetTreeAsync(depth, window); + if (tree == null || tree.Count == 0) + return "No visual tree available. Is the agent connected and the app running?"; + + IEnumerable result = tree; + + if (elementId != null) + { + var subtree = FindElement(tree, elementId); + if (subtree == null) + return $"Element '{elementId}' not found in the visual tree."; + result = [subtree]; + } + + if (filter != null) + { + result = FilterByType(result.ToList(), filter); + if (!result.Any()) + return $"No elements of type '{filter}' found in the visual tree."; + } + + return JsonSerializer.Serialize(result, JsonOptions); + } + + private static ElementInfo? FindElement(IEnumerable elements, string id) + { + foreach (var el in elements) + { + if (el.Id == id) return el; + if (el.Children != null) + { + var found = FindElement(el.Children, id); + if (found != null) return found; + } + } + return null; + } + + private static List FilterByType(List elements, string type) + { + var result = new List(); + foreach (var el in elements) + { + if (el.Type.Equals(type, StringComparison.OrdinalIgnoreCase)) + result.Add(el); + else if (el.Children != null) + { + var filtered = FilterByType(el.Children, type); + if (filtered.Count > 0) + result.AddRange(filtered); + } + } + return result; + } +} diff --git a/src/MauiDevFlow.CLI/Program.cs b/src/MauiDevFlow.CLI/Program.cs index dc70b89..090ba66 100644 --- a/src/MauiDevFlow.CLI/Program.cs +++ b/src/MauiDevFlow.CLI/Program.cs @@ -895,6 +895,11 @@ await BatchAsync(host, port, delay, continueOnError, human), }, jsonOption, noJsonOption); rootCommand.Add(commandsCmd); + // ===== MCP server command ===== + var mcpServeCmd = new Command("mcp-serve", "Start MCP (Model Context Protocol) server for AI agent integration via stdio"); + mcpServeCmd.SetHandler(async () => await Mcp.McpServerHost.RunAsync()); + rootCommand.Add(mcpServeCmd); + _parser = new CommandLineBuilder(rootCommand) .UseDefaults() .Build(); From 8119b5b7f08b285c933b8f88b000686da271e918 Mon Sep 17 00:00:00 2001 From: Emanuel Fernandez Dell'Oca Date: Tue, 10 Mar 2026 11:58:30 -0400 Subject: [PATCH 2/5] Add missing MCP tools and update existing ones for main parity - Add maui_assert tool for element property assertions - Add maui_recording_start/stop/status tools for screen recording - Update maui_scroll with itemIndex, groupIndex, scrollToPosition params - Update maui_screenshot with maxWidth and scale params - Update maui_logs with skip param for pagination - Register new tools in McpServerHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiDevFlow.CLI/Mcp/McpServerHost.cs | 4 +- src/MauiDevFlow.CLI/Mcp/Tools/AssertTool.cs | 40 +++++++++++ .../Mcp/Tools/InteractionTools.cs | 11 +-- src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs | 3 +- .../Mcp/Tools/RecordingTools.cs | 70 +++++++++++++++++++ .../Mcp/Tools/ScreenshotTool.cs | 6 +- 6 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/AssertTool.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/RecordingTools.cs diff --git a/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs index 9806316..48eaf69 100644 --- a/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs +++ b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs @@ -31,7 +31,9 @@ public static async Task RunAsync() .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools() + .WithTools(); await builder.Build().RunAsync(); } diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/AssertTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/AssertTool.cs new file mode 100644 index 0000000..576e2f5 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/AssertTool.cs @@ -0,0 +1,40 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class AssertTool +{ + [McpServerTool(Name = "maui_assert"), Description("Assert that a UI element's property equals an expected value. Returns PASS/FAIL with actual vs expected. Use maui_tree to discover element IDs and property names.")] + public static async Task Assert( + McpAgentSession session, + [Description("Property name to check (e.g. Text, IsVisible, IsEnabled)")] string propertyName, + [Description("Expected property value")] string expectedValue, + [Description("Element ID from the visual tree (use either this or automationId)")] string? elementId = null, + [Description("AutomationId to resolve the element")] string? automationId = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + + var resolvedId = elementId; + if (resolvedId is null && automationId is not null) + { + var results = await agent.QueryAsync(automationId: automationId); + if (results.Count == 0) + return $"FAIL: No element found with AutomationId '{automationId}'"; + resolvedId = results[0].Id; + } + + if (resolvedId is null) + return "FAIL: Either elementId or automationId must be provided"; + + var actualValue = await agent.GetPropertyAsync(resolvedId, propertyName); + var passed = string.Equals(actualValue, expectedValue, StringComparison.Ordinal); + + return passed + ? $"PASS: {propertyName} == \"{expectedValue}\"" + : $"FAIL: {propertyName} expected \"{expectedValue}\" but got \"{actualValue ?? "(null)"}\""; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs index 29c721b..0296eba 100644 --- a/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs +++ b/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs @@ -47,20 +47,23 @@ public static async Task Clear( : $"Failed to clear element '{elementId}'."; } - [McpServerTool(Name = "maui_scroll"), Description("Scroll a ScrollView element by delta X and Y values.")] + [McpServerTool(Name = "maui_scroll"), Description("Scroll a ScrollView, CollectionView, or ListView. Supports delta-based scrolling, scrolling to an item index, or scrolling an element into view.")] public static async Task Scroll( McpAgentSession session, - [Description("Element ID of the ScrollView")] string elementId, + [Description("Element ID of the scroll container, or element to scroll into view")] string? elementId = null, [Description("Horizontal scroll delta in pixels")] double? x = null, [Description("Vertical scroll delta in pixels")] double? y = null, [Description("Whether to animate the scroll (default: true)")] bool? animated = null, [Description("Window index for multi-window apps")] int? window = null, + [Description("Item index to scroll to (for CollectionView/ListView)")] int? itemIndex = null, + [Description("Group index for grouped CollectionView")] int? groupIndex = null, + [Description("Scroll position: MakeVisible (default), Start, Center, End")] string? scrollToPosition = null, [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) { var agent = await session.GetAgentClientAsync(agentPort); - var success = await agent.ScrollAsync(elementId, x ?? 0, y ?? 0, animated ?? true, window); + var success = await agent.ScrollAsync(elementId, x ?? 0, y ?? 0, animated ?? true, window, itemIndex, groupIndex, scrollToPosition); return success - ? $"Scrolled element '{elementId}' successfully." + ? elementId is not null ? $"Scrolled element '{elementId}' successfully." : "Scrolled successfully." : $"Failed to scroll element '{elementId}'. Element may not be a ScrollView."; } } diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs index 487659c..7d48f73 100644 --- a/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs +++ b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs @@ -15,11 +15,12 @@ public static async Task Logs( McpAgentSession session, [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, [Description("Maximum number of log entries to return (default: 50)")] int limit = 50, + [Description("Number of newest entries to skip (for pagination)")] int skip = 0, [Description("Minimum log level: trace, debug, info, warning, error, critical")] string? minLevel = null, [Description("Log source filter: native, webview, or all (default: all)")] string? source = null) { var agent = await session.GetAgentClientAsync(agentPort); - var url = $"{agent.BaseUrl}/api/logs?limit={limit}"; + var url = $"{agent.BaseUrl}/api/logs?limit={limit}&skip={skip}"; if (!string.IsNullOrEmpty(source) && source != "all") url += $"&source={source}"; diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/RecordingTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/RecordingTools.cs new file mode 100644 index 0000000..5194593 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/RecordingTools.cs @@ -0,0 +1,70 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class RecordingTools +{ + [McpServerTool(Name = "maui_recording_start"), Description("Start screen recording of the running app. Uses platform-specific recording (xcrun for iOS/Mac Catalyst, scrcpy for Android).")] + public static async Task RecordingStart( + McpAgentSession session, + [Description("Output file path (default: recording_.mp4)")] string? output = null, + [Description("Max recording duration in seconds (default: 30)")] int timeout = 30, + [Description("Agent HTTP port (optional, used to detect platform)")] int? agentPort = null) + { + try + { + var agent = await session.GetAgentClientAsync(agentPort); + var status = await agent.GetStatusAsync(); + var platform = status?.Platform ?? "maccatalyst"; + + var filename = output ?? $"recording_{DateTime.Now:yyyyMMdd_HHmmss}.mp4"; + using var driver = MauiDevFlow.Driver.AppDriverFactory.Create(platform); + await driver.StartRecordingAsync(filename, timeout); + + return $"Recording started (timeout: {timeout}s). Output: {Path.GetFullPath(filename)}"; + } + catch (Exception ex) + { + return $"Error starting recording: {ex.Message}"; + } + } + + [McpServerTool(Name = "maui_recording_stop"), Description("Stop the active screen recording and save the video file.")] + public static async Task RecordingStop( + McpAgentSession session, + [Description("Agent HTTP port (optional, used to detect platform)")] int? agentPort = null) + { + try + { + var agent = await session.GetAgentClientAsync(agentPort); + var status = await agent.GetStatusAsync(); + var platform = status?.Platform ?? "maccatalyst"; + + using var driver = MauiDevFlow.Driver.AppDriverFactory.Create(platform); + var outputFile = await driver.StopRecordingAsync(); + var size = File.Exists(outputFile) ? new FileInfo(outputFile).Length : 0; + + return $"Recording saved: {outputFile} ({size} bytes)"; + } + catch (Exception ex) + { + return $"Error stopping recording: {ex.Message}"; + } + } + + [McpServerTool(Name = "maui_recording_status"), Description("Check if a screen recording is currently in progress.")] + public static Task RecordingStatus() + { + var state = MauiDevFlow.Driver.RecordingStateManager.Load(); + if (state == null || !MauiDevFlow.Driver.RecordingStateManager.IsRecording()) + return Task.FromResult("No active recording."); + + var elapsed = DateTimeOffset.UtcNow - state.StartedAt; + return Task.FromResult( + $"Recording in progress: platform={state.Platform}, output={state.OutputFile}, " + + $"elapsed={elapsed.TotalSeconds:F0}s/{state.TimeoutSeconds}s, pid={state.RecordingPid}"); + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs index 36f12a5..c956fd8 100644 --- a/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs +++ b/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs @@ -15,10 +15,12 @@ public static async Task Screenshot( [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null, [Description("Window index for multi-window apps (default: 0)")] int? window = null, [Description("Element ID to capture a specific element")] string? elementId = null, - [Description("CSS selector to capture (first match, Blazor WebViews only)")] string? selector = null) + [Description("CSS selector to capture (first match, Blazor WebViews only)")] string? selector = null, + [Description("Resize screenshot to this max width in pixels (overrides auto-scaling)")] int? maxWidth = null, + [Description("Scale mode: 'native' keeps full HiDPI resolution, default auto-scales to 1x logical pixels")] string? scale = null) { var agent = await session.GetAgentClientAsync(agentPort); - var bytes = await agent.ScreenshotAsync(window, elementId, selector); + var bytes = await agent.ScreenshotAsync(window, elementId, selector, maxWidth, scale); if (bytes == null || bytes.Length == 0) throw new McpException("Screenshot failed — no image data returned. Is the agent connected and the app visible?"); From c2978475b8cad6c46dad12acdd56dc139b2750b4 Mon Sep 17 00:00:00 2001 From: Emanuel Fernandez Dell'Oca Date: Tue, 10 Mar 2026 12:05:24 -0400 Subject: [PATCH 3/5] Deduplicate MCP/CLI via AgentClient, extract shared port resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SetPropertyAsync, GetLogsAsync, SendCdpCommandAsync to AgentClient - Refactor MCP PropertyTools, LogsTool, CdpTools to use AgentClient instead of direct HTTP calls (removed static HttpClient instances) - Extract shared port resolution to BrokerClient.ResolveAgentPortForProjectAsync() - Simplify McpAgentSession (87 → 33 lines) by delegating to BrokerClient - Simplify Program.ResolveAgentPort() by delegating to shared helper - Move ReadConfigPort() to BrokerClient, remove duplicate in Program.cs - Update CLI handlers (set-property, logs, CDP, webviews, source) to use AgentClient Net: -130 lines, 0 direct HttpClient usage in MCP tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiDevFlow.CLI/Broker/BrokerClient.cs | 50 +++++ src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs | 87 +-------- src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs | 41 +--- src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs | 8 +- .../Mcp/Tools/PropertyTools.cs | 24 +-- src/MauiDevFlow.CLI/Program.cs | 182 ++++-------------- src/MauiDevFlow.Driver/AgentClient.cs | 46 +++++ 7 files changed, 154 insertions(+), 284 deletions(-) diff --git a/src/MauiDevFlow.CLI/Broker/BrokerClient.cs b/src/MauiDevFlow.CLI/Broker/BrokerClient.cs index 4364ffa..9a070ad 100644 --- a/src/MauiDevFlow.CLI/Broker/BrokerClient.cs +++ b/src/MauiDevFlow.CLI/Broker/BrokerClient.cs @@ -139,6 +139,56 @@ private static async Task IsBrokerAliveAsync(int port) /// public static int? ReadBrokerPortPublic() => ReadBrokerPort(); + /// + /// High-level port resolution: ensure broker running → resolve by project → auto-select → config fallback → default. + /// Returns the resolved agent port. + /// + public static async Task ResolveAgentPortForProjectAsync() + { + var brokerPort = ReadBrokerPort() ?? BrokerServer.DefaultPort; + + if (!await IsBrokerAliveAsync(brokerPort)) + { + var started = await EnsureBrokerRunningAsync(); + if (started.HasValue) + brokerPort = started.Value; + else + return ReadConfigPort() ?? 9223; + } + + // Try project-specific resolution + var csproj = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj").FirstOrDefault(); + if (csproj is not null) + { + var port = await ResolveAgentPortAsync(brokerPort, Path.GetFullPath(csproj)); + if (port.HasValue) return port.Value; + } + + // Try auto-select (single agent) + var autoPort = await ResolveAgentPortAsync(brokerPort); + if (autoPort.HasValue) return autoPort.Value; + + // No single match — return null so callers can handle multi-agent case + return null; + } + + /// + /// Read port from .mauidevflow config file in the current directory. + /// + public static int? ReadConfigPort() + { + var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow"); + if (!File.Exists(configPath)) return null; + try + { + var json = JsonSerializer.Deserialize(File.ReadAllText(configPath)); + if (json.TryGetProperty("port", out var portEl) && portEl.TryGetInt32(out var p)) + return p; + } + catch { } + return null; + } + private static void CleanupStaleBroker() { try diff --git a/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs b/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs index eaf076d..c7e8f99 100644 --- a/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs +++ b/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs @@ -1,4 +1,3 @@ -using System.Net.Sockets; using MauiDevFlow.CLI.Broker; using MauiDevFlow.Driver; @@ -17,14 +16,8 @@ public async Task GetAgentClientAsync(int? agentPort = null) public async Task GetBrokerPortAsync() { - var port = BrokerClient.ReadBrokerPortPublic() ?? BrokerServer.DefaultPort; - if (!IsTcpAlive(port, timeout: 300)) - { - var started = await BrokerClient.EnsureBrokerRunningAsync(); - if (started.HasValue) - port = started.Value; - } - return port; + var port = await BrokerClient.EnsureBrokerRunningAsync(); + return port ?? BrokerServer.DefaultPort; } public async Task ListAgentsAsync() @@ -35,78 +28,8 @@ public async Task GetBrokerPortAsync() private async Task ResolveAgentPortAsync() { - var brokerPort = BrokerClient.ReadBrokerPortPublic() ?? BrokerServer.DefaultPort; - var brokerAlive = IsTcpAlive(brokerPort, timeout: 300); - - if (!brokerAlive) - { - var started = await BrokerClient.EnsureBrokerRunningAsync(); - if (started.HasValue) - { - brokerPort = started.Value; - brokerAlive = true; - } - } - - if (brokerAlive) - { - // Try project-specific resolution - var csprojPath = FindCsprojInCurrentDirectory(); - if (csprojPath is not null) - { - var resolved = await BrokerClient.ResolveAgentPortAsync(brokerPort, csprojPath); - if (resolved.HasValue) - return resolved.Value; - } - - // Try auto-select (single agent) - var auto = await BrokerClient.ResolveAgentPortAsync(brokerPort); - if (auto.HasValue) - return auto.Value; - } - - // Fall back to config file or default - return ReadConfigPort() ?? 9223; - } - - private static bool IsTcpAlive(int port, int timeout) - { - try - { - using var tcp = new TcpClient(); - var result = tcp.BeginConnect("localhost", port, null, null); - var connected = result.AsyncWaitHandle.WaitOne(timeout); - if (connected && tcp.Connected) - { - tcp.EndConnect(result); - return true; - } - return false; - } - catch - { - return false; - } - } - - private static string? FindCsprojInCurrentDirectory() - { - var dir = Directory.GetCurrentDirectory(); - var files = Directory.GetFiles(dir, "*.csproj"); - return files.Length > 0 ? files[0] : null; - } - - private static int? ReadConfigPort() - { - var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow"); - if (!File.Exists(configPath)) return null; - try - { - var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(configPath)); - if (json.RootElement.TryGetProperty("port", out var portEl) && portEl.TryGetInt32(out var p)) - return p; - } - catch { } - return null; + return await BrokerClient.ResolveAgentPortForProjectAsync() + ?? BrokerClient.ReadConfigPort() + ?? 9223; } } diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs index 563c61b..6ace23b 100644 --- a/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs +++ b/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Text; using System.Text.Json; using ModelContextProtocol; using ModelContextProtocol.Server; @@ -11,8 +10,6 @@ namespace MauiDevFlow.CLI.Mcp.Tools; [McpServerToolType] public sealed class CdpTools { - private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) }; - [McpServerTool(Name = "maui_cdp_evaluate"), Description("Execute JavaScript in a Blazor WebView via Chrome DevTools Protocol. Returns the evaluation result.")] public static async Task CdpEvaluate( McpAgentSession session, @@ -21,33 +18,23 @@ public static async Task CdpEvaluate( [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) { var agent = await session.GetAgentClientAsync(agentPort); - var url = $"{agent.BaseUrl}/api/cdp"; - if (webviewId != null) - url += $"?webview={Uri.EscapeDataString(webviewId)}"; - - var body = JsonSerializer.Serialize(new - { - method = "Runtime.evaluate", - @params = new { expression, returnByValue = true } - }); - - var response = await _http.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")); - var content = await response.Content.ReadAsStringAsync(); + var paramsEl = JsonSerializer.Deserialize( + JsonSerializer.Serialize(new { expression, returnByValue = true })); + var content = await agent.SendCdpCommandAsync("Runtime.evaluate", paramsEl, webviewId); try { - var json = JsonSerializer.Deserialize(content); - if (json.TryGetProperty("result", out var result) && + if (content.TryGetProperty("result", out var result) && result.TryGetProperty("result", out var inner) && inner.TryGetProperty("value", out var value)) { return value.ToString(); } - return content; + return content.ToString(); } catch { - return content; + return content.ToString(); } } @@ -58,20 +45,10 @@ public static async Task CdpScreenshot( [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) { var agent = await session.GetAgentClientAsync(agentPort); - var url = $"{agent.BaseUrl}/api/cdp"; - if (webviewId != null) - url += $"?webview={Uri.EscapeDataString(webviewId)}"; - - var body = JsonSerializer.Serialize(new - { - method = "Page.captureScreenshot", - @params = new { format = "png" } - }); - - var response = await _http.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")); - var content = await response.Content.ReadAsStringAsync(); + var paramsEl = JsonSerializer.Deserialize( + JsonSerializer.Serialize(new { format = "png" })); + var json = await agent.SendCdpCommandAsync("Page.captureScreenshot", paramsEl, webviewId); - var json = JsonSerializer.Deserialize(content); if (json.TryGetProperty("result", out var result) && result.TryGetProperty("data", out var data)) { diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs index 7d48f73..ebc46f4 100644 --- a/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs +++ b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs @@ -8,8 +8,6 @@ namespace MauiDevFlow.CLI.Mcp.Tools; [McpServerToolType] public sealed class LogsTool { - private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) }; - [McpServerTool(Name = "maui_logs"), Description("Retrieve app logs (ILogger output and WebView console logs). Returns structured JSON log entries with timestamp, level, category, and message.")] public static async Task Logs( McpAgentSession session, @@ -20,11 +18,7 @@ public static async Task Logs( [Description("Log source filter: native, webview, or all (default: all)")] string? source = null) { var agent = await session.GetAgentClientAsync(agentPort); - var url = $"{agent.BaseUrl}/api/logs?limit={limit}&skip={skip}"; - if (!string.IsNullOrEmpty(source) && source != "all") - url += $"&source={source}"; - - var response = await _http.GetStringAsync(url); + var response = await agent.GetLogsAsync(limit, skip, source); if (string.IsNullOrEmpty(minLevel)) return response; diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs index 8660492..bedd941 100644 --- a/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs +++ b/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs @@ -1,6 +1,4 @@ using System.ComponentModel; -using System.Text; -using System.Text.Json; using ModelContextProtocol.Server; using MauiDevFlow.CLI.Mcp; @@ -9,8 +7,6 @@ namespace MauiDevFlow.CLI.Mcp.Tools; [McpServerToolType] public sealed class PropertyTools { - private static readonly HttpClient s_http = new() { Timeout = TimeSpan.FromSeconds(10) }; - [McpServerTool(Name = "maui_get_property"), Description("Get the value of a property on a UI element (e.g., Text, IsVisible, BackgroundColor, SelectedIndex).")] public static async Task GetProperty( McpAgentSession session, @@ -32,21 +28,9 @@ public static async Task SetProperty( [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) { var agent = await session.GetAgentClientAsync(agentPort); - var json = JsonSerializer.Serialize(new { elementId, property, value }); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - try - { - var response = await s_http.PostAsync($"{agent.BaseUrl}/api/action/set-property", content); - var body = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(body); - if (result.TryGetProperty("success", out var s) && s.GetBoolean()) - return $"Set '{property}' = '{value}' on element '{elementId}'."; - var error = result.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error"; - return $"Failed to set property: {error}"; - } - catch (Exception ex) - { - return $"Failed to set property: {ex.Message}"; - } + var success = await agent.SetPropertyAsync(elementId, property, value); + return success + ? $"Set '{property}' = '{value}' on element '{elementId}'." + : $"Failed to set property '{property}' on element '{elementId}'."; } } diff --git a/src/MauiDevFlow.CLI/Program.cs b/src/MauiDevFlow.CLI/Program.cs index 090ba66..01c44cd 100644 --- a/src/MauiDevFlow.CLI/Program.cs +++ b/src/MauiDevFlow.CLI/Program.cs @@ -909,33 +909,16 @@ await BatchAsync(host, port, delay, continueOnError, human), return _errorOccurred ? 1 : result; } - // ===== CDP Helper: Send command via HTTP POST /api/cdp ===== + // ===== CDP Helper: Send command via AgentClient ===== private static async Task SendCdpCommandAsync(string host, int port, string method, object? parameters = null, string? webview = null) { - using var http = new HttpClient(); - http.Timeout = TimeSpan.FromSeconds(30); - - var command = new Dictionary - { - ["id"] = 1, - ["method"] = method - }; - if (parameters != null) - command["params"] = parameters; - - var json = JsonSerializer.Serialize(command); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - var url = string.IsNullOrEmpty(webview) - ? $"http://{host}:{port}/api/cdp" - : $"http://{host}:{port}/api/cdp?webview={Uri.EscapeDataString(webview)}"; - var response = await http.PostAsync(url, content); - var body = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - throw new Exception($"CDP request failed ({response.StatusCode}): {body}"); - - return JsonSerializer.Deserialize(body); + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + JsonElement? paramsEl = parameters != null + ? JsonSerializer.Deserialize(JsonSerializer.Serialize(parameters)) + : null; + var result = await client.SendCdpCommandAsync(method, paramsEl, webview); + return result; } private static async Task CdpEvaluateAsync(string host, int port, string expression, string? webview = null) @@ -1189,10 +1172,9 @@ private static async Task CdpWebViewsAsync(string host, int port, bool json) { try { - using var http = new HttpClient(); - http.Timeout = TimeSpan.FromSeconds(5); - var response = await http.GetAsync($"http://{host}:{port}/api/cdp/webviews"); - var body = await response.Content.ReadAsStringAsync(); + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var result = await client.GetCdpWebViewsAsync(); + var body = result.ToString(); if (json) { @@ -1200,8 +1182,7 @@ private static async Task CdpWebViewsAsync(string host, int port, bool json) return; } - var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("webviews", out var webviews)) + if (result.TryGetProperty("webviews", out var webviews)) { if (webviews.GetArrayLength() == 0) { @@ -1232,21 +1213,9 @@ private static async Task CdpSourceAsync(string host, int port, string? webview { try { - using var http = new HttpClient(); - http.Timeout = TimeSpan.FromSeconds(10); - var url = $"http://{host}:{port}/api/cdp/source"; - if (!string.IsNullOrEmpty(webview)) - url += $"?webview={Uri.EscapeDataString(webview)}"; - var response = await http.GetAsync(url); - var body = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - WriteError($"Failed to get page source: {body}"); - return; - } - - Console.WriteLine(body); + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var source = await client.GetCdpSourceAsync(webview); + Console.WriteLine(source); } catch (Exception ex) { @@ -2137,21 +2106,16 @@ private static async Task MauiSetPropertyAsync(string host, int port, bool json, { try { - using var http = new HttpClient(); - http.Timeout = TimeSpan.FromSeconds(10); - var body = JsonSerializer.Serialize(new { value }); - var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); - var response = await http.PostAsync($"http://{host}:{port}/api/property/{elementId}/{propertyName}", content); - var responseBody = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode) + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var success = await client.SetPropertyAsync(elementId, propertyName, value); + if (success) { OutputWriter.WriteActionResult(true, "SetProperty", elementId, json, $"Set {propertyName} = {value}"); } else { - OutputWriter.WriteError($"Failed: {responseBody}", json); + OutputWriter.WriteError($"Failed to set {propertyName}", json); _errorOccurred = true; } } @@ -2245,20 +2209,8 @@ private static async Task MauiLogsAsync(string host, int port, bool json, int li { try { - using var http = new HttpClient(); - http.BaseAddress = new Uri($"http://{host}:{port}"); - var url = $"/api/logs?limit={limit}&skip={skip}"; - if (!string.IsNullOrEmpty(source)) - url += $"&source={Uri.EscapeDataString(source)}"; - var response = await http.GetAsync(url); - var body = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - OutputWriter.WriteError($"Failed to fetch logs: {response.StatusCode} {body}", json); - _errorOccurred = true; - return; - } + using var client = new MauiDevFlow.Driver.AgentClient(host, port); + var body = await client.GetLogsAsync(limit, skip, source); if (json) { @@ -3125,95 +3077,39 @@ private static async Task PermissionAsync(string action, string? udid, string? b /// /// Reads the port from .mauidevflow in the current directory. /// - private static int? ReadConfigPort() - { - try - { - var path = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow"); - if (!File.Exists(path)) return null; - - var json = File.ReadAllText(path); - var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("port", out var portProp) && portProp.ValueKind == JsonValueKind.Number) - return portProp.GetInt32(); - } - catch { /* ignore parse failures */ } - return null; - } - /// /// Resolves the agent port: broker discovery → .mauidevflow config → default 9223. /// private static int ResolveAgentPort() { - // Try broker discovery try { - var brokerPort = Broker.BrokerClient.ReadBrokerPortPublic() ?? Broker.BrokerServer.DefaultPort; - - // Quick TCP check if broker is alive; auto-start if not - bool brokerAlive = false; - try - { - using var tcp = new System.Net.Sockets.TcpClient(); - tcp.ConnectAsync("localhost", brokerPort).Wait(TimeSpan.FromMilliseconds(300)); - brokerAlive = tcp.Connected; - } - catch { /* connect failed or timed out — broker not alive */ } + var port = Broker.BrokerClient.ResolveAgentPortForProjectAsync().GetAwaiter().GetResult(); + if (port.HasValue) return port.Value; - if (!brokerAlive) - { - // Auto-start broker in background - try - { - var brokerResult = Broker.BrokerClient.EnsureBrokerRunningAsync().GetAwaiter().GetResult(); - if (brokerResult.HasValue) - { - brokerPort = brokerResult.Value; - brokerAlive = true; - } - } - catch { } - } + // No single match — check config file fallback + var configPort = Broker.BrokerClient.ReadConfigPort(); + if (configPort.HasValue) return configPort.Value; - if (brokerAlive) + // Multiple agents, can't disambiguate — show them so the caller + // (human or AI agent) can re-run with --agent-port + var brokerPort = Broker.BrokerClient.ReadBrokerPortPublic() ?? Broker.BrokerServer.DefaultPort; + var agents = Broker.BrokerClient.ListAgentsAsync(brokerPort).GetAwaiter().GetResult(); + if (agents != null && agents.Length > 1) { - // Find project in current directory - var csproj = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj").FirstOrDefault(); - if (csproj != null) - { - var port = Broker.BrokerClient.ResolveAgentPortAsync(brokerPort, Path.GetFullPath(csproj)).GetAwaiter().GetResult(); - if (port.HasValue) return port.Value; - } - - // Try auto-select (single agent) - var autoPort = Broker.BrokerClient.ResolveAgentPortAsync(brokerPort).GetAwaiter().GetResult(); - if (autoPort.HasValue) return autoPort.Value; - - // Multiple agents, can't disambiguate — show them so the caller - // (human or AI agent) can re-run with --agent-port - // Only show if we won't have a config file fallback - var configPort = ReadConfigPort(); - if (configPort.HasValue) return configPort.Value; - - var agents = Broker.BrokerClient.ListAgentsAsync(brokerPort).GetAwaiter().GetResult(); - if (agents != null && agents.Length > 1) - { - Console.Error.WriteLine("Multiple agents connected. Use --agent-port to specify which one:"); - Console.Error.WriteLine(); - Console.Error.WriteLine($"{"ID",-15}{"App",-20}{"Platform",-15}{"TFM",-25}{"Port",-7}"); - Console.Error.WriteLine(new string('-', 82)); - foreach (var a in agents) - Console.Error.WriteLine($"{a.Id,-15}{a.AppName,-20}{a.Platform,-15}{a.Tfm,-25}{a.Port,-7}"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Example: maui-devflow MAUI status --agent-port "); - } + Console.Error.WriteLine("Multiple agents connected. Use --agent-port to specify which one:"); + Console.Error.WriteLine(); + Console.Error.WriteLine($"{"ID",-15}{"App",-20}{"Platform",-15}{"TFM",-25}{"Port",-7}"); + Console.Error.WriteLine(new string('-', 82)); + foreach (var a in agents) + Console.Error.WriteLine($"{a.Id,-15}{a.AppName,-20}{a.Platform,-15}{a.Tfm,-25}{a.Port,-7}"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Example: maui-devflow MAUI status --agent-port "); } } catch { /* broker unavailable, fall through */ } - // Fall back to config file (already checked above if broker was alive) - return ReadConfigPort() ?? 9223; + return Broker.BrokerClient.ReadConfigPort() ?? 9223; } // ===== Broker Commands ===== diff --git a/src/MauiDevFlow.Driver/AgentClient.cs b/src/MauiDevFlow.Driver/AgentClient.cs index c21399c..04d0501 100644 --- a/src/MauiDevFlow.Driver/AgentClient.cs +++ b/src/MauiDevFlow.Driver/AgentClient.cs @@ -179,6 +179,52 @@ public async Task ResizeAsync(int width, int height, int? window = null) return null; } + /// + /// Set a property value on an element. + /// + public async Task SetPropertyAsync(string elementId, string propertyName, string value) + { + try + { + var json = JsonSerializer.Serialize(new { value }); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{_baseUrl}/api/property/{elementId}/{propertyName}", content); + return response.IsSuccessStatusCode; + } + catch { return false; } + } + + /// + /// Retrieve application logs from the agent. + /// + public async Task GetLogsAsync(int limit = 100, int skip = 0, string? source = null) + { + var path = $"/api/logs?limit={limit}&skip={skip}"; + if (!string.IsNullOrEmpty(source) && source != "all") + path += $"&source={Uri.EscapeDataString(source)}"; + return await _http.GetStringAsync($"{_baseUrl}{path}"); + } + + /// + /// Send a CDP command to a Blazor WebView. + /// + public async Task SendCdpCommandAsync(string method, JsonElement? @params = null, string? webviewId = null) + { + var path = "/api/cdp"; + if (!string.IsNullOrEmpty(webviewId)) + path += $"?webview={Uri.EscapeDataString(webviewId)}"; + + var body = new Dictionary { ["method"] = method }; + if (@params.HasValue && @params.Value.ValueKind != JsonValueKind.Undefined) + body["params"] = @params.Value; + + var json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{_baseUrl}{path}", content); + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + /// /// Gets the list of CDP WebViews registered with the agent. /// From 54cd76225764506eaa256456e462be04f687b4b0 Mon Sep 17 00:00:00 2001 From: Emanuel Fernandez Dell'Oca Date: Wed, 11 Mar 2026 13:26:08 -0400 Subject: [PATCH 4/5] Add MCP tools for preferences, secure storage, platform info, and sensors New MCP tools: - maui_preferences_list/get/set/delete/clear - maui_secure_storage_get/set/delete/clear - maui_app_info, maui_device_info, maui_display_info - maui_battery_info, maui_connectivity, maui_geolocation - maui_sensors_list/start/stop AgentClient: added methods for preferences, secure storage, platform info, geolocation, and sensor management. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiDevFlow.CLI/Mcp/McpServerHost.cs | 5 +- .../Mcp/Tools/PlatformTools.cs | 72 +++++++++++ .../Mcp/Tools/PreferencesTools.cs | 115 ++++++++++++++++++ src/MauiDevFlow.CLI/Mcp/Tools/SensorTools.cs | 43 +++++++ src/MauiDevFlow.Driver/AgentClient.cs | 115 ++++++++++++++++++ 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/PlatformTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/PreferencesTools.cs create mode 100644 src/MauiDevFlow.CLI/Mcp/Tools/SensorTools.cs diff --git a/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs index 48eaf69..02e7af6 100644 --- a/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs +++ b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs @@ -33,7 +33,10 @@ public static async Task RunAsync() .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools() + .WithTools() + .WithTools(); await builder.Build().RunAsync(); } diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/PlatformTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/PlatformTools.cs new file mode 100644 index 0000000..792ef97 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/PlatformTools.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class PlatformTools +{ + [McpServerTool(Name = "maui_app_info"), Description("Get app name, version, package name, build number, and theme.")] + public static async Task GetAppInfo( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPlatformInfoAsync("app-info"); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to get app info." : result.ToString(); + } + + [McpServerTool(Name = "maui_device_info"), Description("Get device manufacturer, model, OS version, platform, and idiom.")] + public static async Task GetDeviceInfo( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPlatformInfoAsync("device-info"); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to get device info." : result.ToString(); + } + + [McpServerTool(Name = "maui_display_info"), Description("Get screen width, height, density, orientation, rotation, and refresh rate.")] + public static async Task GetDisplayInfo( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPlatformInfoAsync("device-display"); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to get display info." : result.ToString(); + } + + [McpServerTool(Name = "maui_battery_info"), Description("Get battery level, charging state, power source, and energy saver status.")] + public static async Task GetBatteryInfo( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPlatformInfoAsync("battery"); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to get battery info." : result.ToString(); + } + + [McpServerTool(Name = "maui_connectivity"), Description("Get network access status and connection profiles (WiFi, Cellular, etc.).")] + public static async Task GetConnectivity( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPlatformInfoAsync("connectivity"); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to get connectivity info." : result.ToString(); + } + + [McpServerTool(Name = "maui_geolocation"), Description("Get current GPS coordinates (latitude, longitude, altitude, accuracy).")] + public static async Task GetGeolocation( + McpAgentSession session, + [Description("Accuracy: default, coarse, fine")] string? accuracy = null, + [Description("Timeout in seconds (default: 10)")] int? timeoutSeconds = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetGeolocationAsync(accuracy, timeoutSeconds); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to get geolocation." : result.ToString(); + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/PreferencesTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/PreferencesTools.cs new file mode 100644 index 0000000..fd07e35 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/PreferencesTools.cs @@ -0,0 +1,115 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class PreferencesTools +{ + [McpServerTool(Name = "maui_preferences_list"), Description("List all known preference keys from the app's key-value store.")] + public static async Task ListPreferences( + McpAgentSession session, + [Description("Shared preferences name (optional, for shared containers)")] string? sharedName = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPreferencesAsync(sharedName); + return result.ValueKind == JsonValueKind.Undefined ? "No preferences found." : result.ToString(); + } + + [McpServerTool(Name = "maui_preferences_get"), Description("Get a preference value by key from the app's key-value store.")] + public static async Task GetPreference( + McpAgentSession session, + [Description("Preference key to retrieve")] string key, + [Description("Value type: string, int, bool, double, float, long, datetime (default: string)")] string? type = null, + [Description("Shared preferences name (optional)")] string? sharedName = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetPreferenceAsync(key, type, sharedName); + return result.ValueKind == JsonValueKind.Undefined ? $"Preference '{key}' not found." : result.ToString(); + } + + [McpServerTool(Name = "maui_preferences_set"), Description("Set a preference value in the app's key-value store.")] + public static async Task SetPreference( + McpAgentSession session, + [Description("Preference key")] string key, + [Description("Value to store")] string value, + [Description("Value type: string, int, bool, double, float, long, datetime (default: string)")] string? type = null, + [Description("Shared preferences name (optional)")] string? sharedName = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.SetPreferenceAsync(key, value, type, sharedName); + return result.ValueKind == JsonValueKind.Undefined ? $"Failed to set preference '{key}'." : result.ToString(); + } + + [McpServerTool(Name = "maui_preferences_delete"), Description("Remove a preference by key from the app's key-value store.")] + public static async Task DeletePreference( + McpAgentSession session, + [Description("Preference key to remove")] string key, + [Description("Shared preferences name (optional)")] string? sharedName = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.DeletePreferenceAsync(key, sharedName); + return result.ValueKind == JsonValueKind.Undefined ? $"Failed to delete preference '{key}'." : result.ToString(); + } + + [McpServerTool(Name = "maui_preferences_clear"), Description("Clear all preferences from the app's key-value store.")] + public static async Task ClearPreferences( + McpAgentSession session, + [Description("Shared preferences name (optional)")] string? sharedName = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.ClearPreferencesAsync(sharedName); + return success ? "Preferences cleared." : "Failed to clear preferences."; + } + + [McpServerTool(Name = "maui_secure_storage_get"), Description("Get a value from the app's encrypted secure storage.")] + public static async Task GetSecureStorage( + McpAgentSession session, + [Description("Secure storage key to retrieve")] string key, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetSecureStorageAsync(key); + return result.ValueKind == JsonValueKind.Undefined ? $"Secure storage key '{key}' not found." : result.ToString(); + } + + [McpServerTool(Name = "maui_secure_storage_set"), Description("Set a value in the app's encrypted secure storage.")] + public static async Task SetSecureStorage( + McpAgentSession session, + [Description("Secure storage key")] string key, + [Description("Value to store")] string value, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.SetSecureStorageAsync(key, value); + return result.ValueKind == JsonValueKind.Undefined ? $"Failed to set secure storage key '{key}'." : result.ToString(); + } + + [McpServerTool(Name = "maui_secure_storage_delete"), Description("Remove an entry from the app's encrypted secure storage.")] + public static async Task DeleteSecureStorage( + McpAgentSession session, + [Description("Secure storage key to remove")] string key, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.DeleteSecureStorageAsync(key); + return result.ValueKind == JsonValueKind.Undefined ? $"Failed to delete secure storage key '{key}'." : result.ToString(); + } + + [McpServerTool(Name = "maui_secure_storage_clear"), Description("Clear all entries from the app's encrypted secure storage.")] + public static async Task ClearSecureStorage( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.ClearSecureStorageAsync(); + return success ? "Secure storage cleared." : "Failed to clear secure storage."; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/Tools/SensorTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/SensorTools.cs new file mode 100644 index 0000000..801d5f7 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/SensorTools.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class SensorTools +{ + [McpServerTool(Name = "maui_sensors_list"), Description("List available device sensors and their current status (active/inactive).")] + public static async Task ListSensors( + McpAgentSession session, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var result = await agent.GetSensorsAsync(); + return result.ValueKind == JsonValueKind.Undefined ? "Failed to list sensors." : result.ToString(); + } + + [McpServerTool(Name = "maui_sensors_start"), Description("Start a device sensor (e.g., Accelerometer, Gyroscope, Magnetometer, Barometer, Compass, OrientationSensor).")] + public static async Task StartSensor( + McpAgentSession session, + [Description("Sensor name (e.g., Accelerometer, Gyroscope, Magnetometer, Barometer, Compass)")] string sensor, + [Description("Reading speed: UI, Default, Game, Fastest (default: Default)")] string? speed = null, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.StartSensorAsync(sensor, speed); + return success ? $"Sensor '{sensor}' started." : $"Failed to start sensor '{sensor}'."; + } + + [McpServerTool(Name = "maui_sensors_stop"), Description("Stop a running device sensor.")] + public static async Task StopSensor( + McpAgentSession session, + [Description("Sensor name to stop")] string sensor, + [Description("Agent HTTP port (optional if only one agent connected)")] int? agentPort = null) + { + var agent = await session.GetAgentClientAsync(agentPort); + var success = await agent.StopSensorAsync(sensor); + return success ? $"Sensor '{sensor}' stopped." : $"Failed to stop sensor '{sensor}'."; + } +} diff --git a/src/MauiDevFlow.Driver/AgentClient.cs b/src/MauiDevFlow.Driver/AgentClient.cs index 04d0501..544902b 100644 --- a/src/MauiDevFlow.Driver/AgentClient.cs +++ b/src/MauiDevFlow.Driver/AgentClient.cs @@ -355,6 +355,121 @@ private async Task PostActionAsync(string path, object body) } } + // ── Preferences ── + + public async Task GetPreferencesAsync(string? sharedName = null) + { + var path = "/api/preferences"; + if (!string.IsNullOrEmpty(sharedName)) + path += $"?sharedName={Uri.EscapeDataString(sharedName)}"; + return await GetJsonAsync(path); + } + + public async Task GetPreferenceAsync(string key, string? type = null, string? sharedName = null) + { + var path = $"/api/preferences/{Uri.EscapeDataString(key)}"; + var qs = new List(); + if (!string.IsNullOrEmpty(type)) qs.Add($"type={Uri.EscapeDataString(type)}"); + if (!string.IsNullOrEmpty(sharedName)) qs.Add($"sharedName={Uri.EscapeDataString(sharedName)}"); + if (qs.Count > 0) path += "?" + string.Join("&", qs); + return await GetJsonAsync(path); + } + + public async Task SetPreferenceAsync(string key, string value, string? type = null, string? sharedName = null) + { + var body = new Dictionary { ["value"] = value }; + if (!string.IsNullOrEmpty(type)) body["type"] = type; + if (!string.IsNullOrEmpty(sharedName)) body["sharedName"] = sharedName; + var json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{_baseUrl}/api/preferences/{Uri.EscapeDataString(key)}", content); + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + + public async Task DeletePreferenceAsync(string key, string? sharedName = null) + { + var path = $"/api/preferences/{Uri.EscapeDataString(key)}"; + if (!string.IsNullOrEmpty(sharedName)) + path += $"?sharedName={Uri.EscapeDataString(sharedName)}"; + var response = await _http.DeleteAsync($"{_baseUrl}{path}"); + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + + public async Task ClearPreferencesAsync(string? sharedName = null) + { + var path = "/api/preferences/clear"; + if (!string.IsNullOrEmpty(sharedName)) + path += $"?sharedName={Uri.EscapeDataString(sharedName)}"; + return await PostActionAsync(path, new { }); + } + + // ── Secure Storage ── + + public async Task GetSecureStorageAsync(string key) + { + return await GetJsonAsync($"/api/secure-storage/{Uri.EscapeDataString(key)}"); + } + + public async Task SetSecureStorageAsync(string key, string value) + { + var json = JsonSerializer.Serialize(new { value }); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{_baseUrl}/api/secure-storage/{Uri.EscapeDataString(key)}", content); + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + + public async Task DeleteSecureStorageAsync(string key) + { + var response = await _http.DeleteAsync($"{_baseUrl}/api/secure-storage/{Uri.EscapeDataString(key)}"); + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + + public async Task ClearSecureStorageAsync() + { + return await PostActionAsync("/api/secure-storage/clear", new { }); + } + + // ── Platform info ── + + public async Task GetPlatformInfoAsync(string endpoint) + { + return await GetJsonAsync($"/api/platform/{endpoint}"); + } + + public async Task GetGeolocationAsync(string? accuracy = null, int? timeoutSeconds = null) + { + var path = "/api/platform/geolocation"; + var qs = new List(); + if (!string.IsNullOrEmpty(accuracy)) qs.Add($"accuracy={Uri.EscapeDataString(accuracy)}"); + if (timeoutSeconds.HasValue) qs.Add($"timeout={timeoutSeconds.Value}"); + if (qs.Count > 0) path += "?" + string.Join("&", qs); + return await GetJsonAsync(path); + } + + // ── Sensors ── + + public async Task GetSensorsAsync() + { + return await GetJsonAsync("/api/sensors"); + } + + public async Task StartSensorAsync(string sensor, string? speed = null) + { + var path = $"/api/sensors/{Uri.EscapeDataString(sensor)}/start"; + if (!string.IsNullOrEmpty(speed)) + path += $"?speed={Uri.EscapeDataString(speed)}"; + return await PostActionAsync(path, new { }); + } + + public async Task StopSensorAsync(string sensor) + { + return await PostActionAsync($"/api/sensors/{Uri.EscapeDataString(sensor)}/stop", new { }); + } + public void Dispose() { if (_disposed) return; From 0f8c0120e77b5228d744b29411097a14cf3a0546 Mon Sep 17 00:00:00 2001 From: Emanuel Fernandez Dell'Oca Date: Wed, 11 Mar 2026 13:31:04 -0400 Subject: [PATCH 5/5] Update README with all 49 MCP tools Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6e28f63..a15dc77 100644 --- a/README.md +++ b/README.md @@ -448,32 +448,65 @@ Add to your VS Code MCP settings (`.vscode/mcp.json`): | Tool | Description | |------|-------------| +| **Inspection** | | | `maui_screenshot` | Capture screenshot — **returns image directly** to the AI agent | | `maui_tree` | Visual tree as structured JSON with element IDs, types, bounds, properties | -| `maui_logs` | Structured log entries with level filtering | -| `maui_network` | Captured HTTP requests with status, timing, sizes | -| `maui_network_detail` | Full request/response detail including headers and body | +| `maui_element` | Get detailed info for a single element | +| `maui_query` | Query elements by type, AutomationId, or text | +| `maui_query_css` | Query Blazor WebView elements via CSS selector | +| `maui_hittest` | Find element at screen coordinates | +| `maui_assert` | Assert an element property matches an expected value | +| **Interaction** | | | `maui_tap` | Tap a UI element by ID | | `maui_fill` | Fill text into an Entry/Editor/SearchBar | | `maui_clear` | Clear text from an input element | -| `maui_scroll` | Scroll a ScrollView element | -| `maui_navigate` | Navigate to a Shell route | +| `maui_scroll` | Scroll a ScrollView (by delta, item index, or position) | | `maui_focus` | Set focus to an element | +| `maui_navigate` | Navigate to a Shell route | | `maui_resize` | Resize the app window | -| `maui_set_property` | Set a property value at runtime | +| **Properties** | | | `maui_get_property` | Get a property value | -| `maui_query` | Query elements by type, AutomationId, or text | -| `maui_query_css` | Query Blazor WebView elements via CSS selector | -| `maui_element` | Get detailed info for a single element | -| `maui_hittest` | Find element at screen coordinates | -| `maui_list_agents` | List connected MAUI apps | -| `maui_status` | Agent status (platform, version, app name) | -| `maui_wait` | Wait for an agent to connect | -| `maui_select_agent` | Set default agent for the session | +| `maui_set_property` | Set a property value at runtime | +| **Logging & Network** | | +| `maui_logs` | Structured log entries with level filtering | +| `maui_network` | Captured HTTP requests with status, timing, sizes | +| `maui_network_detail` | Full request/response detail including headers and body | +| `maui_network_clear` | Clear captured network requests | +| **Preferences & Storage** | | +| `maui_preferences_list` | List all known preference keys | +| `maui_preferences_get` | Get a preference value by key | +| `maui_preferences_set` | Set a preference value | +| `maui_preferences_delete` | Remove a preference by key | +| `maui_preferences_clear` | Clear all preferences | +| `maui_secure_storage_get` | Get a value from encrypted secure storage | +| `maui_secure_storage_set` | Set a value in encrypted secure storage | +| `maui_secure_storage_delete` | Remove a secure storage entry | +| `maui_secure_storage_clear` | Clear all secure storage entries | +| **Platform & Device** | | +| `maui_app_info` | App name, version, package name, build number, theme | +| `maui_device_info` | Device manufacturer, model, OS version, platform, idiom | +| `maui_display_info` | Screen width, height, density, orientation, refresh rate | +| `maui_battery_info` | Battery level, charging state, power source | +| `maui_connectivity` | Network access status and connection profiles | +| `maui_geolocation` | Current GPS coordinates | +| **Sensors** | | +| `maui_sensors_list` | List available device sensors and their status | +| `maui_sensors_start` | Start a sensor (Accelerometer, Gyroscope, etc.) | +| `maui_sensors_stop` | Stop a running sensor | +| **Recording** | | +| `maui_recording_start` | Start screen recording | +| `maui_recording_stop` | Stop recording and save the file | +| `maui_recording_status` | Check recording status | +| **Blazor WebView (CDP)** | | | `maui_cdp_evaluate` | Execute JavaScript in a Blazor WebView | | `maui_cdp_screenshot` | Capture WebView screenshot via CDP | | `maui_cdp_source` | Get WebView HTML source | | `maui_cdp_webviews` | List registered Blazor WebViews | +| **Agent Management** | | +| `maui_list_agents` | List connected MAUI apps | +| `maui_status` | Agent status (platform, version, app name) | +| `maui_wait` | Wait for an agent to connect | +| `maui_select_agent` | Set default agent for the session | All tools accept an optional `agentPort` parameter. When omitted, the server auto-discovers the connected agent via the broker — same as the CLI.