diff --git a/README.md b/README.md index 7bbaab5..a15dc77 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,92 @@ 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 | +|------|-------------| +| **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_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 (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 | +| **Properties** | | +| `maui_get_property` | Get a property value | +| `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. + ## License MIT 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/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..c7e8f99 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs @@ -0,0 +1,35 @@ +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 = await BrokerClient.EnsureBrokerRunningAsync(); + return port ?? BrokerServer.DefaultPort; + } + + public async Task ListAgentsAsync() + { + var brokerPort = await GetBrokerPortAsync(); + return await BrokerClient.ListAgentsAsync(brokerPort); + } + + private async Task ResolveAgentPortAsync() + { + return await BrokerClient.ResolveAgentPortForProjectAsync() + ?? BrokerClient.ReadConfigPort() + ?? 9223; + } +} diff --git a/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs new file mode 100644 index 0000000..02e7af6 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/McpServerHost.cs @@ -0,0 +1,43 @@ +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() + .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/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/CdpTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs new file mode 100644 index 0000000..6ace23b --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/CdpTools.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; +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 +{ + [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 paramsEl = JsonSerializer.Deserialize( + JsonSerializer.Serialize(new { expression, returnByValue = true })); + var content = await agent.SendCdpCommandAsync("Runtime.evaluate", paramsEl, webviewId); + + try + { + if (content.TryGetProperty("result", out var result) && + result.TryGetProperty("result", out var inner) && + inner.TryGetProperty("value", out var value)) + { + return value.ToString(); + } + return content.ToString(); + } + catch + { + return content.ToString(); + } + } + + [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 paramsEl = JsonSerializer.Deserialize( + JsonSerializer.Serialize(new { format = "png" })); + var json = await agent.SendCdpCommandAsync("Page.captureScreenshot", paramsEl, webviewId); + + 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..0296eba --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/InteractionTools.cs @@ -0,0 +1,69 @@ +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, 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 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, itemIndex, groupIndex, scrollToPosition); + return success + ? 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 new file mode 100644 index 0000000..ebc46f4 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/LogsTool.cs @@ -0,0 +1,51 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class LogsTool +{ + [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("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 response = await agent.GetLogsAsync(limit, skip, source); + + 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/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/PropertyTools.cs b/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs new file mode 100644 index 0000000..bedd941 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/PropertyTools.cs @@ -0,0 +1,36 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using MauiDevFlow.CLI.Mcp; + +namespace MauiDevFlow.CLI.Mcp.Tools; + +[McpServerToolType] +public sealed class PropertyTools +{ + [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 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/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/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 new file mode 100644 index 0000000..c956fd8 --- /dev/null +++ b/src/MauiDevFlow.CLI/Mcp/Tools/ScreenshotTool.cs @@ -0,0 +1,32 @@ +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, + [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, 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?"); + + return [ + new TextContentBlock { Text = $"Screenshot captured ({bytes.Length} bytes, PNG)" }, + ImageContentBlock.FromBytes(bytes, "image/png") + ]; + } +} 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.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..01c44cd 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(); @@ -904,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) @@ -1184,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) { @@ -1195,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) { @@ -1227,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) { @@ -2132,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; } } @@ -2240,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) { @@ -3120,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..544902b 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. /// @@ -309,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;