diff --git a/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj new file mode 100644 index 00000000..1cc2b4fa --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs new file mode 100644 index 00000000..9bee7d9a --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs @@ -0,0 +1,69 @@ +namespace BlazorExpress.ChartJS.MCP.Tests; + +public class ChartCatalogTests +{ + [Fact] + public void All_Returns_Current_Public_Chart_Types() + { + var names = ChartCatalog.All.Select(x => x.Name).ToArray(); + + Assert.Equal(["Bar", "Bubble", "Doughnut", "Line", "Pie", "PolarArea", "Radar", "Scatter"], names); + } + + [Theory] + [InlineData("Bar", "BarChart", "BarChartOptions", "BarChartDataset")] + [InlineData("polar-area", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset")] + public void GetSchema_Returns_Metadata_For_Chart_Type(string chartType, string component, string options, string dataset) + { + var schema = ChartCatalog.GetSchema(chartType); + + Assert.Equal(component, schema.Component); + Assert.Equal(options, schema.OptionsType); + Assert.Equal(dataset, schema.DatasetType); + Assert.NotEmpty(schema.CommonInputs); + Assert.NotEmpty(schema.Examples); + } + + [Fact] + public void Radar_Is_Not_Datalabel_Safe() + { + var schema = ChartCatalog.GetSchema("Radar"); + + Assert.False(schema.SupportsDatalabels); + } + + [Theory] + [InlineData("Bubble")] + [InlineData("Radar")] + public void Charts_Without_Plugins_Report_No_Title_Or_Legend_Support(string chartType) + { + var schema = ChartCatalog.GetSchema(chartType); + + Assert.False(schema.SupportsPluginOptions); + Assert.False(schema.SupportsTitleOptions); + Assert.False(schema.SupportsLegendOptions); + } + + [Theory] + [InlineData("Bar")] + [InlineData("Scatter")] + public void Charts_With_Plugins_Report_Title_And_Legend_Support(string chartType) + { + var schema = ChartCatalog.GetSchema(chartType); + + Assert.True(schema.SupportsPluginOptions); + Assert.True(schema.SupportsTitleOptions); + Assert.True(schema.SupportsLegendOptions); + } + + [Theory] + [InlineData("Bar", "numericDatasetsJson")] + [InlineData("Scatter", "pointDatasetsJson")] + [InlineData("Bubble", "pointDatasetsJson")] + public void GetSchema_Includes_Dataset_Examples(string chartType, string exampleKey) + { + var schema = ChartCatalog.GetSchema(chartType); + + Assert.True(schema.Examples.ContainsKey(exampleKey)); + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs new file mode 100644 index 00000000..a0e470c5 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs @@ -0,0 +1,231 @@ +namespace BlazorExpress.ChartJS.MCP.Tests; + +public class ChartExampleGeneratorTests +{ + [Theory] + [InlineData("Bar", "BarChart", "BarChartOptions", "BarChartDataset")] + [InlineData("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset")] + [InlineData("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset")] + [InlineData("Line", "LineChart", "LineChartOptions", "LineChartDataset")] + [InlineData("Pie", "PieChart", "PieChartOptions", "PieChartDataset")] + [InlineData("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset")] + [InlineData("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset")] + [InlineData("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset")] + public void Generate_Includes_Expected_Razor_Types_For_All_Charts(string chartType, string component, string options, string dataset) + { + var generator = new ChartExampleGenerator(); + + var generated = generator.Generate(new ChartGenerationRequest { ChartType = chartType, Title = $"{chartType} Sample" }); + + Assert.Contains($"<{component} @ref=", generated.Code); + Assert.Contains($"private {options}", generated.Code); + Assert.Contains($"new {dataset}", generated.Code); + Assert.Contains("@using BlazorExpress.ChartJS", generated.Code); + } + + [Fact] + public void Generate_Bar_Datalabels_Uses_Plugin() + { + var generator = new ChartExampleGenerator(); + + var generated = generator.Generate(new ChartGenerationRequest { ChartType = "Bar", Datalabels = true }); + + Assert.Contains("ChartDataLabels", generated.Code); + Assert.Contains("chartjs-plugin-datalabels", string.Join(" ", generated.RequiredScripts)); + } + + [Fact] + public void Generate_Radar_Does_Not_Use_Datalabels() + { + var generator = new ChartExampleGenerator(); + + var generated = generator.Generate(new ChartGenerationRequest { ChartType = "Radar", Datalabels = true }); + + Assert.DoesNotContain("ChartDataLabels", generated.Code); + Assert.DoesNotContain("chartjs-plugin-datalabels", string.Join(" ", generated.RequiredScripts)); + } + + [Theory] + [InlineData("Bubble")] + [InlineData("Radar")] + public void Generate_Charts_Without_Plugin_Options_Do_Not_Emit_Plugin_Title(string chartType) + { + var generator = new ChartExampleGenerator(); + + var generated = generator.Generate(new ChartGenerationRequest { ChartType = chartType }); + + Assert.DoesNotContain(".Plugins.Title", generated.Code); + Assert.DoesNotContain(".Plugins.Legend", generated.Code); + } + + [Fact] + public void Generate_Scatter_Uses_Point_Data() + { + var generator = new ChartExampleGenerator(); + + var generated = generator.Generate(new ChartGenerationRequest + { + ChartType = "Scatter", + Datasets = + [ + new() + { + Label = "Samples", + Points = + [ + new ChartPointRequest { X = 1, Y = 2 }, + new ChartPointRequest { X = 3, Y = 4 }, + ], + }, + ], + }); + + Assert.Contains("new List { new(1, 2), new(3, 4)", generated.Code); + } + + [Theory] + [InlineData(null, "new(1, 2, 8)")] + [InlineData(9.0, "new(1, 2, 9)")] + public void Generate_Bubble_Uses_Point_Data_With_Defaultable_Radius(double? radius, string expected) + { + var generator = new ChartExampleGenerator(); + + var generated = generator.Generate(new ChartGenerationRequest + { + ChartType = "Bubble", + Datasets = + [ + new() + { + Label = "Samples", + Points = [new ChartPointRequest { X = 1, Y = 2, R = radius }], + }, + ], + }); + + Assert.Contains(expected, generated.Code); + } + + [Fact] + public void GenerateDashboard_Default_Includes_All_Chart_Components() + { + var generator = new ChartExampleGenerator(); + + var generated = generator.GenerateDashboard(new ChartDashboardRequest()); + + foreach (var definition in ChartCatalog.All) + Assert.Contains($"<{definition.ComponentName} @ref=", generated.Code); + + Assert.Equal(8, generated.ChartTypes.Count); + } + + [Fact] + public void GenerateDashboard_Custom_Uses_Unique_Field_Names_And_Deduplicated_Scripts() + { + var generator = new ChartExampleGenerator(); + + var generated = generator.GenerateDashboard(new ChartDashboardRequest + { + Charts = + [ + new ChartGenerationRequest { ChartType = "Bar", Datalabels = true }, + new ChartGenerationRequest { ChartType = "Bar", Datalabels = true }, + ], + }); + + Assert.Contains("barChart1", generated.Code); + Assert.Contains("barChart2", generated.Code); + Assert.Equal(generated.RequiredScripts.Count, generated.RequiredScripts.Distinct().Count()); + } + + [Fact] + public void Generated_Single_And_Dashboard_Pages_Compile() + { + using var workspace = new RazorCompileWorkspace(); + var generator = new ChartExampleGenerator(); + var single = generator.Generate(new ChartGenerationRequest { ChartType = "Bubble", Title = "Bubble Compile" }); + var dashboard = generator.GenerateDashboard(new ChartDashboardRequest()); + + workspace.AddPage(single.PageName, single.Code); + workspace.AddPage(dashboard.PageName, dashboard.Code); + + workspace.Build(); + } + + private sealed class RazorCompileWorkspace : IDisposable + { + private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-compile-{Guid.NewGuid():N}"); + + public RazorCompileWorkspace() + { + Directory.CreateDirectory(root); + Directory.CreateDirectory(Path.Combine(root, "Pages")); + + File.WriteAllText(Path.Combine(root, "CompileHost.csproj"), $$""" + + + net10.0 + enable + enable + + + + + + + """); + + File.WriteAllText(Path.Combine(root, "_Imports.razor"), "@using BlazorExpress.ChartJS" + Environment.NewLine); + } + + public void AddPage(string pageName, string code) => + File.WriteAllText(Path.Combine(root, "Pages", $"{pageName}.razor"), code); + + public void Build() + { + using var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = "dotnet"; + process.StartInfo.ArgumentList.Add("build"); + process.StartInfo.ArgumentList.Add(Path.Combine(root, "CompileHost.csproj")); + process.StartInfo.ArgumentList.Add("--nologo"); + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.UseShellExecute = false; + process.Start(); + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + var exited = process.WaitForExit(120_000); + if (!exited) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(30_000); + } + + var output = outputTask.GetAwaiter().GetResult(); + var error = errorTask.GetAwaiter().GetResult(); + + Assert.True(exited && process.ExitCode == 0, output + Environment.NewLine + error); + } + + public void Dispose() + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "BlazorExpress.ChartJS.sln"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not locate repository root."); + } + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs new file mode 100644 index 00000000..d67dfa8a --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; + +namespace BlazorExpress.ChartJS.MCP.Tests; + +public class ChartJsMcpToolsTests +{ + [Fact] + public void GenerateChartExample_Returns_Structured_Error_For_Malformed_Labels() + { + var json = ChartJsMcpTools.GenerateChartExample("Bar", labelsJson: "["); + + AssertToolError(json, "labelsJson"); + } + + [Fact] + public void GenerateChartExample_Returns_Structured_Error_For_Malformed_Datasets() + { + var json = ChartJsMcpTools.GenerateChartExample("Bar", datasetsJson: """{"label":"Bad"}"""); + + AssertToolError(json, "datasetsJson"); + } + + [Fact] + public void GenerateChartExample_Returns_Structured_Error_For_Unsupported_Chart() + { + var json = ChartJsMcpTools.GenerateChartExample("Nope"); + + AssertToolError(json, "chartType"); + } + + [Fact] + public void ApplyProjectIntegration_Returns_Structured_Error_For_Bad_Plan_Json() + { + var json = ChartJsMcpTools.ApplyProjectIntegration("["); + + AssertToolError(json); + } + + [Fact] + public void PreviewProjectIntegration_Returns_Structured_Error_For_Invalid_Target_Project() + { + var json = ChartJsMcpTools.PreviewProjectIntegration(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), "Bar"); + + AssertToolError(json, "targetProjectPath"); + } + + [Fact] + public void PreviewProjectIntegration_Returns_Structured_Error_For_Ambiguous_Folder() + { + using var workspace = new TemporaryWorkspace(); + var firstProject = workspace.CreateWebAssemblyProject(Path.Combine("src", "FirstApp"), "FirstApp"); + var secondProject = workspace.CreateWebAssemblyProject(Path.Combine("src", "SecondApp"), "SecondApp"); + + var json = ChartJsMcpTools.PreviewProjectIntegration(workspace.Root, "Bar"); + + AssertToolError(json, "targetProjectPath"); + Assert.Contains(EscapeJsonPath(Path.Combine(firstProject, "FirstApp.csproj")), json); + Assert.Contains(EscapeJsonPath(Path.Combine(secondProject, "SecondApp.csproj")), json); + } + + [Fact] + public void PreviewProjectIntegration_Resolves_Nested_Single_Blazor_Project() + { + using var workspace = new TemporaryWorkspace(); + var project = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App"); + + var json = ChartJsMcpTools.PreviewProjectIntegration(workspace.Root, "Bar"); + + Assert.DoesNotContain("\"success\": false", json); + Assert.Contains(EscapeJsonPath(Path.Combine(project, "App.csproj")), json); + } + + [Fact] + public void PreviewDashboardIntegration_Uses_Same_Project_Resolver() + { + using var workspace = new TemporaryWorkspace(); + var project = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App"); + + var json = ChartJsMcpTools.PreviewDashboardIntegration(workspace.Root, title: "Charts"); + + Assert.DoesNotContain("\"success\": false", json); + Assert.Contains("ChartDashboardPage.razor", json); + Assert.Contains(EscapeJsonPath(Path.Combine(project, "App.csproj")), json); + } + + [Fact] + public void GenerateChartExample_Accepts_Color_String() + { + var json = ChartJsMcpTools.GenerateChartExample( + "Bar", + datasetsJson: """[{"label":"Revenue","data":[1,2],"backgroundColor":"#123456","borderColor":"#654321"}]"""); + + Assert.DoesNotContain("\"success\": false", json); + Assert.Contains("#123456", json); + Assert.Contains("#654321", json); + } + + [Fact] + public void GenerateChartExample_Accepts_Color_Array() + { + var json = ChartJsMcpTools.GenerateChartExample( + "Line", + datasetsJson: """[{"label":"Revenue","data":[1,2],"backgroundColor":["#123456"],"borderColor":["#654321"]}]"""); + + Assert.DoesNotContain("\"success\": false", json); + Assert.Contains("#123456", json); + Assert.Contains("#654321", json); + } + + [Fact] + public void GenerateChartDashboard_Returns_Structured_Error_For_Invalid_Child_Spec() + { + var json = ChartJsMcpTools.GenerateChartDashboard(chartsJson: """[{"title":"Missing type"}]"""); + + AssertToolError(json, "chartsJson[0]"); + } + + [Fact] + public void GenerateChartDashboard_Accepts_Custom_Chart_Specs() + { + var json = ChartJsMcpTools.GenerateChartDashboard(chartsJson: """ + [ + { + "chartType": "Scatter", + "datasets": [ + { + "label": "Samples", + "points": [{ "x": 1, "y": 2 }], + "backgroundColor": "#123456" + } + ] + } + ] + """); + + Assert.DoesNotContain("\"success\": false", json); + Assert.Contains("ScatterChart", json); + Assert.Contains("new(1, 2)", json); + } + + private static void AssertToolError(string json, string? expectedField = null) + { + using var document = JsonDocument.Parse(json); + Assert.False(document.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("invalid_input", document.RootElement.GetProperty("error").GetProperty("code").GetString()); + + if (expectedField is not null) + Assert.Equal(expectedField, document.RootElement.GetProperty("error").GetProperty("field").GetString()); + } + + private static string EscapeJsonPath(string path) => + path.Replace("\\", "\\\\", StringComparison.Ordinal); + + private sealed class TemporaryWorkspace : IDisposable + { + private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tool-tests-{Guid.NewGuid():N}"); + + public string Root => root; + + public string CreateWebAssemblyProject(string relativePath, string projectName) + { + var projectRoot = Path.Combine(root, relativePath); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "wwwroot")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Pages")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Shared")); + + File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """ + + + net10.0 + + + """); + File.WriteAllText(Path.Combine(projectRoot, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine); + File.WriteAllText(Path.Combine(projectRoot, "wwwroot", "index.html"), """ + + +
+ + + """); + File.WriteAllText(Path.Combine(projectRoot, "Shared", "NavMenu.razor"), """ + + """); + + return projectRoot; + } + + public void Dispose() + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs b/BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs new file mode 100644 index 00000000..abc7c980 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.TestHost; + +namespace BlazorExpress.ChartJS.MCP.Tests; + +public class McpApplicationTests +{ + [Fact] + public void ParseOptions_NoArgs_Selects_Http() + { + var options = McpApplication.ParseOptions([]); + + Assert.Equal(McpTransportMode.Http, options.Transport); + Assert.Empty(options.RemainingArgs); + } + + [Theory] + [InlineData("--stdio")] + [InlineData("--transport", "stdio")] + public void ParseOptions_StdioArgs_Select_Stdio(params string[] args) + { + var options = McpApplication.ParseOptions(args); + + Assert.Equal(McpTransportMode.Stdio, options.Transport); + Assert.Empty(options.RemainingArgs); + } + + [Fact] + public void ParseOptions_HttpArgs_Select_Http() + { + var options = McpApplication.ParseOptions(["--http"]); + + Assert.Equal(McpTransportMode.Http, options.Transport); + Assert.Empty(options.RemainingArgs); + } + + [Fact] + public void CreateHttpApp_Defaults_To_Localhost_5000() + { + using var app = McpApplication.CreateHttpApp([], "Development"); + + Assert.Contains(McpApplication.DefaultHttpUrl, app.Urls); + } + + [Fact] + public async Task Health_Returns_Success() + { + await using var app = CreateTestHttpApp("Development"); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync(McpApplication.HealthPath); + + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + public async Task Development_Mcp_Allows_Missing_Bearer_Token() + { + await using var app = CreateTestHttpApp("Development"); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync(McpApplication.McpPath); + + Assert.NotEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public void Production_Http_Requires_Token_At_Startup() + { + var exception = Assert.Throws(() => CreateTestHttpApp("Production")); + + Assert.Contains(McpApplication.TokenConfigurationKey, exception.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("wrong-token")] + public async Task Production_Mcp_Rejects_Missing_Or_Wrong_Bearer_Token(string? token) + { + await using var app = CreateTestHttpApp("Production", "correct-token"); + await app.StartAsync(); + using var request = new HttpRequestMessage(HttpMethod.Get, McpApplication.McpPath); + if (token is not null) + request.Headers.Authorization = new("Bearer", token); + + var response = await app.GetTestClient().SendAsync(request); + + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Production_Mcp_Allows_Correct_Bearer_Token() + { + await using var app = CreateTestHttpApp("Production", "correct-token"); + await app.StartAsync(); + using var request = new HttpRequestMessage(HttpMethod.Get, McpApplication.McpPath); + request.Headers.Authorization = new("Bearer", "correct-token"); + + var response = await app.GetTestClient().SendAsync(request); + + Assert.NotEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } + + private static Microsoft.AspNetCore.Builder.WebApplication CreateTestHttpApp(string environmentName, string? token = null) + { + var configuration = token is null + ? null + : new Dictionary { [McpApplication.TokenConfigurationKey] = token }; + + return McpApplication.CreateHttpApp( + [], + environmentName, + webHost => webHost.UseTestServer(), + configuration); + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs new file mode 100644 index 00000000..6c95f0b4 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs @@ -0,0 +1,292 @@ +namespace BlazorExpress.ChartJS.MCP.Tests; + +public class ProjectIntegrationServiceTests +{ + [Fact] + public void Preview_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var plan = service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = projectDirectory, + Chart = new ChartGenerationRequest { ChartType = "Line", Title = "Sales Trend", Route = "/charts/sales-trend" }, + }); + + Assert.Equal("BlazorWebAssembly", plan.DetectedHostModel); + Assert.NotEmpty(plan.PlanHash); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("LineChartPage.razor", StringComparison.Ordinal)); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("Sample.csproj", StringComparison.Ordinal) && x.NewContent.Contains("BlazorExpress.ChartJS", StringComparison.Ordinal)); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("_Imports.razor", StringComparison.Ordinal) && x.NewContent.Contains("@using BlazorExpress.ChartJS", StringComparison.Ordinal)); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("index.html", StringComparison.Ordinal) && x.NewContent.Contains("chart.umd.js", StringComparison.Ordinal)); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("NavMenu.razor", StringComparison.Ordinal) && x.NewContent.Contains("charts/sales-trend", StringComparison.Ordinal)); + Assert.False(File.Exists(Path.Combine(projectDirectory, "Pages", "LineChartPage.razor"))); + } + + [Fact] + public void Preview_Accepts_Exact_Project_File_Path() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(); + var projectFilePath = Path.Combine(projectDirectory, "Sample.csproj"); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var plan = service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = projectFilePath, + Chart = new ChartGenerationRequest { ChartType = "Line" }, + }); + + Assert.Equal(projectFilePath, plan.ProjectFilePath); + } + + [Fact] + public void Preview_Resolves_Repo_Folder_With_One_Nested_Blazor_App() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App"); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var plan = service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = workspace.Root, + Chart = new ChartGenerationRequest { ChartType = "Line" }, + }); + + Assert.Equal(Path.Combine(projectDirectory, "App.csproj"), plan.ProjectFilePath); + } + + [Fact] + public void Preview_Rejects_Repo_Folder_With_Multiple_Blazor_App_Candidates() + { + using var workspace = new TemporaryWorkspace(); + var firstProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "FirstApp"), "FirstApp"); + var secondProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "SecondApp"), "SecondApp"); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var exception = Assert.Throws(() => service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = workspace.Root, + Chart = new ChartGenerationRequest { ChartType = "Line" }, + })); + + Assert.Equal("targetProjectPath", exception.Field); + Assert.Contains(Path.Combine(firstProjectDirectory, "FirstApp.csproj"), exception.Details); + Assert.Contains(Path.Combine(secondProjectDirectory, "SecondApp.csproj"), exception.Details); + } + + [Fact] + public void Preview_Rejects_Folder_With_Only_NonBlazor_Projects() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateLibraryProject(Path.Combine("src", "Library"), "Library"); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var exception = Assert.Throws(() => service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = workspace.Root, + Chart = new ChartGenerationRequest { ChartType = "Line" }, + })); + + Assert.Equal("targetProjectPath", exception.Field); + Assert.Contains(Path.Combine(projectDirectory, "Library.csproj"), exception.Details); + } + + [Fact] + public void Preview_Resolves_Solution_With_One_Blazor_App() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App"); + var libraryDirectory = workspace.CreateLibraryProject(Path.Combine("src", "Library"), "Library"); + var solutionPath = workspace.CreateSolution("Sample.sln", Path.Combine(projectDirectory, "App.csproj"), Path.Combine(libraryDirectory, "Library.csproj")); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var plan = service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = solutionPath, + Chart = new ChartGenerationRequest { ChartType = "Line" }, + }); + + Assert.Equal(Path.Combine(projectDirectory, "App.csproj"), plan.ProjectFilePath); + } + + [Fact] + public void Preview_Rejects_Solution_With_Multiple_Blazor_App_Candidates() + { + using var workspace = new TemporaryWorkspace(); + var firstProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "FirstApp"), "FirstApp"); + var secondProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "SecondApp"), "SecondApp"); + var solutionPath = workspace.CreateSolution("Sample.sln", Path.Combine(firstProjectDirectory, "FirstApp.csproj"), Path.Combine(secondProjectDirectory, "SecondApp.csproj")); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var exception = Assert.Throws(() => service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = solutionPath, + Chart = new ChartGenerationRequest { ChartType = "Line" }, + })); + + Assert.Equal("targetProjectPath", exception.Field); + Assert.Contains(Path.Combine(firstProjectDirectory, "FirstApp.csproj"), exception.Details); + Assert.Contains(Path.Combine(secondProjectDirectory, "SecondApp.csproj"), exception.Details); + } + + [Fact] + public void Apply_Writes_Files_For_Matching_Preview() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + var plan = service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = projectDirectory, + Chart = new ChartGenerationRequest { ChartType = "Pie", Title = "Sales Mix" }, + }); + + var result = service.Apply(plan); + + Assert.NotEmpty(result.WrittenFiles); + Assert.True(File.Exists(Path.Combine(projectDirectory, "Pages", "PieChartPage.razor"))); + } + + [Fact] + public void Apply_Rejects_Stale_Preview() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + var plan = service.Preview(new PreviewIntegrationRequest + { + TargetProjectPath = projectDirectory, + Chart = new ChartGenerationRequest { ChartType = "Bar" }, + }); + + File.AppendAllText(Path.Combine(projectDirectory, "_Imports.razor"), "@using Changed"); + + Assert.Throws(() => service.Apply(plan)); + } + + [Fact] + public void PreviewDashboard_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + + var plan = service.PreviewDashboard(new PreviewDashboardIntegrationRequest + { + TargetProjectPath = projectDirectory, + Dashboard = new ChartDashboardRequest { Title = "Charts", Route = "/charts/dashboard" }, + }); + + Assert.Equal("BlazorWebAssembly", plan.DetectedHostModel); + Assert.NotEmpty(plan.PlanHash); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("ChartDashboardPage.razor", StringComparison.Ordinal)); + Assert.Contains(plan.Edits, x => x.Path.EndsWith("NavMenu.razor", StringComparison.Ordinal) && x.NewContent.Contains("charts/dashboard", StringComparison.Ordinal)); + Assert.False(File.Exists(Path.Combine(projectDirectory, "Pages", "ChartDashboardPage.razor"))); + } + + [Fact] + public void ApplyDashboard_Writes_Files_For_Matching_Preview() + { + using var workspace = new TemporaryWorkspace(); + var projectDirectory = workspace.CreateWebAssemblyProject(); + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + var plan = service.PreviewDashboard(new PreviewDashboardIntegrationRequest + { + TargetProjectPath = projectDirectory, + Dashboard = new ChartDashboardRequest { Title = "Charts" }, + }); + + var result = service.Apply(plan); + + Assert.NotEmpty(result.WrittenFiles); + Assert.True(File.Exists(Path.Combine(projectDirectory, "Pages", "ChartDashboardPage.razor"))); + } + + private sealed class TemporaryWorkspace : IDisposable + { + private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tests-{Guid.NewGuid():N}"); + + public string Root => root; + + public string CreateWebAssemblyProject(string relativePath = "", string projectName = "Sample") + { + var projectRoot = string.IsNullOrWhiteSpace(relativePath) ? root : Path.Combine(root, relativePath); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(Path.Combine(projectRoot, "wwwroot")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Pages")); + Directory.CreateDirectory(Path.Combine(projectRoot, "Shared")); + + File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """ + + + net10.0 + + + """); + File.WriteAllText(Path.Combine(projectRoot, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine); + File.WriteAllText(Path.Combine(projectRoot, "wwwroot", "index.html"), """ + + +
+ + + """); + File.WriteAllText(Path.Combine(projectRoot, "Shared", "NavMenu.razor"), """ + + """); + + return projectRoot; + } + + public string CreateLibraryProject(string relativePath, string projectName) + { + var projectRoot = Path.Combine(root, relativePath); + Directory.CreateDirectory(projectRoot); + File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """ + + + net10.0 + + + """); + + return projectRoot; + } + + public string CreateSolution(string fileName, params string[] projectPaths) + { + Directory.CreateDirectory(root); + var solutionPath = Path.Combine(root, fileName); + var lines = new List + { + "Microsoft Visual Studio Solution File, Format Version 12.00", + "# Visual Studio Version 17", + }; + + foreach (var projectPath in projectPaths) + { + var relativePath = Path.GetRelativePath(root, projectPath); + var projectName = Path.GetFileNameWithoutExtension(projectPath); + lines.Add($"Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"{projectName}\", \"{relativePath}\", \"{{{Guid.NewGuid():D}}}\""); + lines.Add("EndProject"); + } + + lines.Add("Global"); + lines.Add("EndGlobal"); + File.WriteAllText(solutionPath, string.Join(Environment.NewLine, lines)); + + return solutionPath; + } + + public void Dispose() + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/Usings.cs b/BlazorExpress.ChartJS.MCP.Tests/Usings.cs new file mode 100644 index 00000000..9bfc7113 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/Usings.cs @@ -0,0 +1,2 @@ +global using BlazorExpress.ChartJS.MCP; +global using Xunit; diff --git a/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj new file mode 100644 index 00000000..ee2412d9 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj @@ -0,0 +1,37 @@ + + + + Exe + net10.0 + enable + enable + + true + blazorexpress-chartjs-mcp + BlazorExpress.ChartJS.MCP + 0.1.0 + 0.1.0 + Apache-2.0 + https://chartjs.blazorexpress.com + https://github.com/BlazorExpress/BlazorExpress.ChartJS + README.md + Model Context Protocol server for generating and integrating BlazorExpress.ChartJS charts. + Vikram Reddy + Blazor Express + + + + + + + + + + + + + + + + + diff --git a/BlazorExpress.ChartJS.MCP/ChartCatalog.cs b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs new file mode 100644 index 00000000..2980b005 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs @@ -0,0 +1,171 @@ +using System.ComponentModel; +using System.Reflection; + +namespace BlazorExpress.ChartJS.MCP; + +public static class ChartCatalog +{ + private static readonly IReadOnlyDictionary DefinitionsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["bar"] = Create("Bar", "BarChart", "BarChartOptions", "BarChartDataset", typeof(BarChart), typeof(BarChartOptions), typeof(BarChartDataset), true, true, true), + ["bubble"] = Create("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset", typeof(BubbleChart), typeof(BubbleChartOptions), typeof(BubbleChartDataset), true, false, false), + ["doughnut"] = Create("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset", typeof(DoughnutChart), typeof(DoughnutChartOptions), typeof(DoughnutChartDataset), true, false, false), + ["line"] = Create("Line", "LineChart", "LineChartOptions", "LineChartDataset", typeof(LineChart), typeof(LineChartOptions), typeof(LineChartDataset), true, true, false), + ["pie"] = Create("Pie", "PieChart", "PieChartOptions", "PieChartDataset", typeof(PieChart), typeof(PieChartOptions), typeof(PieChartDataset), true, false, false), + ["polararea"] = Create("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false), + ["polar-area"] = Create("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false), + ["radar"] = Create("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset", typeof(RadarChart), typeof(RadarChartOptions), typeof(RadarChartDataset), false, false, false), + ["scatter"] = Create("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset", typeof(ScatterChart), typeof(ScatterChartOptions), typeof(ScatterChartDataset), true, false, false), + }; + + public static IReadOnlyList All { get; } = DefinitionsByKey.Values + .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .OrderBy(x => x.Name) + .ToList(); + + public static ChartDefinition Get(string chartType) + { + var key = NormalizeKey(chartType); + + if (DefinitionsByKey.TryGetValue(key, out var definition)) + return definition; + + throw new ArgumentException($"Unsupported chart type '{chartType}'. Supported values are: {string.Join(", ", All.Select(x => x.Name))}.", nameof(chartType)); + } + + public static ChartGenerationSchema GetSchema(string chartType) + { + var definition = Get(chartType); + + return new ChartGenerationSchema( + ChartType: definition.Name, + Component: definition.ComponentName, + OptionsType: definition.OptionsTypeName, + DatasetType: definition.DatasetTypeName, + SupportsDatalabels: definition.SupportsDatalabels, + SupportsStacking: definition.SupportsStacking, + SupportsOrientation: definition.SupportsOrientation, + SupportsPluginOptions: definition.SupportsPluginOptions, + SupportsTitleOptions: definition.SupportsTitleOptions, + SupportsLegendOptions: definition.SupportsLegendOptions, + CommonInputs: + [ + "title", + "route", + "pageName", + "labelsJson", + "datasetsJson", + "width", + "height", + "legendPosition", + "datalabels" + ], + ChartSpecificInputs: GetChartSpecificInputs(definition), + Examples: GetExamples(definition), + Metadata: GetTypeMetadata(definition)); + } + + private static ChartDefinition Create( + string name, + string componentName, + string optionsTypeName, + string datasetTypeName, + Type componentType, + Type optionsType, + Type datasetType, + bool supportsDatalabels, + bool supportsStacking, + bool supportsOrientation) + { + var pluginsProperty = optionsType.GetProperty("Plugins", BindingFlags.Public | BindingFlags.Instance); + var supportsPluginOptions = pluginsProperty is not null; + var pluginType = pluginsProperty?.PropertyType; + var supportsTitleOptions = pluginType?.GetProperty("Title", BindingFlags.Public | BindingFlags.Instance) is not null; + var supportsLegendOptions = pluginType?.GetProperty("Legend", BindingFlags.Public | BindingFlags.Instance) is not null; + var supportsScales = optionsType.GetProperty("Scales", BindingFlags.Public | BindingFlags.Instance) is not null; + + return new ChartDefinition( + name, + componentName, + optionsTypeName, + datasetTypeName, + componentType, + optionsType, + datasetType, + supportsDatalabels, + supportsStacking, + supportsOrientation, + supportsPluginOptions, + supportsTitleOptions, + supportsLegendOptions, + supportsScales); + } + + private static IReadOnlyList GetChartSpecificInputs(ChartDefinition definition) + { + var inputs = new List(); + + if (definition.SupportsStacking) + inputs.Add("stacked"); + + if (definition.SupportsOrientation) + inputs.Add("orientation: 'vertical' or 'horizontal'"); + + if (definition.Name is "Bubble") + inputs.Add("datasetsJson data points may include x, y, r"); + else if (definition.Name is "Scatter") + inputs.Add("datasetsJson data points may include x, y"); + else + inputs.Add("datasetsJson data values are numeric"); + + return inputs; + } + + private static IReadOnlyDictionary GetTypeMetadata(ChartDefinition definition) => + new Dictionary + { + ["componentDescription"] = GetDescription(definition.ComponentType), + ["optionsProperties"] = GetPublicPropertyMetadata(definition.OptionsType), + ["datasetProperties"] = GetPublicPropertyMetadata(definition.DatasetType), + }; + + private static IReadOnlyDictionary GetExamples(ChartDefinition definition) + { + var common = new Dictionary + { + ["labelsJson"] = """["Jan","Feb","Mar"]""", + ["numericDatasetsJson"] = """[{"label":"Revenue","data":[12,19,7],"backgroundColor":"rgba(54, 162, 235, 0.7)","borderColor":["rgba(54, 162, 235, 1)"]}]""", + }; + + if (definition.Name is "Scatter" or "Bubble") + common["pointDatasetsJson"] = """[{"label":"Samples","points":[{"x":1,"y":12,"r":6},{"x":2,"y":19,"r":8}],"backgroundColor":"rgba(255, 99, 132, 0.7)"}]"""; + + return common; + } + + private static string? GetDescription(MemberInfo member) => + member.GetCustomAttribute()?.Description; + + private static IReadOnlyList GetPublicPropertyMetadata(Type type) => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(x => x.GetMethod is not null) + .Select(x => new PropertyMetadata( + Name: x.Name, + Type: x.PropertyType.Name, + Description: x.GetCustomAttribute()?.Description, + DefaultValue: x.GetCustomAttribute()?.Value?.ToString())) + .OrderBy(x => x.Name) + .ToList(); + + private static string NormalizeKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("A chart type is required.", nameof(value)); + + return + value.Replace(" ", "", StringComparison.Ordinal) + .Replace("_", "", StringComparison.Ordinal) + .ToLowerInvariant(); + } +} diff --git a/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs b/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs new file mode 100644 index 00000000..e961025b --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs @@ -0,0 +1,438 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace BlazorExpress.ChartJS.MCP; + +public sealed class ChartExampleGenerator +{ + private static readonly string[] DefaultColors = + [ + "rgba(54, 162, 235, 0.7)", + "rgba(255, 99, 132, 0.7)", + "rgba(255, 205, 86, 0.7)", + "rgba(75, 192, 192, 0.7)", + "rgba(153, 102, 255, 0.7)", + "rgba(255, 159, 64, 0.7)" + ]; + + public GeneratedChartExample Generate(ChartGenerationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var model = CreateChartModel(request, identifierSuffix: ""); + var code = new StringBuilder(); + + code.AppendLine($"@page \"{model.Route}\""); + code.AppendLine("@using BlazorExpress.ChartJS"); + code.AppendLine(); + code.AppendLine($"

{EscapeHtml(model.Title)}

"); + code.AppendLine(); + AppendChartMarkup(code, model, indent: ""); + code.AppendLine(); + code.AppendLine("@code {"); + AppendChartFields(code, model); + code.AppendLine(); + code.AppendLine(" protected override void OnInitialized()"); + code.AppendLine(" {"); + AppendChartInitialization(code, model); + code.AppendLine(" }"); + code.AppendLine(); + AppendAfterRender(code, [model]); + code.AppendLine("}"); + + return new GeneratedChartExample( + ChartType: model.Definition.Name, + Route: model.Route, + PageName: model.PageName, + Code: code.ToString(), + RequiredScripts: RequiredScripts([model])); + } + + public GeneratedChartDashboard GenerateDashboard(ChartDashboardRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var chartRequests = request.Charts is { Count: > 0 } + ? request.Charts + : ChartCatalog.All.Select(x => new ChartGenerationRequest + { + ChartType = x.Name, + Title = $"{x.Name} Example", + Datalabels = false, + }).ToList(); + + var title = string.IsNullOrWhiteSpace(request.Title) ? "Chart Dashboard" : request.Title.Trim(); + var route = NormalizeRoute(request.Route, "/charts/dashboard", "route"); + var pageName = MakeIdentifier(string.IsNullOrWhiteSpace(request.PageName) ? "ChartDashboardPage" : request.PageName); + var models = chartRequests.Select((chart, index) => CreateChartModel(chart, (index + 1).ToString(CultureInfo.InvariantCulture))).ToList(); + + var code = new StringBuilder(); + code.AppendLine($"@page \"{route}\""); + code.AppendLine("@using BlazorExpress.ChartJS"); + code.AppendLine(); + code.AppendLine($"

{EscapeHtml(title)}

"); + code.AppendLine(); + code.AppendLine("
"); + foreach (var model in models) + { + code.AppendLine("
"); + code.AppendLine($"

{EscapeHtml(model.Title)}

"); + AppendChartMarkup(code, model, indent: " "); + code.AppendLine("
"); + } + code.AppendLine("
"); + code.AppendLine(); + code.AppendLine("@code {"); + foreach (var model in models) + { + AppendChartFields(code, model); + code.AppendLine(); + } + code.AppendLine(" protected override void OnInitialized()"); + code.AppendLine(" {"); + foreach (var model in models) + AppendChartInitialization(code, model); + code.AppendLine(" }"); + code.AppendLine(); + AppendAfterRender(code, models); + code.AppendLine("}"); + + return new GeneratedChartDashboard( + Route: route, + PageName: pageName, + Code: code.ToString(), + ChartTypes: models.Select(x => x.Definition.Name).ToList(), + RequiredScripts: RequiredScripts(models)); + } + + private static ChartRenderModel CreateChartModel(ChartGenerationRequest request, string identifierSuffix) + { + var definition = ChartCatalog.Get(request.ChartType); + var title = string.IsNullOrWhiteSpace(request.Title) ? $"{definition.Name} Chart" : request.Title.Trim(); + var pageName = MakeIdentifier(string.IsNullOrWhiteSpace(request.PageName) ? $"{definition.Name}ChartPage" : request.PageName); + var route = NormalizeRoute(request.Route, $"/charts/{ToKebabCase(definition.Name)}", "route"); + var labels = NormalizeLabels(request.Labels, definition); + var datasets = NormalizeDatasets(request.Datasets, labels, definition); + var width = NormalizeDimension(request.Width, 700, "width"); + var height = NormalizeDimension(request.Height, 400, "height"); + var datalabels = request.Datalabels == true && definition.SupportsDatalabels; + var legendPosition = NormalizeLegendPosition(request.LegendPosition); + var stacked = request.Stacked == true && definition.SupportsStacking; + var horizontal = definition.SupportsOrientation && string.Equals(request.Orientation, "horizontal", StringComparison.OrdinalIgnoreCase); + var suffix = string.IsNullOrWhiteSpace(identifierSuffix) ? "" : identifierSuffix; + + return new ChartRenderModel( + Definition: definition, + Title: title, + Route: route, + PageName: pageName, + Labels: labels, + Datasets: datasets, + Width: width, + Height: height, + IncludeDatalabels: datalabels, + LegendPosition: legendPosition, + Stacked: stacked, + Horizontal: horizontal, + ChartField: $"{LowerFirst(definition.ComponentName)}{suffix}", + OptionsField: $"{LowerFirst(definition.OptionsTypeName)}{suffix}", + DataField: $"chartData{suffix}"); + } + + private static void AppendChartMarkup(StringBuilder code, ChartRenderModel model, string indent) => + code.AppendLine($"{indent}<{model.Definition.ComponentName} @ref=\"{model.ChartField}\" Width=\"{model.Width}\" Height=\"{model.Height}\" />"); + + private static void AppendChartFields(StringBuilder code, ChartRenderModel model) + { + code.AppendLine($" private {model.Definition.ComponentName} {model.ChartField} = default!;"); + code.AppendLine($" private {model.Definition.OptionsTypeName} {model.OptionsField} = default!;"); + code.AppendLine($" private ChartData {model.DataField} = default!;"); + } + + private static void AppendChartInitialization(StringBuilder code, ChartRenderModel model) + { + code.AppendLine($" {model.DataField} = new ChartData"); + code.AppendLine(" {"); + code.AppendLine($" Labels = new List {{ {string.Join(", ", model.Labels.Select(ToCSharpString))} }},"); + code.AppendLine(" Datasets = new List"); + code.AppendLine(" {"); + foreach (var dataset in model.Datasets) + AppendDataset(code, model.Definition, dataset, model.Labels.Count, model.Stacked); + code.AppendLine(" },"); + code.AppendLine(" };"); + code.AppendLine(); + code.AppendLine($" {model.OptionsField} = new {model.Definition.OptionsTypeName}"); + code.AppendLine(" {"); + code.AppendLine(" Responsive = true,"); + code.AppendLine(" MaintainAspectRatio = false,"); + if (model.Definition.Name is "Bar" && model.Horizontal) + code.AppendLine(" IndexAxis = \"y\","); + code.AppendLine(" };"); + code.AppendLine(); + AppendOptionsCustomization(code, model); + } + + private static void AppendAfterRender(StringBuilder code, IReadOnlyList models) + { + code.AppendLine(" protected override async Task OnAfterRenderAsync(bool firstRender)"); + code.AppendLine(" {"); + code.AppendLine(" if (firstRender)"); + code.AppendLine(" {"); + foreach (var model in models) + { + if (model.IncludeDatalabels) + code.AppendLine($" await {model.ChartField}.InitializeAsync({model.DataField}, {model.OptionsField}, new string[] {{ \"ChartDataLabels\" }});"); + else + code.AppendLine($" await {model.ChartField}.InitializeAsync({model.DataField}, {model.OptionsField});"); + } + code.AppendLine(" }"); + code.AppendLine(); + code.AppendLine(" await base.OnAfterRenderAsync(firstRender);"); + code.AppendLine(" }"); + } + + private static void AppendDataset(StringBuilder code, ChartDefinition definition, ChartDatasetRequest dataset, int labelCount, bool stacked) + { + var colors = NormalizeColors(dataset.BackgroundColor, labelCount); + var borderColors = NormalizeColors(dataset.BorderColor, labelCount); + + code.AppendLine($" new {definition.DatasetTypeName}"); + code.AppendLine(" {"); + code.AppendLine($" Label = {ToCSharpString(dataset.Label ?? "Dataset")},"); + + if (definition.Name is "Bubble") + { + var points = NormalizePoints(dataset, labelCount, includeRadius: true); + code.AppendLine($" Data = new List {{ {string.Join(", ", points.Select(x => $"new({FormatNumber(x.X)}, {FormatNumber(x.Y)}, {FormatNumber(x.R ?? 8)})"))} }},"); + } + else if (definition.Name is "Scatter") + { + var points = NormalizePoints(dataset, labelCount, includeRadius: false); + code.AppendLine($" Data = new List {{ {string.Join(", ", points.Select(x => $"new({FormatNumber(x.X)}, {FormatNumber(x.Y)})"))} }},"); + } + else + { + var values = NormalizeData(dataset.Data, labelCount); + code.AppendLine($" Data = new List {{ {string.Join(", ", values.Select(FormatNullableNumber))} }},"); + } + + AppendColorProperty(code, definition, "BackgroundColor", colors); + AppendColorProperty(code, definition, "BorderColor", borderColors); + + if (definition.Name is "Bar") + code.AppendLine(" BorderWidth = new List { 1 },"); + + if (stacked && !string.IsNullOrWhiteSpace(dataset.Stack) && definition.Name is "Bar") + code.AppendLine($" Stack = {ToCSharpString(dataset.Stack)},"); + + code.AppendLine(" },"); + } + + private static void AppendOptionsCustomization(StringBuilder code, ChartRenderModel model) + { + var definition = model.Definition; + + if (definition.SupportsTitleOptions) + { + code.AppendLine($" {model.OptionsField}.Plugins.Title!.Text = {ToCSharpString(model.Title)};"); + code.AppendLine($" {model.OptionsField}.Plugins.Title.Display = true;"); + } + + if (definition.SupportsLegendOptions) + code.AppendLine($" {model.OptionsField}.Plugins.Legend.Position = {ToCSharpString(model.LegendPosition)};"); + + if (model.Stacked && definition.SupportsScales) + { + code.AppendLine($" {model.OptionsField}.Scales.X!.Stacked = true;"); + code.AppendLine($" {model.OptionsField}.Scales.Y!.Stacked = true;"); + } + } + + private static void AppendColorProperty(StringBuilder code, ChartDefinition definition, string propertyName, IReadOnlyList colors) + { + var propertyType = definition.DatasetType.GetProperty(propertyName)?.PropertyType; + if (propertyType == typeof(string)) + { + code.AppendLine($" {propertyName} = {ToCSharpString(colors.FirstOrDefault() ?? DefaultColors[0])},"); + return; + } + + code.AppendLine($" {propertyName} = new List {{ {string.Join(", ", colors.Select(ToCSharpString))} }},"); + } + + private static IReadOnlyList RequiredScripts(IReadOnlyList models) + { + var scripts = new List + { + "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js", + }; + + if (models.Any(x => x.IncludeDatalabels)) + scripts.Add("https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js"); + + scripts.Add("_content/BlazorExpress.ChartJS/blazorexpress.chartjs.js"); + return scripts.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static IReadOnlyList NormalizeLabels(IReadOnlyList? labels, ChartDefinition definition) + { + if (labels is { Count: > 0 }) + return labels.Select((x, index) => string.IsNullOrWhiteSpace(x) ? $"Label {index + 1}" : x.Trim()).ToList(); + + return definition.Name is "Bubble" or "Scatter" + ? ["Point 1", "Point 2", "Point 3", "Point 4"] + : ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]; + } + + private static IReadOnlyList NormalizeDatasets(IReadOnlyList? datasets, IReadOnlyList labels, ChartDefinition definition) + { + if (datasets is { Count: > 0 }) + return datasets; + + if (definition.Name is "Pie" or "Doughnut" or "PolarArea") + { + return + [ + new() + { + Label = "Revenue", + Data = labels.Select((_, index) => (double?)(20 + (index + 1) * 8)).ToList(), + BackgroundColor = DefaultColors.Take(labels.Count).ToList(), + BorderColor = DefaultColors.Take(labels.Count).ToList(), + } + ]; + } + + return + [ + new() + { + Label = "Current", + Data = labels.Select((_, index) => (double?)(12 + (index + 1) * 6)).ToList(), + BackgroundColor = [DefaultColors[0]], + BorderColor = [DefaultColors[0]], + Points = labels.Select((_, index) => new ChartPointRequest { X = index + 1, Y = 12 + (index + 1) * 6, R = 6 + index }).ToList(), + }, + new() + { + Label = "Previous", + Data = labels.Select((_, index) => (double?)(10 + (index + 1) * 4)).ToList(), + BackgroundColor = [DefaultColors[1]], + BorderColor = [DefaultColors[1]], + Points = labels.Select((_, index) => new ChartPointRequest { X = index + 1, Y = 10 + (index + 1) * 4, R = 5 + index }).ToList(), + } + ]; + } + + private static IReadOnlyList NormalizeData(IReadOnlyList? values, int count) + { + if (values is { Count: > 0 }) + return Pad(values, count, 0); + + return Enumerable.Range(1, count).Select(x => (double?)(x * 10)).ToList(); + } + + private static IReadOnlyList NormalizePoints(ChartDatasetRequest dataset, int count, bool includeRadius) + { + if (dataset.Points is { Count: > 0 }) + return Pad(dataset.Points, count, new ChartPointRequest()); + + var data = NormalizeData(dataset.Data, count); + return data.Select((y, index) => new ChartPointRequest { X = index + 1, Y = y ?? 0, R = includeRadius ? 6 + index : null }).ToList(); + } + + private static IReadOnlyList NormalizeColors(IReadOnlyList? colors, int count) + { + if (colors is { Count: > 0 }) + return colors.Select(x => string.IsNullOrWhiteSpace(x) ? DefaultColors[0] : x).ToList(); + + return DefaultColors.Take(Math.Max(1, Math.Min(count, DefaultColors.Length))).ToList(); + } + + private static int NormalizeDimension(int? value, int fallback, string field) + { + if (value is null) + return fallback; + + if (value <= 0) + throw new ToolInputException($"{field} must be greater than zero.", field); + + return value.Value; + } + + private static IReadOnlyList Pad(IReadOnlyList values, int count, T fallback) + { + var result = values.Take(count).ToList(); + + while (result.Count < count) + result.Add(fallback); + + return result; + } + + private static string NormalizeRoute(string? route, string fallback, string field) + { + if (string.IsNullOrWhiteSpace(route)) + return fallback; + + var normalized = route.Trim(); + if (normalized.Any(char.IsWhiteSpace) || normalized.Contains('"', StringComparison.Ordinal)) + throw new ToolInputException($"{field} must be a route path without whitespace or quotes.", field); + + return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : $"/{normalized}"; + } + + private static string NormalizeLegendPosition(string? legendPosition) + { + var value = legendPosition?.Trim().ToLowerInvariant(); + return value is "left" or "right" or "bottom" or "top" ? value : "top"; + } + + private static string MakeIdentifier(string? value) + { + var cleaned = Regex.Replace(value ?? "GeneratedChartPage", "[^a-zA-Z0-9_]", ""); + if (string.IsNullOrWhiteSpace(cleaned)) + cleaned = "GeneratedChartPage"; + if (char.IsDigit(cleaned[0])) + cleaned = $"Chart{cleaned}"; + return cleaned; + } + + internal static string ToKebabCase(string value) => + Regex.Replace(value, "([a-z0-9])([A-Z])", "$1-$2").ToLowerInvariant(); + + private static string LowerFirst(string value) => + string.IsNullOrWhiteSpace(value) ? value : char.ToLowerInvariant(value[0]) + value[1..]; + + private static string EscapeHtml(string value) => + value.Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); + + private static string ToCSharpString(string value) => + $"\"{value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal)}\""; + + private static string FormatNullableNumber(double? value) => + value.HasValue ? FormatNumber(value.Value) : "null"; + + private static string FormatNumber(double value) => + value.ToString("0.########", CultureInfo.InvariantCulture); + + private sealed record ChartRenderModel( + ChartDefinition Definition, + string Title, + string Route, + string PageName, + IReadOnlyList Labels, + IReadOnlyList Datasets, + int Width, + int Height, + bool IncludeDatalabels, + string LegendPosition, + bool Stacked, + bool Horizontal, + string ChartField, + string OptionsField, + string DataField); +} diff --git a/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs new file mode 100644 index 00000000..dde3e23b --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs @@ -0,0 +1,143 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace BlazorExpress.ChartJS.MCP; + +[McpServerToolType] +public static class ChartJsMcpTools +{ + [McpServerTool(Name = "list_chart_types")] + [Description("Lists chart types supported by BlazorExpress.ChartJS code generation.")] + public static string ListChartTypes() => + McpToolExecution.Run(() => ChartCatalog.All.Select(x => new + { + x.Name, + x.ComponentName, + x.OptionsTypeName, + x.DatasetTypeName, + x.SupportsDatalabels, + x.SupportsStacking, + x.SupportsOrientation, + x.SupportsPluginOptions, + x.SupportsTitleOptions, + x.SupportsLegendOptions, + })); + + [McpServerTool(Name = "get_chart_generation_schema")] + [Description("Returns the code-generation input schema and local API metadata for a BlazorExpress.ChartJS chart type.")] + public static string GetChartGenerationSchema( + [Description("Chart type, for example Bar, Line, Pie, Doughnut, Bubble, Scatter, Radar, or PolarArea.")] + string chartType) => + McpToolExecution.Run(() => ChartCatalog.GetSchema(chartType)); + + [McpServerTool(Name = "generate_chart_example")] + [Description("Generates a complete ready-to-paste Razor example for a BlazorExpress.ChartJS chart. labelsJson is a JSON string array. datasetsJson is a JSON array of { label, data, points, backgroundColor, borderColor, stack }.")] + public static string GenerateChartExample( + string chartType, + string? title = null, + string? route = null, + string? pageName = null, + string? labelsJson = null, + string? datasetsJson = null, + int? width = null, + int? height = null, + string? legendPosition = null, + bool? datalabels = null, + bool? stacked = null, + string? orientation = null) + => McpToolExecution.Run(() => + { + var request = ChartRequestParser.CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation); + var generator = new ChartExampleGenerator(); + return generator.Generate(request); + }); + + [McpServerTool(Name = "generate_chart_dashboard")] + [Description("Generates a complete ready-to-paste Razor dashboard page. chartsJson is an optional JSON array of chart specs using chartType, title, labels, datasets, width, height, legendPosition, datalabels, stacked, and orientation.")] + public static string GenerateChartDashboard( + string? title = null, + string? route = null, + string? pageName = null, + string? chartsJson = null) + => McpToolExecution.Run(() => + { + var request = new ChartDashboardRequest + { + Title = title, + Route = route, + PageName = pageName, + Charts = ChartRequestParser.ParseDashboardCharts(chartsJson), + }; + + var generator = new ChartExampleGenerator(); + return generator.GenerateDashboard(request); + }); + + [McpServerTool(Name = "preview_project_integration")] + [Description("Creates a non-mutating preview plan for adding a generated chart page and required BlazorExpress.ChartJS setup to a target Blazor project.")] + public static string PreviewProjectIntegration( + string targetProjectPath, + string chartType, + string? title = null, + string? route = null, + string? pageName = null, + string? labelsJson = null, + string? datasetsJson = null, + int? width = null, + int? height = null, + string? legendPosition = null, + bool? datalabels = null, + bool? stacked = null, + string? orientation = null) + => McpToolExecution.Run(() => + { + var request = new PreviewIntegrationRequest + { + TargetProjectPath = targetProjectPath, + Chart = ChartRequestParser.CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation), + }; + + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + return service.Preview(request); + }); + + [McpServerTool(Name = "preview_dashboard_integration")] + [Description("Creates a non-mutating preview plan for adding a generated chart dashboard page and required BlazorExpress.ChartJS setup to a target Blazor project.")] + public static string PreviewDashboardIntegration( + string targetProjectPath, + string? title = null, + string? route = null, + string? pageName = null, + string? chartsJson = null) + => McpToolExecution.Run(() => + { + var request = new PreviewDashboardIntegrationRequest + { + TargetProjectPath = targetProjectPath, + Dashboard = new ChartDashboardRequest + { + Title = title, + Route = route, + PageName = pageName, + Charts = ChartRequestParser.ParseDashboardCharts(chartsJson), + }, + }; + + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + return service.PreviewDashboard(request); + }); + + [McpServerTool(Name = "apply_project_integration")] + [Description("Applies a preview_project_integration plan after validating its plan hash and current file hashes.")] + public static string ApplyProjectIntegration( + [Description("The full JSON plan returned by preview_project_integration.")] + string integrationPlanJson) + => McpToolExecution.Run(() => + { + var plan = Json.Deserialize(integrationPlanJson) + ?? throw new ToolInputException("integrationPlanJson must contain a serialized integration plan.", "integrationPlanJson"); + + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + return service.Apply(plan); + }); +} diff --git a/BlazorExpress.ChartJS.MCP/ChartRequestParser.cs b/BlazorExpress.ChartJS.MCP/ChartRequestParser.cs new file mode 100644 index 00000000..7068f492 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartRequestParser.cs @@ -0,0 +1,278 @@ +using System.Text.Json; + +namespace BlazorExpress.ChartJS.MCP; + +internal static class ChartRequestParser +{ + public static ChartGenerationRequest CreateChartRequest( + string chartType, + string? title, + string? route, + string? pageName, + string? labelsJson, + string? datasetsJson, + int? width, + int? height, + string? legendPosition, + bool? datalabels, + bool? stacked, + string? orientation) => + new() + { + ChartType = chartType, + Title = title, + Route = route, + PageName = pageName, + Labels = ParseLabels(labelsJson), + Datasets = ParseDatasets(datasetsJson), + Width = width, + Height = height, + LegendPosition = legendPosition, + Datalabels = datalabels, + Stacked = stacked, + Orientation = orientation, + }; + + public static IReadOnlyList? ParseDashboardCharts(string? chartsJson) + { + if (string.IsNullOrWhiteSpace(chartsJson)) + return null; + + try + { + using var document = JsonDocument.Parse(chartsJson); + if (document.RootElement.ValueKind is not JsonValueKind.Array) + throw new ToolInputException("chartsJson must be a JSON array of chart specifications.", "chartsJson"); + + var charts = new List(); + var index = 0; + foreach (var element in document.RootElement.EnumerateArray()) + { + if (element.ValueKind is not JsonValueKind.Object) + throw new ToolInputException($"chartsJson[{index}] must be an object.", "chartsJson"); + + charts.Add(ParseChartObject(element, $"chartsJson[{index}]")); + index++; + } + + return charts; + } + catch (JsonException exception) + { + throw new ToolInputException($"chartsJson is not valid JSON: {exception.Message}", "chartsJson"); + } + } + + private static IReadOnlyList? ParseLabels(string? labelsJson) + { + if (string.IsNullOrWhiteSpace(labelsJson)) + return null; + + try + { + var labels = Json.Deserialize>(labelsJson); + return labels; + } + catch (JsonException exception) + { + throw new ToolInputException($"labelsJson must be a JSON string array: {exception.Message}", "labelsJson"); + } + } + + private static IReadOnlyList? ParseDatasets(string? datasetsJson) + { + if (string.IsNullOrWhiteSpace(datasetsJson)) + return null; + + try + { + using var document = JsonDocument.Parse(datasetsJson); + if (document.RootElement.ValueKind is not JsonValueKind.Array) + throw new ToolInputException("datasetsJson must be a JSON array.", "datasetsJson"); + + return ParseDatasetArray(document.RootElement, "datasetsJson"); + } + catch (JsonException exception) + { + throw new ToolInputException($"datasetsJson is not valid JSON: {exception.Message}", "datasetsJson"); + } + } + + private static ChartGenerationRequest ParseChartObject(JsonElement element, string field) + { + var chartType = GetOptionalString(element, "chartType") + ?? throw new ToolInputException($"{field}.chartType is required.", field); + + return new ChartGenerationRequest + { + ChartType = chartType, + Title = GetOptionalString(element, "title"), + Route = GetOptionalString(element, "route"), + PageName = GetOptionalString(element, "pageName"), + Labels = element.TryGetProperty("labels", out var labels) ? ParseStringArray(labels, $"{field}.labels") : null, + Datasets = element.TryGetProperty("datasets", out var datasets) ? ParseDatasetArray(datasets, $"{field}.datasets") : null, + Width = GetOptionalInt(element, "width", field), + Height = GetOptionalInt(element, "height", field), + LegendPosition = GetOptionalString(element, "legendPosition"), + Datalabels = GetOptionalBool(element, "datalabels", field), + Stacked = GetOptionalBool(element, "stacked", field), + Orientation = GetOptionalString(element, "orientation"), + }; + } + + private static IReadOnlyList ParseDatasetArray(JsonElement element, string field) + { + if (element.ValueKind is not JsonValueKind.Array) + throw new ToolInputException($"{field} must be a JSON array.", field); + + var datasets = new List(); + var index = 0; + foreach (var datasetElement in element.EnumerateArray()) + { + if (datasetElement.ValueKind is not JsonValueKind.Object) + throw new ToolInputException($"{field}[{index}] must be an object.", field); + + datasets.Add(new ChartDatasetRequest + { + Label = GetOptionalString(datasetElement, "label"), + Data = datasetElement.TryGetProperty("data", out var data) ? ParseNullableNumberArray(data, $"{field}[{index}].data") : null, + Points = datasetElement.TryGetProperty("points", out var points) ? ParsePoints(points, $"{field}[{index}].points") : null, + BackgroundColor = datasetElement.TryGetProperty("backgroundColor", out var backgroundColor) ? ParseStringOrStringArray(backgroundColor, $"{field}[{index}].backgroundColor") : null, + BorderColor = datasetElement.TryGetProperty("borderColor", out var borderColor) ? ParseStringOrStringArray(borderColor, $"{field}[{index}].borderColor") : null, + Stack = GetOptionalString(datasetElement, "stack"), + }); + index++; + } + + return datasets; + } + + private static IReadOnlyList ParseStringOrStringArray(JsonElement element, string field) => + element.ValueKind switch + { + JsonValueKind.String => [element.GetString() ?? ""], + JsonValueKind.Array => ParseStringArray(element, field), + _ => throw new ToolInputException($"{field} must be a string or string array.", field), + }; + + private static IReadOnlyList ParseStringArray(JsonElement element, string field) + { + if (element.ValueKind is not JsonValueKind.Array) + throw new ToolInputException($"{field} must be a string array.", field); + + var values = new List(); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is not JsonValueKind.String) + throw new ToolInputException($"{field} must contain only strings.", field); + + values.Add(item.GetString() ?? ""); + } + + return values; + } + + private static IReadOnlyList ParseNullableNumberArray(JsonElement element, string field) + { + if (element.ValueKind is not JsonValueKind.Array) + throw new ToolInputException($"{field} must be a number array.", field); + + var values = new List(); + foreach (var item in element.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.Null) + { + values.Add(null); + continue; + } + + if (item.ValueKind is not JsonValueKind.Number || !item.TryGetDouble(out var value)) + throw new ToolInputException($"{field} must contain only numbers or null.", field); + + values.Add(value); + } + + return values; + } + + private static IReadOnlyList ParsePoints(JsonElement element, string field) + { + if (element.ValueKind is not JsonValueKind.Array) + throw new ToolInputException($"{field} must be an array of point objects.", field); + + var points = new List(); + var index = 0; + foreach (var point in element.EnumerateArray()) + { + if (point.ValueKind is not JsonValueKind.Object) + throw new ToolInputException($"{field}[{index}] must be an object.", field); + + points.Add(new ChartPointRequest + { + X = GetRequiredDouble(point, "x", $"{field}[{index}]"), + Y = GetRequiredDouble(point, "y", $"{field}[{index}]"), + R = GetOptionalDouble(point, "r", $"{field}[{index}]"), + }); + index++; + } + + return points; + } + + private static string? GetOptionalString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null) + return null; + + if (value.ValueKind is not JsonValueKind.String) + throw new ToolInputException($"{propertyName} must be a string.", propertyName); + + return value.GetString(); + } + + private static int? GetOptionalInt(JsonElement element, string propertyName, string field) + { + if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null) + return null; + + if (value.ValueKind is not JsonValueKind.Number || !value.TryGetInt32(out var result)) + throw new ToolInputException($"{field}.{propertyName} must be an integer.", $"{field}.{propertyName}"); + + return result; + } + + private static bool? GetOptionalBool(JsonElement element, string propertyName, string field) + { + if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null) + return null; + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => throw new ToolInputException($"{field}.{propertyName} must be a boolean.", $"{field}.{propertyName}"), + }; + } + + private static double GetRequiredDouble(JsonElement element, string propertyName, string field) + { + if (!element.TryGetProperty(propertyName, out var value)) + throw new ToolInputException($"{field}.{propertyName} is required.", $"{field}.{propertyName}"); + + if (value.ValueKind is not JsonValueKind.Number || !value.TryGetDouble(out var result)) + throw new ToolInputException($"{field}.{propertyName} must be a number.", $"{field}.{propertyName}"); + + return result; + } + + private static double? GetOptionalDouble(JsonElement element, string propertyName, string field) + { + if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null) + return null; + + if (value.ValueKind is not JsonValueKind.Number || !value.TryGetDouble(out var result)) + throw new ToolInputException($"{field}.{propertyName} must be a number.", $"{field}.{propertyName}"); + + return result; + } +} diff --git a/BlazorExpress.ChartJS.MCP/Json.cs b/BlazorExpress.ChartJS.MCP/Json.cs new file mode 100644 index 00000000..d0e026aa --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Json.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace BlazorExpress.ChartJS.MCP; + +public static class Json +{ + public static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + public static string Serialize(T value) => + JsonSerializer.Serialize(value, SerializerOptions); + + public static T? Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return default; + + return JsonSerializer.Deserialize(json, SerializerOptions); + } +} diff --git a/BlazorExpress.ChartJS.MCP/McpApplication.cs b/BlazorExpress.ChartJS.MCP/McpApplication.cs new file mode 100644 index 00000000..7ed07b64 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/McpApplication.cs @@ -0,0 +1,189 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Server; + +namespace BlazorExpress.ChartJS.MCP; + +public static class McpApplication +{ + public const string DefaultHttpUrl = "http://localhost:5000"; + public const string McpPath = "/mcp"; + public const string HealthPath = "/health"; + public const string TokenConfigurationKey = "CHARTJS_MCP_TOKEN"; + + public static Task RunAsync(string[] args, CancellationToken cancellationToken = default) + { + var options = ParseOptions(args); + + return options.Transport == McpTransportMode.Stdio + ? RunStdioAsync(options.RemainingArgs, cancellationToken) + : RunHttpAsync(options.RemainingArgs, cancellationToken); + } + + public static McpHostOptions ParseOptions(IReadOnlyList args) + { + var transport = McpTransportMode.Http; + var remainingArgs = new List(); + + for (var i = 0; i < args.Count; i++) + { + var arg = args[i]; + + if (string.Equals(arg, "--stdio", StringComparison.OrdinalIgnoreCase)) + { + transport = McpTransportMode.Stdio; + continue; + } + + if (string.Equals(arg, "--http", StringComparison.OrdinalIgnoreCase)) + { + transport = McpTransportMode.Http; + continue; + } + + if (string.Equals(arg, "--transport", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 >= args.Count) + throw new ArgumentException("Missing value for --transport. Use 'http' or 'stdio'."); + + transport = ParseTransport(args[++i]); + continue; + } + + if (arg.StartsWith("--transport=", StringComparison.OrdinalIgnoreCase)) + { + transport = ParseTransport(arg["--transport=".Length..]); + continue; + } + + remainingArgs.Add(arg); + } + + return new McpHostOptions(transport, remainingArgs.ToArray()); + } + + public static WebApplication CreateHttpApp( + string[] args, + string? environmentName = null, + Action? configureWebHost = null, + IEnumerable>? configuration = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = args, + EnvironmentName = environmentName, + }); + + if (configuration is not null) + builder.Configuration.AddInMemoryCollection(configuration); + + var useDefaultHttpUrl = !HasUrlOverride(args); + + configureWebHost?.Invoke(builder.WebHost); + + RegisterCoreServices(builder.Services); + builder.Services + .AddMcpServer() + .WithHttpTransport(options => + { + options.Stateless = true; + }) + .AddAuthorizationFilters() + .WithToolsFromAssembly(); + + var token = builder.Configuration[TokenConfigurationKey]; + if (!builder.Environment.IsDevelopment() && string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException($"{TokenConfigurationKey} must be configured when HTTP MCP is running outside Development."); + + var app = builder.Build(); + + if (useDefaultHttpUrl) + app.Urls.Add(DefaultHttpUrl); + + app.MapGet(HealthPath, () => Results.Ok(new { status = "healthy" })); + + if (!app.Environment.IsDevelopment()) + app.UseBearerTokenProtection(token!); + + app.MapMcp(McpPath); + + return app; + } + + private static async Task RunHttpAsync(string[] args, CancellationToken cancellationToken) + { + var app = CreateHttpApp(args); + await app.RunAsync(cancellationToken); + } + + private static async Task RunStdioAsync(string[] args, CancellationToken cancellationToken) + { + var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings { Args = args }); + + RegisterCoreServices(builder.Services); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + + await builder.Build().RunAsync(cancellationToken); + } + + private static void RegisterCoreServices(IServiceCollection services) + { + services + .AddSingleton() + .AddSingleton(); + } + + private static McpTransportMode ParseTransport(string value) => + value.ToLowerInvariant() switch + { + "http" => McpTransportMode.Http, + "stdio" => McpTransportMode.Stdio, + _ => throw new ArgumentException($"Unsupported MCP transport '{value}'. Use 'http' or 'stdio'."), + }; + + private static bool HasUrlOverride(IEnumerable args) => + args.Any(arg => + string.Equals(arg, "--urls", StringComparison.OrdinalIgnoreCase) + || arg.StartsWith("--urls=", StringComparison.OrdinalIgnoreCase)) + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")); +} + +internal static class McpApplicationBuilderExtensions +{ + public static IApplicationBuilder UseBearerTokenProtection(this IApplicationBuilder app, string token) => + app.Use(async (context, next) => + { + if (!context.Request.Path.StartsWithSegments(McpApplication.McpPath)) + { + await next(context); + return; + } + + var expectedHeader = $"Bearer {token}"; + var actualHeader = context.Request.Headers.Authorization.ToString(); + + if (!string.Equals(actualHeader, expectedHeader, StringComparison.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + await next(context); + }); +} + +public sealed record McpHostOptions(McpTransportMode Transport, string[] RemainingArgs); + +public enum McpTransportMode +{ + Http, + Stdio, +} diff --git a/BlazorExpress.ChartJS.MCP/McpToolExecution.cs b/BlazorExpress.ChartJS.MCP/McpToolExecution.cs new file mode 100644 index 00000000..bb1f792f --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/McpToolExecution.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace BlazorExpress.ChartJS.MCP; + +internal static class McpToolExecution +{ + public static string Run(Func action) + { + try + { + return Json.Serialize(action()); + } + catch (ToolInputException exception) + { + return InvalidInput(exception.Message, exception.Field, exception.Details); + } + catch (ArgumentException exception) + { + return InvalidInput(exception.Message, exception.ParamName); + } + catch (JsonException exception) + { + return InvalidInput(exception.Message, null); + } + catch (DirectoryNotFoundException exception) + { + return InvalidInput(exception.Message, "targetProjectPath"); + } + catch (FileNotFoundException exception) + { + return InvalidInput(exception.Message, "targetProjectPath"); + } + catch (InvalidOperationException exception) + { + return InvalidInput(exception.Message, null); + } + } + + private static string InvalidInput(string message, string? field, IReadOnlyList? details = null) => + Json.Serialize(new ToolFailure( + Success: false, + Error: new ToolError( + Code: "invalid_input", + Message: message, + Field: field, + Details: details ?? []))); +} diff --git a/BlazorExpress.ChartJS.MCP/Models.cs b/BlazorExpress.ChartJS.MCP/Models.cs new file mode 100644 index 00000000..014e21dd --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Models.cs @@ -0,0 +1,159 @@ +using System.Text.Json.Serialization; + +namespace BlazorExpress.ChartJS.MCP; + +public sealed record ChartDefinition( + string Name, + string ComponentName, + string OptionsTypeName, + string DatasetTypeName, + Type ComponentType, + Type OptionsType, + Type DatasetType, + bool SupportsDatalabels, + bool SupportsStacking, + bool SupportsOrientation, + bool SupportsPluginOptions, + bool SupportsTitleOptions, + bool SupportsLegendOptions, + bool SupportsScales); + +public sealed record ChartGenerationSchema( + string ChartType, + string Component, + string OptionsType, + string DatasetType, + bool SupportsDatalabels, + bool SupportsStacking, + bool SupportsOrientation, + bool SupportsPluginOptions, + bool SupportsTitleOptions, + bool SupportsLegendOptions, + IReadOnlyList CommonInputs, + IReadOnlyList ChartSpecificInputs, + IReadOnlyDictionary Examples, + IReadOnlyDictionary Metadata); + +public sealed record PropertyMetadata( + string Name, + string Type, + string? Description, + string? DefaultValue); + +public sealed record ChartGenerationRequest +{ + public string ChartType { get; init; } = "Bar"; + public string? Title { get; init; } + public string? Route { get; init; } + public string? PageName { get; init; } + public IReadOnlyList? Labels { get; init; } + public IReadOnlyList? Datasets { get; init; } + public int? Width { get; init; } + public int? Height { get; init; } + public string? LegendPosition { get; init; } + public bool? Datalabels { get; init; } + public bool? Stacked { get; init; } + public string? Orientation { get; init; } +} + +public sealed record ChartDatasetRequest +{ + public string? Label { get; init; } + public IReadOnlyList? Data { get; init; } + public IReadOnlyList? Points { get; init; } + public IReadOnlyList? BackgroundColor { get; init; } + public IReadOnlyList? BorderColor { get; init; } + public string? Stack { get; init; } +} + +public sealed record ChartPointRequest +{ + [JsonPropertyName("x")] + public double X { get; init; } + + [JsonPropertyName("y")] + public double Y { get; init; } + + [JsonPropertyName("r")] + public double? R { get; init; } +} + +public sealed record GeneratedChartExample( + string ChartType, + string Route, + string PageName, + string Code, + IReadOnlyList RequiredScripts); + +public sealed record ChartDashboardRequest +{ + public string? Title { get; init; } + public string? Route { get; init; } + public string? PageName { get; init; } + public IReadOnlyList? Charts { get; init; } +} + +public sealed record GeneratedChartDashboard( + string Route, + string PageName, + string Code, + IReadOnlyList ChartTypes, + IReadOnlyList RequiredScripts); + +public sealed record PreviewIntegrationRequest +{ + public string TargetProjectPath { get; init; } = ""; + public ChartGenerationRequest Chart { get; init; } = new(); +} + +public sealed record PreviewDashboardIntegrationRequest +{ + public string TargetProjectPath { get; init; } = ""; + public ChartDashboardRequest Dashboard { get; init; } = new(); +} + +public sealed record IntegrationPlan +{ + public string PlanHash { get; init; } = ""; + public string TargetProjectRoot { get; init; } = ""; + public string ProjectFilePath { get; init; } = ""; + public string DetectedHostModel { get; init; } = ""; + public IReadOnlyList Edits { get; init; } = []; + public IReadOnlyList ManualSteps { get; init; } = []; +} + +public sealed record FileEdit +{ + public string Path { get; init; } = ""; + public string Operation { get; init; } = ""; + public string? OriginalHash { get; init; } + public string NewContent { get; init; } = ""; + public string Description { get; init; } = ""; +} + +public sealed record ApplyIntegrationResult( + string PlanHash, + IReadOnlyList WrittenFiles, + IReadOnlyList ManualSteps); + +public sealed record ToolFailure(bool Success, ToolError Error); + +public sealed record ToolError( + string Code, + string Message, + string? Field = null, + IReadOnlyList? Details = null); + +public sealed class ToolInputException : Exception +{ + public ToolInputException(string message, string? field = null, IReadOnlyList? details = null) + : base(message) + { + Field = field; + Details = details ?? []; + } + + public string? Field { get; } + + public IReadOnlyList Details { get; } +} diff --git a/BlazorExpress.ChartJS.MCP/Program.cs b/BlazorExpress.ChartJS.MCP/Program.cs new file mode 100644 index 00000000..3eeac2fe --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Program.cs @@ -0,0 +1,3 @@ +using BlazorExpress.ChartJS.MCP; + +await McpApplication.RunAsync(args); diff --git a/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs new file mode 100644 index 00000000..7e804432 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs @@ -0,0 +1,442 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace BlazorExpress.ChartJS.MCP; + +public sealed class ProjectIntegrationService +{ + private const string PackageReference = "BlazorExpress.ChartJS"; + private const string PackageVersion = "1.2.3"; + private static readonly string[] ExcludedProjectSearchDirectories = + [ + "bin", + "obj", + ".git", + ".vs", + "node_modules", + "artifacts" + ]; + + private readonly ChartExampleGenerator generator; + + public ProjectIntegrationService(ChartExampleGenerator generator) + { + this.generator = generator; + } + + public IntegrationPlan Preview(PreviewIntegrationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var generated = generator.Generate(request.Chart); + return PreviewGeneratedPage( + request.TargetProjectPath, + new GeneratedPage( + PageName: generated.PageName, + Route: generated.Route, + Title: request.Chart.Title ?? generated.ChartType + " Chart", + Code: generated.Code, + RequiredScripts: generated.RequiredScripts, + Description: $"generated {generated.ChartType} chart page")); + } + + public IntegrationPlan PreviewDashboard(PreviewDashboardIntegrationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var generated = generator.GenerateDashboard(request.Dashboard); + return PreviewGeneratedPage( + request.TargetProjectPath, + new GeneratedPage( + PageName: generated.PageName, + Route: generated.Route, + Title: request.Dashboard.Title ?? "Chart Dashboard", + Code: generated.Code, + RequiredScripts: generated.RequiredScripts, + Description: "generated chart dashboard page")); + } + + public IntegrationPlan PreviewGeneratedPage(string targetProjectPath, GeneratedPage generated) + { + var project = ResolveProject(targetProjectPath); + var hostModel = DetectHostModel(project); + var edits = new List(); + var manualSteps = new List(); + + AddProjectReferenceEdit(project, edits); + AddImportsEdit(project.RootDirectory, edits); + AddScriptEdit(project, hostModel, generated.RequiredScripts, edits, manualSteps); + AddPageEdit(project, hostModel, generated, edits); + AddNavigationEdit(project.RootDirectory, generated.Route, generated.Title, edits, manualSteps); + + var plan = new IntegrationPlan + { + TargetProjectRoot = project.RootDirectory, + ProjectFilePath = project.ProjectFilePath, + DetectedHostModel = hostModel, + Edits = edits, + ManualSteps = manualSteps, + }; + + return plan with { PlanHash = ComputePlanHash(plan) }; + } + + public ApplyIntegrationResult Apply(IntegrationPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + + var expectedHash = ComputePlanHash(plan with { PlanHash = "" }); + if (!string.Equals(plan.PlanHash, expectedHash, StringComparison.Ordinal)) + throw new InvalidOperationException("The integration plan hash is stale or invalid. Run preview_project_integration again."); + + var root = Path.GetFullPath(plan.TargetProjectRoot); + var writtenFiles = new List(); + + foreach (var edit in plan.Edits) + { + var fullPath = Path.GetFullPath(edit.Path); + if (!IsPathInside(root, fullPath)) + throw new InvalidOperationException($"Refusing to write outside the target project root: {edit.Path}"); + + if (File.Exists(fullPath)) + { + var currentHash = HashText(File.ReadAllText(fullPath)); + if (!string.Equals(edit.OriginalHash, currentHash, StringComparison.Ordinal)) + throw new InvalidOperationException($"Refusing to overwrite changed file. Re-run preview_project_integration: {fullPath}"); + } + else if (!string.IsNullOrEmpty(edit.OriginalHash)) + { + throw new InvalidOperationException($"Refusing to update a file that no longer exists: {fullPath}"); + } + + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, edit.NewContent, Encoding.UTF8); + writtenFiles.Add(fullPath); + } + + return new ApplyIntegrationResult(plan.PlanHash, writtenFiles, plan.ManualSteps); + } + + public static string ComputePlanHash(IntegrationPlan plan) + { + var normalized = plan with { PlanHash = "" }; + return HashText(JsonSerializer.Serialize(normalized, Json.SerializerOptions)); + } + + private static ProjectContext ResolveProject(string targetProjectPath) + { + if (string.IsNullOrWhiteSpace(targetProjectPath)) + throw new ToolInputException("A target project path is required.", "targetProjectPath"); + + var path = Path.GetFullPath(targetProjectPath); + if (File.Exists(path)) + { + if (string.Equals(Path.GetExtension(path), ".csproj", StringComparison.OrdinalIgnoreCase)) + return new ProjectContext(path, Path.GetDirectoryName(path)!); + + if (string.Equals(Path.GetExtension(path), ".sln", StringComparison.OrdinalIgnoreCase)) + return ResolveProjectFromSolution(path); + + throw new ToolInputException("Target project path must be a .csproj file, .sln file, or directory.", "targetProjectPath"); + } + + if (!Directory.Exists(path)) + throw new DirectoryNotFoundException($"Target project path was not found: {path}"); + + return ResolveProjectFromDirectory(path); + } + + private static ProjectContext ResolveProjectFromDirectory(string directory) + { + var topLevelProjects = Directory.GetFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly); + if (topLevelProjects.Length > 0) + return ResolveProjectCandidates(topLevelProjects, $"No Blazor app project was found in {directory}."); + + var recursiveProjects = Directory + .EnumerateFiles(directory, "*.csproj", SearchOption.AllDirectories) + .Where(path => !IsExcludedFromProjectSearch(directory, path)) + .ToArray(); + + return ResolveProjectCandidates(recursiveProjects, $"No Blazor app project was found under {directory}."); + } + + private static ProjectContext ResolveProjectFromSolution(string solutionPath) + { + var solutionDirectory = Path.GetDirectoryName(solutionPath)!; + var content = File.ReadAllText(solutionPath); + var projectFiles = Regex.Matches( + content, + @"Project\("".*?""\)\s*=\s*"".*?"",\s*""(?[^""]+\.csproj)""", + RegexOptions.IgnoreCase) + .Select(match => Path.GetFullPath(Path.Combine(solutionDirectory, match.Groups["path"].Value))) + .Where(File.Exists) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return ResolveProjectCandidates(projectFiles, $"No Blazor app project was found in {solutionPath}."); + } + + private static ProjectContext ResolveProjectCandidates(IReadOnlyList projectFiles, string noBlazorProjectMessage) + { + if (projectFiles.Count == 0) + throw new ToolInputException(noBlazorProjectMessage, "targetProjectPath"); + + var candidates = projectFiles + .Select(CreateProjectCandidate) + .OrderBy(x => x.ProjectFilePath, StringComparer.OrdinalIgnoreCase) + .ToList(); + var blazorAppCandidates = candidates.Where(x => x.IsBlazorApp).ToList(); + + if (blazorAppCandidates.Count == 1) + return new ProjectContext(blazorAppCandidates[0].ProjectFilePath, blazorAppCandidates[0].RootDirectory); + + if (blazorAppCandidates.Count > 1) + throw new ToolInputException( + "Multiple Blazor project files were found. Pass the exact .csproj path.", + "targetProjectPath", + blazorAppCandidates.Select(x => x.ProjectFilePath).ToList()); + + throw new ToolInputException( + noBlazorProjectMessage, + "targetProjectPath", + candidates.Select(x => x.ProjectFilePath).ToList()); + } + + private static ProjectCandidate CreateProjectCandidate(string projectFilePath) + { + var fullPath = Path.GetFullPath(projectFilePath); + var rootDirectory = Path.GetDirectoryName(fullPath)!; + var projectText = File.ReadAllText(fullPath); + + return new ProjectCandidate(fullPath, rootDirectory, IsBlazorAppProject(rootDirectory, projectText)); + } + + private static bool IsBlazorAppProject(string rootDirectory, string projectText) => + projectText.Contains("Microsoft.NET.Sdk.BlazorWebAssembly", StringComparison.OrdinalIgnoreCase) + || projectText.Contains("Microsoft.AspNetCore.Components.WebAssembly", StringComparison.OrdinalIgnoreCase) + || projectText.Contains("true", StringComparison.OrdinalIgnoreCase) + || File.Exists(Path.Combine(rootDirectory, "App.razor")) + || File.Exists(Path.Combine(rootDirectory, "Components", "App.razor")) + || File.Exists(Path.Combine(rootDirectory, "wwwroot", "index.html")); + + private static bool IsExcludedFromProjectSearch(string rootDirectory, string path) + { + var relativePath = Path.GetRelativePath(rootDirectory, path); + var segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + return segments.Any(segment => ExcludedProjectSearchDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase)); + } + + private static string DetectHostModel(ProjectContext project) + { + var projectText = File.ReadAllText(project.ProjectFilePath); + + if (projectText.Contains("true", StringComparison.OrdinalIgnoreCase) + || projectText.Contains("-ios", StringComparison.OrdinalIgnoreCase) + || projectText.Contains("-android", StringComparison.OrdinalIgnoreCase) + || projectText.Contains("-maccatalyst", StringComparison.OrdinalIgnoreCase)) + return "MauiBlazorHybrid"; + + if (projectText.Contains("Microsoft.NET.Sdk.BlazorWebAssembly", StringComparison.OrdinalIgnoreCase) + || projectText.Contains("Microsoft.AspNetCore.Components.WebAssembly", StringComparison.OrdinalIgnoreCase)) + return "BlazorWebAssembly"; + + if (File.Exists(Path.Combine(project.RootDirectory, "Components", "App.razor")) + || File.Exists(Path.Combine(project.RootDirectory, "App.razor"))) + return "BlazorWebApp"; + + if (Directory.Exists(Path.Combine(project.RootDirectory, "wwwroot"))) + return "BlazorWebAssembly"; + + return "UnknownBlazor"; + } + + private static void AddProjectReferenceEdit(ProjectContext project, List edits) + { + var content = File.ReadAllText(project.ProjectFilePath); + if (content.Contains($"Include=\"{PackageReference}\"", StringComparison.OrdinalIgnoreCase) + || content.Contains($"Include='{PackageReference}'", StringComparison.OrdinalIgnoreCase)) + return; + + var document = XDocument.Parse(content, LoadOptions.PreserveWhitespace); + var projectElement = document.Root ?? throw new InvalidOperationException("Project file has no root element."); + var itemGroup = new XElement("ItemGroup", + new XElement("PackageReference", + new XAttribute("Include", PackageReference), + new XAttribute("Version", PackageVersion))); + projectElement.Add(Environment.NewLine, " ", itemGroup, Environment.NewLine); + + AddReplaceEdit(project.ProjectFilePath, content, document.ToString(SaveOptions.DisableFormatting), "Add BlazorExpress.ChartJS package reference.", edits); + } + + private static void AddImportsEdit(string root, List edits) + { + var importsPath = FindFirst(root, "_Imports.razor") + ?? Path.Combine(root, "_Imports.razor"); + var content = File.Exists(importsPath) ? File.ReadAllText(importsPath) : ""; + + if (content.Contains("@using BlazorExpress.ChartJS", StringComparison.Ordinal)) + return; + + var newContent = AppendLine(content, "@using BlazorExpress.ChartJS"); + AddReplaceEdit(importsPath, content, newContent, "Add BlazorExpress.ChartJS using to _Imports.razor.", edits); + } + + private static void AddScriptEdit(ProjectContext project, string hostModel, IReadOnlyList scripts, List edits, List manualSteps) + { + var candidatePaths = hostModel switch + { + "BlazorWebApp" => new[] + { + Path.Combine(project.RootDirectory, "Components", "App.razor"), + Path.Combine(project.RootDirectory, "Pages", "_Host.cshtml"), + Path.Combine(project.RootDirectory, "App.razor"), + }, + _ => new[] + { + Path.Combine(project.RootDirectory, "wwwroot", "index.html"), + } + }; + + var scriptFile = candidatePaths.FirstOrDefault(File.Exists); + if (scriptFile is null) + { + manualSteps.Add("Add Chart.js, optional chartjs-plugin-datalabels, and _content/BlazorExpress.ChartJS/blazorexpress.chartjs.js script references to the host page."); + return; + } + + var content = File.ReadAllText(scriptFile); + var newContent = content; + foreach (var script in scripts) + { + if (newContent.Contains(script, StringComparison.OrdinalIgnoreCase)) + continue; + + newContent = InsertBeforeBodyEnd(newContent, $" "); + } + + if (!string.Equals(content, newContent, StringComparison.Ordinal)) + AddReplaceEdit(scriptFile, content, newContent, "Add BlazorExpress.ChartJS script references.", edits); + } + + private static void AddPageEdit(ProjectContext project, string hostModel, GeneratedPage generated, List edits) + { + var pagesDirectory = hostModel switch + { + "BlazorWebApp" when Directory.Exists(Path.Combine(project.RootDirectory, "Components", "Pages")) => Path.Combine(project.RootDirectory, "Components", "Pages"), + _ when Directory.Exists(Path.Combine(project.RootDirectory, "Pages")) => Path.Combine(project.RootDirectory, "Pages"), + _ => Path.Combine(project.RootDirectory, "Pages"), + }; + + var pagePath = Path.Combine(pagesDirectory, $"{generated.PageName}.razor"); + var original = File.Exists(pagePath) ? File.ReadAllText(pagePath) : ""; + AddReplaceEdit(pagePath, original, generated.Code, $"Create or update {generated.Description}.", edits); + } + + private static void AddNavigationEdit(string root, string route, string title, List edits, List manualSteps) + { + var navFiles = Directory.GetFiles(root, "NavMenu.razor", SearchOption.AllDirectories) + .Where(x => !x.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) + && !x.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (navFiles.Count != 1) + { + manualSteps.Add($"Add a navigation link to {route} ({title}) in your app navigation."); + return; + } + + var navPath = navFiles[0]; + var content = File.ReadAllText(navPath); + if (content.Contains($"href=\"{route.TrimStart('/')}\"", StringComparison.OrdinalIgnoreCase) + || content.Contains($"href=\"{route}\"", StringComparison.OrdinalIgnoreCase)) + return; + + var navLink = $" {title}"; + var lines = content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList(); + var lastNavLinkIndex = lines.FindLastIndex(x => x.Contains("", StringComparison.OrdinalIgnoreCase); + if (bodyEnd >= 0) + return content.Insert(bodyEnd, line + Environment.NewLine); + + return AppendLine(content, line); + } + + private static string AppendLine(string content, string line) + { + if (string.IsNullOrEmpty(content)) + return line + Environment.NewLine; + + return content.EndsWith(Environment.NewLine, StringComparison.Ordinal) + ? content + line + Environment.NewLine + : content + Environment.NewLine + line + Environment.NewLine; + } + + private static string? FindFirst(string root, string fileName) => + Directory.GetFiles(root, fileName, SearchOption.AllDirectories) + .Where(x => !x.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) + && !x.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.Length) + .FirstOrDefault(); + + private static void AddReplaceEdit(string path, string originalContent, string newContent, string description, List edits) + { + if (string.Equals(originalContent, newContent, StringComparison.Ordinal)) + return; + + edits.Add(new FileEdit + { + Path = Path.GetFullPath(path), + Operation = File.Exists(path) ? "replace" : "create", + OriginalHash = File.Exists(path) ? HashText(originalContent) : null, + NewContent = newContent, + Description = description, + }); + } + + private static bool IsPathInside(string root, string path) + { + var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + return path.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase) + || string.Equals(root, path, StringComparison.OrdinalIgnoreCase); + } + + private static string HashText(string text) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private sealed record ProjectContext(string ProjectFilePath, string RootDirectory); + + private sealed record ProjectCandidate(string ProjectFilePath, string RootDirectory, bool IsBlazorApp); + + public sealed record GeneratedPage( + string PageName, + string Route, + string Title, + string Code, + IReadOnlyList RequiredScripts, + string Description); +} diff --git a/BlazorExpress.ChartJS.MCP/Properties/launchSettings.json b/BlazorExpress.ChartJS.MCP/Properties/launchSettings.json new file mode 100644 index 00000000..c880e613 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Properties/launchSettings.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "BlazorExpress.ChartJS.MCP": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "BlazorExpress.ChartJS.MCP (stdio)": { + "commandName": "Project", + "commandLineArgs": "--stdio", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/BlazorExpress.ChartJS.MCP/README.md b/BlazorExpress.ChartJS.MCP/README.md new file mode 100644 index 00000000..27133f28 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/README.md @@ -0,0 +1,224 @@ +# BlazorExpress.ChartJS.MCP + +Model Context Protocol server for generating and integrating BlazorExpress.ChartJS charts. + +Requires .NET 10 SDK/runtime. + +## Install + +```powershell +dotnet tool install --global BlazorExpress.ChartJS.MCP +``` + +## MCP command + +```powershell +blazorexpress-chartjs-mcp +``` + +The default command starts a Streamable HTTP MCP server at `http://localhost:5000/mcp`. + +The server exposes tools for listing supported chart types, generating complete Razor examples, generating multi-chart dashboard pages, previewing project integration edits, and applying approved integration plans. + +Available tools include: + +- `list_chart_types` +- `get_chart_generation_schema` +- `generate_chart_example` +- `generate_chart_dashboard` +- `preview_project_integration` +- `preview_dashboard_integration` +- `apply_project_integration` + +Successful tool responses return the generated payload directly. Invalid inputs return structured JSON with `success: false` and an `error` object containing `code`, `message`, `field`, and `details`. + +HTTP mode does not require authentication when `ASPNETCORE_ENVIRONMENT` is `Development`. In other environments, set `CHARTJS_MCP_TOKEN` and send it as a bearer token. + +Use explicit stdio mode for editor integrations that launch the MCP server as a child process: + +```powershell +blazorexpress-chartjs-mcp --stdio +``` + +You can also select the transport with: + +```powershell +blazorexpress-chartjs-mcp --transport http +blazorexpress-chartjs-mcp --transport stdio +``` + +## How to test in local + +From the repository root, run the solution tests: + +```powershell +dotnet test .\BlazorExpress.ChartJS.sln +``` + +Create the local .NET tool package: + +```powershell +dotnet pack .\BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj -c Release +``` + +Install the generated package locally: + +```powershell +dotnet tool install --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release +``` + +If the tool is already installed, update it instead: + +```powershell +dotnet tool update --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release +``` + +Run the MCP server: + +```powershell +$env:ASPNETCORE_ENVIRONMENT = "Development" +blazorexpress-chartjs-mcp +``` + +The command starts a Streamable HTTP MCP server on `http://localhost:5000/mcp`. + +Check health: + +```powershell +Invoke-RestMethod http://localhost:5000/health +``` + +Run the stdio MCP server for local editor integration: + +```powershell +blazorexpress-chartjs-mcp --stdio +``` + +## How to run as a Kestrel service + +For local development, no bearer token is required: + +```powershell +$env:ASPNETCORE_ENVIRONMENT = "Development" +blazorexpress-chartjs-mcp +``` + +For production, configure the URL and bearer token: + +```powershell +$env:ASPNETCORE_ENVIRONMENT = "Production" +$env:ASPNETCORE_URLS = "http://localhost:5000" +$env:CHARTJS_MCP_TOKEN = "" +blazorexpress-chartjs-mcp +``` + +Production MCP clients must send: + +```text +Authorization: Bearer +``` + +The Streamable HTTP endpoint is: + +```text +http://localhost:5000/mcp +``` + +The health endpoint is: + +```text +http://localhost:5000/health +``` + +## How to integrate with VS Code + +Install or update the tool locally first: + +```powershell +dotnet pack .\BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj -c Release +dotnet tool install --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release +``` + +If the tool is already installed: + +```powershell +dotnet tool update --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release +``` + +Create or update `.vscode/mcp.json` in your workspace: + +```json +{ + "servers": { + "blazorexpress-chartjs": { + "type": "stdio", + "command": "blazorexpress-chartjs-mcp", + "args": ["--stdio"] + } + } +} +``` + +For an HTTP MCP client, use this URL: + +```text +http://localhost:5000/mcp +``` + +In VS Code: + +1. Open Command Palette. +2. Run `MCP: List Servers`. +3. Start `blazorexpress-chartjs` if it is not already running. +4. Open Copilot Chat in Agent mode. +5. Use the tools exposed by the server, such as `list_chart_types`, `generate_chart_example`, or `generate_chart_dashboard`. + +You can also add the server through Command Palette using `MCP: Add Server`, choose a command/stdio server, and use `blazorexpress-chartjs-mcp` as the command. + +## How to integrate with Visual Studio + +Prerequisite: Visual Studio 2022 version 17.14 or later, or Visual Studio 2026, with GitHub Copilot Agent mode enabled. + +Option 1: use Visual Studio chat. + +1. Open the Copilot chat pane. +2. Switch to Agent mode. +3. Select Tools. +4. Select the plus (`+`) button. +5. Select `Add custom MCP server`. +6. Enter: + - Name: `blazorexpress-chartjs` + - Transport: `stdio` + - Command: `blazorexpress-chartjs-mcp` + - Arguments: `--stdio` +7. Save the server. +8. Enable the MCP tools from the Tools picker. + +Option 2: use a config file. + +Create one of these files: + +- `\.mcp.json` for a solution-level config that can be checked in. +- `%USERPROFILE%\.mcp.json` for a user-level config. +- `\.vscode\mcp.json` if you want VS Code and Visual Studio to share the same workspace config. + +Use this configuration: + +```json +{ + "servers": { + "blazorexpress-chartjs": { + "type": "stdio", + "command": "blazorexpress-chartjs-mcp", + "args": ["--stdio"] + } + } +} +``` + +After saving the file, open Copilot Chat in Agent mode, select Tools, and enable the BlazorExpress.ChartJS MCP tools. Visual Studio may ask for permission before running a tool. + +References: + +- VS Code MCP servers: https://code.visualstudio.com/docs/agent-customization/mcp-servers +- Visual Studio MCP servers: https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers diff --git a/BlazorExpress.ChartJS.sln b/BlazorExpress.ChartJS.sln index 29244aa1..29d9dad9 100644 --- a/BlazorExpress.ChartJS.sln +++ b/BlazorExpress.ChartJS.sln @@ -9,10 +9,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.Demo. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.Demo.WebAssembly", "BlazorExpress.ChartJS.Demo.WebAssembly\BlazorExpress.ChartJS.Demo.WebAssembly.csproj", "{7064944C-CF0A-4D64-AF1B-1F57EFBDA108}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.MCP", "BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj", "{B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.MCP.Tests", "BlazorExpress.ChartJS.MCP.Tests\BlazorExpress.ChartJS.MCP.Tests.csproj", "{E4609336-CF76-4C99-A40A-B529302D4B9F}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "demos", "demos", "{1B73E52F-BE4E-4EF2-B8E5-A795BA44FF5F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9F6BB711-0B8B-4790-8B16-F554B9B6462E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -31,6 +37,14 @@ Global {7064944C-CF0A-4D64-AF1B-1F57EFBDA108}.Debug|Any CPU.Build.0 = Debug|Any CPU {7064944C-CF0A-4D64-AF1B-1F57EFBDA108}.Release|Any CPU.ActiveCfg = Release|Any CPU {7064944C-CF0A-4D64-AF1B-1F57EFBDA108}.Release|Any CPU.Build.0 = Release|Any CPU + {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Release|Any CPU.Build.0 = Release|Any CPU + {E4609336-CF76-4C99-A40A-B529302D4B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4609336-CF76-4C99-A40A-B529302D4B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4609336-CF76-4C99-A40A-B529302D4B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4609336-CF76-4C99-A40A-B529302D4B9F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -39,6 +53,8 @@ Global {F618FB87-B86A-4A57-950A-9DD032E090CB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {B719B79F-9D20-4D39-96EB-7386B40EE693} = {1B73E52F-BE4E-4EF2-B8E5-A795BA44FF5F} {7064944C-CF0A-4D64-AF1B-1F57EFBDA108} = {1B73E52F-BE4E-4EF2-B8E5-A795BA44FF5F} + {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {E4609336-CF76-4C99-A40A-B529302D4B9F} = {9F6BB711-0B8B-4790-8B16-F554B9B6462E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F3AC9875-2CDD-4111-B39A-FFBF5066FFFF}