Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions src/MauiDevFlow.CLI/Broker/BrokerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,56 @@ private static async Task<bool> IsBrokerAliveAsync(int port)
/// </summary>
public static int? ReadBrokerPortPublic() => ReadBrokerPort();

/// <summary>
/// High-level port resolution: ensure broker running → resolve by project → auto-select → config fallback → default.
/// Returns the resolved agent port.
/// </summary>
public static async Task<int?> 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;
}

/// <summary>
/// Read port from .mauidevflow config file in the current directory.
/// </summary>
public static int? ReadConfigPort()
{
var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow");
if (!File.Exists(configPath)) return null;
try
{
var json = JsonSerializer.Deserialize<JsonElement>(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
Expand Down
2 changes: 2 additions & 0 deletions src/MauiDevFlow.CLI/MauiDevFlow.CLI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="ModelContextProtocol" Version="1.0.0" />
<PackageReference Include="Spectre.Console" Version="0.54.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="Websocket.Client" Version="5.1.2" />
Expand Down
35 changes: 35 additions & 0 deletions src/MauiDevFlow.CLI/Mcp/McpAgentSession.cs
Original file line number Diff line number Diff line change
@@ -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<AgentClient> GetAgentClientAsync(int? agentPort = null)
{
var port = agentPort ?? DefaultAgentPort ?? await ResolveAgentPortAsync();
return new AgentClient(DefaultAgentHost, port);
}

public async Task<int> GetBrokerPortAsync()
{
var port = await BrokerClient.EnsureBrokerRunningAsync();
return port ?? BrokerServer.DefaultPort;
}

public async Task<AgentRegistration[]?> ListAgentsAsync()
{
var brokerPort = await GetBrokerPortAsync();
return await BrokerClient.ListAgentsAsync(brokerPort);
}

private async Task<int> ResolveAgentPortAsync()
{
return await BrokerClient.ResolveAgentPortForProjectAsync()
?? BrokerClient.ReadConfigPort()
?? 9223;
}
}
43 changes: 43 additions & 0 deletions src/MauiDevFlow.CLI/Mcp/McpServerHost.cs
Original file line number Diff line number Diff line change
@@ -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<McpAgentSession>();

builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new() { Name = "maui-devflow", Version = version };
})
.WithStdioServerTransport()
.WithTools<ScreenshotTool>()
.WithTools<TreeTool>()
.WithTools<LogsTool>()
.WithTools<NetworkTool>()
.WithTools<InteractionTools>()
.WithTools<PropertyTools>()
.WithTools<NavigationTools>()
.WithTools<QueryTools>()
.WithTools<AgentTools>()
.WithTools<CdpTools>()
.WithTools<AssertTool>()
.WithTools<RecordingTools>()
.WithTools<PreferencesTools>()
.WithTools<PlatformTools>()
.WithTools<SensorTools>();

await builder.Build().RunAsync();
}
}
102 changes: 102 additions & 0 deletions src/MauiDevFlow.CLI/Mcp/Tools/AgentTools.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<string> 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<string> 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.";
}
}
40 changes: 40 additions & 0 deletions src/MauiDevFlow.CLI/Mcp/Tools/AssertTool.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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)"}\"";
}
}
Loading
Loading