diff --git a/DotNetMcp.Tests/Server/McpAppsMetaWireFormatTest.cs b/DotNetMcp.Tests/Server/McpAppsMetaWireFormatTest.cs
new file mode 100644
index 0000000..98a45ee
--- /dev/null
+++ b/DotNetMcp.Tests/Server/McpAppsMetaWireFormatTest.cs
@@ -0,0 +1,116 @@
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using Xunit;
+
+namespace DotNetMcp.Tests;
+
+///
+/// Verifies the wire format of _meta.ui on the dotnet_sdk tool,
+/// ensuring both modern nested and legacy flat key formats are present
+/// for MCP Apps compatibility with VS Code.
+///
+public class McpAppsMetaWireFormatTest : IAsyncLifetime
+{
+ private McpClient? _client;
+
+ public async ValueTask InitializeAsync()
+ {
+ var serverPath = GetServerExecutablePath();
+ var transportOptions = new StdioClientTransportOptions
+ {
+ Command = serverPath.command,
+ Arguments = serverPath.arguments,
+ Name = "dotnet-mcp-test",
+ };
+
+ _client = await McpClient.CreateAsync(
+ new StdioClientTransport(transportOptions));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_client != null)
+ await _client.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task DotnetSdk_Meta_Ui_HasModernNestedFormat()
+ {
+ Assert.NotNull(_client);
+
+ var meta = await GetSdkToolMeta();
+
+ Assert.True(meta.TryGetProperty("ui", out var ui), "_meta should contain 'ui' key");
+ Assert.Equal(JsonValueKind.Object, ui.ValueKind);
+ Assert.True(ui.TryGetProperty("resourceUri", out var resourceUri),
+ "_meta.ui should contain 'resourceUri'");
+ Assert.Equal("ui://dotnet-mcp/sdk-dashboard", resourceUri.GetString());
+ }
+
+ [Fact]
+ public async Task DotnetSdk_Meta_Ui_HasLegacyFlatKey()
+ {
+ Assert.NotNull(_client);
+
+ var meta = await GetSdkToolMeta();
+
+ // Legacy flat key required for VS Code compatibility
+ Assert.True(meta.TryGetProperty("ui/resourceUri", out var legacyUri),
+ "_meta should contain legacy 'ui/resourceUri' flat key (required for VS Code)");
+ Assert.Equal("ui://dotnet-mcp/sdk-dashboard", legacyUri.GetString());
+ }
+
+ private async Task GetSdkToolMeta()
+ {
+ var tools = await _client!.ListToolsAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ var sdkTool = tools.FirstOrDefault(t => t.Name == "dotnet_sdk");
+ Assert.NotNull(sdkTool);
+
+ var json = JsonSerializer.Serialize(sdkTool.ProtocolTool);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ Assert.True(root.TryGetProperty("_meta", out var meta),
+ "dotnet_sdk tool should have _meta field");
+ return meta.Clone();
+ }
+
+ ///
+ /// Finds the pre-built DotNetMcp server binary, matching the same
+ /// configuration/framework as the test project to work in CI.
+ ///
+ private static (string command, string[] arguments) GetServerExecutablePath()
+ {
+ var testBinaryDir = AppContext.BaseDirectory;
+ var configuration = Path.GetFileName(Path.GetDirectoryName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar))!);
+ var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
+
+ var serverBinaryDir = Path.GetFullPath(
+ Path.Join(testBinaryDir, "..", "..", "..", "..", "DotNetMcp", "bin", configuration, targetFramework));
+
+ if (!Directory.Exists(serverBinaryDir))
+ {
+ throw new DirectoryNotFoundException(
+ $"Server binary directory not found at: {serverBinaryDir}. " +
+ $"Make sure DotNetMcp project is built before running tests.");
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var exePath = Path.Join(serverBinaryDir, "DotNetMcp.exe");
+ if (!File.Exists(exePath))
+ throw new FileNotFoundException($"Server executable not found at: {exePath}");
+ return (exePath, Array.Empty());
+ }
+ else
+ {
+ var dllPath = Path.Join(serverBinaryDir, "DotNetMcp.dll");
+ if (!File.Exists(dllPath))
+ throw new FileNotFoundException($"Server assembly not found at: {dllPath}");
+ return ("dotnet", new[] { dllPath });
+ }
+ }
+}
\ No newline at end of file
diff --git a/DotNetMcp/Program.cs b/DotNetMcp/Program.cs
index c5877b1..226cd72 100644
--- a/DotNetMcp/Program.cs
+++ b/DotNetMcp/Program.cs
@@ -73,6 +73,7 @@
.WithStdioServerTransport()
.WithTools()
.WithResources()
+ .WithResources()
.WithPrompts()
.WithSubscribeToResourcesHandler((context, ct) =>
{
diff --git a/DotNetMcp/Resources/McpAppsResources.cs b/DotNetMcp/Resources/McpAppsResources.cs
new file mode 100644
index 0000000..422a61a
--- /dev/null
+++ b/DotNetMcp/Resources/McpAppsResources.cs
@@ -0,0 +1,444 @@
+using System.Text.Json.Nodes;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace DotNetMcp;
+
+///
+/// MCP Apps UI resources (SEP-1865).
+/// Provides interactive HTML views rendered inline in hosts that support
+/// the io.modelcontextprotocol/ui extension.
+///
+[McpServerResourceType]
+public sealed class McpAppsResources
+{
+ [McpServerResource(
+ UriTemplate = "ui://dotnet-mcp/sdk-dashboard",
+ Name = "sdk_dashboard_ui",
+ MimeType = "text/html;profile=mcp-app")]
+ [McpMeta("ui", JsonValue = """{"prefersBorder": true}""")]
+ public static ResourceContents GetSdkDashboardUI() => new TextResourceContents
+ {
+ Uri = "ui://dotnet-mcp/sdk-dashboard",
+ MimeType = "text/html;profile=mcp-app",
+ Text = SdkDashboardHtml,
+ Meta = new JsonObject
+ {
+ ["ui"] = new JsonObject { ["prefersBorder"] = true }
+ }
+ };
+
+ ///
+ /// Interactive HTML dashboard for .NET SDK information.
+ /// Renders installed SDKs, runtimes, and framework info in a styled table
+ /// with host-aware theming via CSS variables from SEP-1865.
+ ///
+ private const string SdkDashboardHtml = """
+
+
+
+
+
+.NET SDK Dashboard
+
+
+
+
+
+
+
+
+
+
⚙️ Installed SDKs
+
Loading SDK data…
+
+
+
+
📦 Installed Runtimes
+
Loading runtime data…
+
+
+
+
+
+
+
+""";
+}
diff --git a/DotNetMcp/Tools/Cli/DotNetCliTools.Project.Consolidated.cs b/DotNetMcp/Tools/Cli/DotNetCliTools.Project.Consolidated.cs
index b18db80..1cefdc2 100644
--- a/DotNetMcp/Tools/Cli/DotNetCliTools.Project.Consolidated.cs
+++ b/DotNetMcp/Tools/Cli/DotNetCliTools.Project.Consolidated.cs
@@ -1181,6 +1181,21 @@ internal async Task DotnetProjectTest(
selectionSource = detectionSource;
}
+ // When MTP is detected and a project path is provided, ensure the working
+ // directory is the project's directory so that `dotnet test` also walks up and
+ // discovers global.json with the MTP runner config. Without this, the CLI may
+ // fall back to VSTest internally and choke on the `--project` flag (MSB1001).
+ if (effectiveRunner == TestRunner.MicrosoftTestingPlatform
+ && !string.IsNullOrEmpty(project)
+ && string.IsNullOrEmpty(DotNetCommandExecutor.WorkingDirectoryOverride.Value))
+ {
+ var projectDir = Path.GetDirectoryName(Path.GetFullPath(project));
+ if (!string.IsNullOrEmpty(projectDir))
+ {
+ DotNetCommandExecutor.WorkingDirectoryOverride.Value = projectDir;
+ }
+ }
+
// Build the command
var args = new StringBuilder("test");
diff --git a/DotNetMcp/Tools/Sdk/DotNetCliTools.Sdk.Consolidated.cs b/DotNetMcp/Tools/Sdk/DotNetCliTools.Sdk.Consolidated.cs
index 52b0115..073fee4 100644
--- a/DotNetMcp/Tools/Sdk/DotNetCliTools.Sdk.Consolidated.cs
+++ b/DotNetMcp/Tools/Sdk/DotNetCliTools.Sdk.Consolidated.cs
@@ -39,6 +39,8 @@ public sealed partial class DotNetCliTools
[McpMeta("commonlyUsed", true)]
[McpMeta("consolidatedTool", true)]
[McpMeta("actions", JsonValue = """["Version","Info","ListSdks","ListRuntimes","ListTemplates","SearchTemplates","TemplateInfo","ClearTemplateCache","ListTemplatePacks","InstallTemplatePack","UninstallTemplatePack","FrameworkInfo","CacheMetrics","ConfigureGlobalJson"]""")]
+ [McpMeta("ui", JsonValue = """{"resourceUri": "ui://dotnet-mcp/sdk-dashboard"}""")]
+ [McpMeta("ui/resourceUri", "ui://dotnet-mcp/sdk-dashboard")]
public async partial Task DotnetSdk(
DotnetSdkAction action,
string? searchTerm = null,
@@ -88,16 +90,25 @@ public async partial Task DotnetSdk(
};
});
- // Add structured content for key actions
+ // Add structured content for key actions.
+ // For ListSdks, also fetch runtimes so the dashboard UI can render both
+ // from a single tool-result notification (tools/call from MCP App iframes
+ // is not reliably supported by all hosts).
object? structured = action switch
{
DotnetSdkAction.Version => BuildVersionStructuredContent(textResult),
- DotnetSdkAction.ListSdks => BuildListSdksStructuredContent(textResult),
+ DotnetSdkAction.ListSdks => await BuildListSdksWithRuntimesStructuredContentAsync(textResult),
DotnetSdkAction.ListRuntimes => BuildListRuntimesStructuredContent(textResult),
_ => null
};
- return StructuredContentHelper.ToCallToolResult(textResult, structured);
+ // When structured content is available, the SDK dashboard UI renders it visually.
+ // Add a hint so the LLM avoids repeating the same data in prose.
+ var displayText = structured != null
+ ? $"[This data is displayed in the SDK dashboard UI. Summarize briefly or refer the user to it rather than repeating all details.]\n\n{textResult}"
+ : textResult;
+
+ return StructuredContentHelper.ToCallToolResult(displayText, structured);
}
private async Task HandleSearchTemplatesAction(string? searchTerm, bool forceReload)
@@ -555,6 +566,59 @@ internal async Task DotnetRuntimeList()
return new { sdks };
}
+ ///
+ /// Build structured content for ListSdks that also includes runtime data.
+ /// The SDK dashboard UI needs both to render fully from a single tool-result notification.
+ ///
+ private async Task