From 341f802ab91fe8513094fa32beb27e9eeb8f4c4c Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Thu, 2 Jul 2026 22:44:51 +0530 Subject: [PATCH 1/4] Add ChartJS MCP server for generating and integrating BlazorExpress charts - Implement ChartCatalog for managing chart definitions and schemas. - Create ChartExampleGenerator for generating Razor examples of charts. - Develop ChartJsMcpTools for MCP server tools including chart type listing and example generation. - Introduce Json utility for serialization and deserialization. - Define models for chart generation requests, datasets, and integration plans. - Implement ProjectIntegrationService for managing project integration and edits. - Add README.md for installation and usage instructions. - Set up Program.cs for hosting the MCP server. --- .../BlazorExpress.ChartJS.MCP.Tests.csproj | 23 ++ .../ChartCatalogTests.cs | 25 ++ .../ChartExampleGeneratorTests.cs | 36 ++ .../ProjectIntegrationServiceTests.cs | 100 ++++++ BlazorExpress.ChartJS.MCP.Tests/Usings.cs | 2 + .../BlazorExpress.ChartJS.MCP.csproj | 36 ++ BlazorExpress.ChartJS.MCP/ChartCatalog.cs | 111 +++++++ .../ChartExampleGenerator.cs | 294 +++++++++++++++++ BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs | 119 +++++++ BlazorExpress.ChartJS.MCP/Json.cs | 23 ++ BlazorExpress.ChartJS.MCP/Models.cs | 108 ++++++ BlazorExpress.ChartJS.MCP/Program.cs | 15 + .../ProjectIntegrationService.cs | 309 ++++++++++++++++++ BlazorExpress.ChartJS.MCP/README.md | 137 ++++++++ BlazorExpress.ChartJS.sln | 16 + 15 files changed, 1354 insertions(+) create mode 100644 BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj create mode 100644 BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs create mode 100644 BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs create mode 100644 BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs create mode 100644 BlazorExpress.ChartJS.MCP.Tests/Usings.cs create mode 100644 BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj create mode 100644 BlazorExpress.ChartJS.MCP/ChartCatalog.cs create mode 100644 BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs create mode 100644 BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs create mode 100644 BlazorExpress.ChartJS.MCP/Json.cs create mode 100644 BlazorExpress.ChartJS.MCP/Models.cs create mode 100644 BlazorExpress.ChartJS.MCP/Program.cs create mode 100644 BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs create mode 100644 BlazorExpress.ChartJS.MCP/README.md 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..55fa8c30 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj @@ -0,0 +1,23 @@ + + + + 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..7e1bf9bf --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs @@ -0,0 +1,25 @@ +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); + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs new file mode 100644 index 00000000..10de4ae3 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs @@ -0,0 +1,36 @@ +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)); + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs new file mode 100644 index 00000000..7638dcba --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs @@ -0,0 +1,100 @@ +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.False(File.Exists(Path.Combine(projectDirectory, "Pages", "LineChartPage.razor"))); + } + + [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)); + } + + private sealed class TemporaryWorkspace : IDisposable + { + private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tests-{Guid.NewGuid():N}"); + + public string CreateWebAssemblyProject() + { + Directory.CreateDirectory(root); + Directory.CreateDirectory(Path.Combine(root, "wwwroot")); + Directory.CreateDirectory(Path.Combine(root, "Pages")); + Directory.CreateDirectory(Path.Combine(root, "Shared")); + + File.WriteAllText(Path.Combine(root, "Sample.csproj"), """ + + + net10.0 + + + """); + File.WriteAllText(Path.Combine(root, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine); + File.WriteAllText(Path.Combine(root, "wwwroot", "index.html"), """ + + +
+ + + """); + File.WriteAllText(Path.Combine(root, "Shared", "NavMenu.razor"), """ + + """); + + return root; + } + + 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..07b90cb1 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj @@ -0,0 +1,36 @@ + + + + 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..c479be7f --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs @@ -0,0 +1,111 @@ +using System.ComponentModel; +using System.Reflection; + +namespace BlazorExpress.ChartJS.MCP; + +public static class ChartCatalog +{ + private static readonly IReadOnlyDictionary DefinitionsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["bar"] = new("Bar", "BarChart", "BarChartOptions", "BarChartDataset", typeof(BarChart), typeof(BarChartOptions), typeof(BarChartDataset), true, true, true), + ["bubble"] = new("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset", typeof(BubbleChart), typeof(BubbleChartOptions), typeof(BubbleChartDataset), true, false, false), + ["doughnut"] = new("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset", typeof(DoughnutChart), typeof(DoughnutChartOptions), typeof(DoughnutChartDataset), true, false, false), + ["line"] = new("Line", "LineChart", "LineChartOptions", "LineChartDataset", typeof(LineChart), typeof(LineChartOptions), typeof(LineChartDataset), true, true, false), + ["pie"] = new("Pie", "PieChart", "PieChartOptions", "PieChartDataset", typeof(PieChart), typeof(PieChartOptions), typeof(PieChartDataset), true, false, false), + ["polararea"] = new("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false), + ["polar-area"] = new("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false), + ["radar"] = new("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset", typeof(RadarChart), typeof(RadarChartOptions), typeof(RadarChartDataset), true, false, false), + ["scatter"] = new("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, + CommonInputs: + [ + "title", + "route", + "pageName", + "labelsJson", + "datasetsJson", + "width", + "height", + "legendPosition", + "datalabels" + ], + ChartSpecificInputs: GetChartSpecificInputs(definition), + Metadata: GetTypeMetadata(definition)); + } + + 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 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) => + 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..1f52bac8 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs @@ -0,0 +1,294 @@ +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 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, definition); + var labels = NormalizeLabels(request.Labels, definition); + var datasets = NormalizeDatasets(request.Datasets, labels, definition); + var width = request.Width is > 0 ? request.Width.Value : 700; + var height = request.Height is > 0 ? request.Height.Value : 400; + 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 chartField = LowerFirst(definition.ComponentName); + var optionsField = LowerFirst(definition.OptionsTypeName); + var code = new StringBuilder(); + + code.AppendLine($"@page \"{route}\""); + code.AppendLine("@using BlazorExpress.ChartJS"); + code.AppendLine(); + code.AppendLine($"

{EscapeHtml(title)}

"); + code.AppendLine(); + code.AppendLine($"<{definition.ComponentName} @ref=\"{chartField}\" Width=\"{width}\" Height=\"{height}\" />"); + code.AppendLine(); + code.AppendLine("@code {"); + code.AppendLine($" private {definition.ComponentName} {chartField} = default!;"); + code.AppendLine($" private {definition.OptionsTypeName} {optionsField} = default!;"); + code.AppendLine(" private ChartData chartData = default!;"); + code.AppendLine(); + code.AppendLine(" protected override void OnInitialized()"); + code.AppendLine(" {"); + code.AppendLine(" chartData = new ChartData"); + code.AppendLine(" {"); + code.AppendLine($" Labels = new List {{ {string.Join(", ", labels.Select(ToCSharpString))} }},"); + code.AppendLine(" Datasets = new List"); + code.AppendLine(" {"); + foreach (var dataset in datasets) + AppendDataset(code, definition, dataset, labels.Count, stacked); + code.AppendLine(" },"); + code.AppendLine(" };"); + code.AppendLine(); + code.AppendLine($" {optionsField} = new {definition.OptionsTypeName}"); + code.AppendLine(" {"); + code.AppendLine(" Responsive = true,"); + code.AppendLine(" MaintainAspectRatio = false,"); + if (definition.Name is "Bar" && horizontal) + code.AppendLine(" IndexAxis = \"y\","); + code.AppendLine(" };"); + code.AppendLine(); + AppendOptionsCustomization(code, definition, optionsField, title, legendPosition, stacked); + code.AppendLine(" }"); + code.AppendLine(); + code.AppendLine(" protected override async Task OnAfterRenderAsync(bool firstRender)"); + code.AppendLine(" {"); + code.AppendLine(" if (firstRender)"); + if (datalabels) + code.AppendLine($" await {chartField}.InitializeAsync(chartData: chartData, chartOptions: {optionsField}, plugins: new string[] {{ \"ChartDataLabels\" }});"); + else + code.AppendLine($" await {chartField}.InitializeAsync(chartData, {optionsField});"); + code.AppendLine(); + code.AppendLine(" await base.OnAfterRenderAsync(firstRender);"); + code.AppendLine(" }"); + code.AppendLine("}"); + + return new GeneratedChartExample( + ChartType: definition.Name, + Route: route, + PageName: pageName, + Code: code.ToString(), + RequiredScripts: RequiredScripts(datalabels)); + } + + 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))} }},"); + } + + code.AppendLine($" BackgroundColor = new List {{ {string.Join(", ", colors.Select(ToCSharpString))} }},"); + code.AppendLine($" BorderColor = new List {{ {string.Join(", ", borderColors.Select(ToCSharpString))} }},"); + + 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, ChartDefinition definition, string optionsField, string title, string legendPosition, bool stacked) + { + if (definition.Name is "Bubble") + { + code.AppendLine($" // BubbleChartOptions currently inherits the common ChartOptions surface."); + return; + } + + code.AppendLine($" {optionsField}.Plugins.Title!.Text = {ToCSharpString(title)};"); + code.AppendLine($" {optionsField}.Plugins.Title.Display = true;"); + code.AppendLine($" {optionsField}.Plugins.Legend.Position = {ToCSharpString(legendPosition)};"); + + if (stacked && definition.Name is "Bar" or "Line") + { + code.AppendLine($" {optionsField}.Scales.X!.Stacked = true;"); + code.AppendLine($" {optionsField}.Scales.Y!.Stacked = true;"); + } + } + + private static IReadOnlyList RequiredScripts(bool includeDatalabels) + { + var scripts = new List + { + "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js", + "_content/BlazorExpress.ChartJS/blazorexpress.chartjs.js" + }; + + if (includeDatalabels) + scripts.Insert(1, "https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js"); + + return scripts; + } + + 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; + + return DefaultColors.Take(Math.Max(1, Math.Min(count, DefaultColors.Length))).ToList(); + } + + 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, ChartDefinition definition) + { + if (string.IsNullOrWhiteSpace(route)) + return $"/charts/{ToKebabCase(definition.Name)}"; + + var normalized = route.Trim(); + 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); +} diff --git a/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs new file mode 100644 index 00000000..dd321052 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs @@ -0,0 +1,119 @@ +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() => + Json.Serialize(ChartCatalog.All.Select(x => new + { + x.Name, + x.ComponentName, + x.OptionsTypeName, + x.DatasetTypeName, + x.SupportsDatalabels, + x.SupportsStacking, + x.SupportsOrientation, + })); + + [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) => + Json.Serialize(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) + { + var request = CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation); + var generator = new ChartExampleGenerator(); + return Json.Serialize(generator.Generate(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) + { + var request = new PreviewIntegrationRequest + { + TargetProjectPath = targetProjectPath, + Chart = CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation), + }; + + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + return Json.Serialize(service.Preview(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) + { + var plan = Json.Deserialize(integrationPlanJson) + ?? throw new ArgumentException("integrationPlanJson must contain a serialized integration plan.", nameof(integrationPlanJson)); + + var service = new ProjectIntegrationService(new ChartExampleGenerator()); + return Json.Serialize(service.Apply(plan)); + } + + private 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 = Json.Deserialize>(labelsJson), + Datasets = Json.Deserialize>(datasetsJson), + Width = width, + Height = height, + LegendPosition = legendPosition, + Datalabels = datalabels, + Stacked = stacked, + Orientation = orientation, + }; +} diff --git a/BlazorExpress.ChartJS.MCP/Json.cs b/BlazorExpress.ChartJS.MCP/Json.cs new file mode 100644 index 00000000..d817ebcf --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Json.cs @@ -0,0 +1,23 @@ +using System.Text.Json; + +namespace BlazorExpress.ChartJS.MCP; + +public static class Json +{ + public static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + 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/Models.cs b/BlazorExpress.ChartJS.MCP/Models.cs new file mode 100644 index 00000000..b45ccf41 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Models.cs @@ -0,0 +1,108 @@ +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); + +public sealed record ChartGenerationSchema( + string ChartType, + string Component, + string OptionsType, + string DatasetType, + bool SupportsDatalabels, + bool SupportsStacking, + bool SupportsOrientation, + IReadOnlyList CommonInputs, + IReadOnlyList ChartSpecificInputs, + 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 PreviewIntegrationRequest +{ + public string TargetProjectPath { get; init; } = ""; + public ChartGenerationRequest Chart { 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); diff --git a/BlazorExpress.ChartJS.MCP/Program.cs b/BlazorExpress.ChartJS.MCP/Program.cs new file mode 100644 index 00000000..1f00d1eb --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/Program.cs @@ -0,0 +1,15 @@ +using BlazorExpress.ChartJS.MCP; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Server; + +var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings { Args = args }); + +builder.Services + .AddSingleton() + .AddSingleton() + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + +await builder.Build().RunAsync(); diff --git a/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs new file mode 100644 index 00000000..dfb77a3c --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs @@ -0,0 +1,309 @@ +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 readonly ChartExampleGenerator generator; + + public ProjectIntegrationService(ChartExampleGenerator generator) + { + this.generator = generator; + } + + public IntegrationPlan Preview(PreviewIntegrationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var project = ResolveProject(request.TargetProjectPath); + var generated = generator.Generate(request.Chart); + 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, request.Chart.Title ?? generated.ChartType + " Chart", 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 ArgumentException("A target project path is required.", nameof(targetProjectPath)); + + var path = Path.GetFullPath(targetProjectPath); + if (File.Exists(path) && string.Equals(Path.GetExtension(path), ".csproj", StringComparison.OrdinalIgnoreCase)) + return new ProjectContext(path, Path.GetDirectoryName(path)!); + + if (!Directory.Exists(path)) + throw new DirectoryNotFoundException($"Target project path was not found: {path}"); + + var projectFiles = Directory.GetFiles(path, "*.csproj", SearchOption.TopDirectoryOnly); + if (projectFiles.Length == 0) + throw new FileNotFoundException($"No .csproj file was found in {path}."); + if (projectFiles.Length > 1) + throw new InvalidOperationException($"Multiple .csproj files were found in {path}. Pass the exact project file path."); + + return new ProjectContext(projectFiles[0], path); + } + + 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, GeneratedChartExample 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 {generated.ChartType} chart page.", 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); +} diff --git a/BlazorExpress.ChartJS.MCP/README.md b/BlazorExpress.ChartJS.MCP/README.md new file mode 100644 index 00000000..5dbd8405 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP/README.md @@ -0,0 +1,137 @@ +# 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 server uses stdio transport and exposes tools for listing supported chart types, generating complete Razor examples, previewing project integration edits, and applying approved integration plans. + +## 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 +blazorexpress-chartjs-mcp +``` + +The command starts a stdio MCP server. It is expected to keep running and wait for an MCP client to send requests. + +## 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" + } + } +} +``` + +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` or `generate_chart_example`. + +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` +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" + } + } +} +``` + +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} From 144582d4207f1aaa1775de29caa7dd120970367f Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Tue, 7 Jul 2026 00:12:48 +0530 Subject: [PATCH 2/4] Refactor MCP server implementation and add unit tests for option parsing --- .../BlazorExpress.ChartJS.MCP.Tests.csproj | 1 + .../McpApplicationTests.cs | 115 +++++++++++ .../BlazorExpress.ChartJS.MCP.csproj | 3 +- BlazorExpress.ChartJS.MCP/McpApplication.cs | 189 ++++++++++++++++++ BlazorExpress.ChartJS.MCP/Program.cs | 14 +- BlazorExpress.ChartJS.MCP/README.md | 83 +++++++- 6 files changed, 387 insertions(+), 18 deletions(-) create mode 100644 BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs create mode 100644 BlazorExpress.ChartJS.MCP/McpApplication.cs diff --git a/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj index 55fa8c30..1cc2b4fa 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj +++ b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj @@ -8,6 +8,7 @@ + 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/BlazorExpress.ChartJS.MCP.csproj b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj index 07b90cb1..ee2412d9 100644 --- a/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj +++ b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj @@ -21,8 +21,9 @@ - + + 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/Program.cs b/BlazorExpress.ChartJS.MCP/Program.cs index 1f00d1eb..3eeac2fe 100644 --- a/BlazorExpress.ChartJS.MCP/Program.cs +++ b/BlazorExpress.ChartJS.MCP/Program.cs @@ -1,15 +1,3 @@ using BlazorExpress.ChartJS.MCP; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using ModelContextProtocol.Server; -var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings { Args = args }); - -builder.Services - .AddSingleton() - .AddSingleton() - .AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(); - -await builder.Build().RunAsync(); +await McpApplication.RunAsync(args); diff --git a/BlazorExpress.ChartJS.MCP/README.md b/BlazorExpress.ChartJS.MCP/README.md index 5dbd8405..b320a320 100644 --- a/BlazorExpress.ChartJS.MCP/README.md +++ b/BlazorExpress.ChartJS.MCP/README.md @@ -16,7 +16,24 @@ dotnet tool install --global BlazorExpress.ChartJS.MCP blazorexpress-chartjs-mcp ``` -The server uses stdio transport and exposes tools for listing supported chart types, generating complete Razor examples, previewing project integration edits, and applying approved integration plans. +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, previewing project integration edits, and applying approved integration plans. + +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 @@ -47,10 +64,59 @@ dotnet tool update --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpre Run the MCP server: ```powershell +$env:ASPNETCORE_ENVIRONMENT = "Development" blazorexpress-chartjs-mcp ``` -The command starts a stdio MCP server. It is expected to keep running and wait for an MCP client to send requests. +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 @@ -74,12 +140,19 @@ Create or update `.vscode/mcp.json` in your workspace: "servers": { "blazorexpress-chartjs": { "type": "stdio", - "command": "blazorexpress-chartjs-mcp" + "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. @@ -105,6 +178,7 @@ Option 1: use Visual Studio chat. - Name: `blazorexpress-chartjs` - Transport: `stdio` - Command: `blazorexpress-chartjs-mcp` + - Arguments: `--stdio` 7. Save the server. 8. Enable the MCP tools from the Tools picker. @@ -123,7 +197,8 @@ Use this configuration: "servers": { "blazorexpress-chartjs": { "type": "stdio", - "command": "blazorexpress-chartjs-mcp" + "command": "blazorexpress-chartjs-mcp", + "args": ["--stdio"] } } } From c04710de1713fdad351d3d3d9760efdd3d77eb57 Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Tue, 7 Jul 2026 11:31:38 +0530 Subject: [PATCH 3/4] feat: Enhance chart generation tools and add dashboard support - Refactored chart generation methods to utilize McpToolExecution for error handling. - Introduced GenerateChartDashboard method for creating multi-chart dashboard pages. - Added PreviewDashboardIntegration method for previewing dashboard integration plans. - Updated ChartDefinition and ChartGenerationSchema to include new options for plugins and titles. - Implemented ChartRequestParser for parsing chart requests and improved error handling. - Added unit tests for chart generation and dashboard functionalities. - Updated README to reflect new features and tools available. - Introduced McpToolExecution class for centralized error handling in tool execution. - Added launch settings for easier development and debugging. --- .../ChartCatalogTests.cs | 44 +++ .../ChartExampleGeneratorTests.cs | 187 ++++++++++++ .../ChartJsMcpToolsTests.cs | 111 +++++++ .../ProjectIntegrationServiceTests.cs | 42 +++ BlazorExpress.ChartJS.MCP/ChartCatalog.cs | 80 ++++- .../ChartExampleGenerator.cs | 268 +++++++++++++---- BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs | 124 ++++---- .../ChartRequestParser.cs | 278 ++++++++++++++++++ BlazorExpress.ChartJS.MCP/Json.cs | 1 + BlazorExpress.ChartJS.MCP/McpToolExecution.cs | 47 +++ BlazorExpress.ChartJS.MCP/Models.cs | 53 +++- .../ProjectIntegrationService.cs | 45 ++- .../Properties/launchSettings.json | 21 ++ BlazorExpress.ChartJS.MCP/README.md | 16 +- 14 files changed, 1188 insertions(+), 129 deletions(-) create mode 100644 BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs create mode 100644 BlazorExpress.ChartJS.MCP/ChartRequestParser.cs create mode 100644 BlazorExpress.ChartJS.MCP/McpToolExecution.cs create mode 100644 BlazorExpress.ChartJS.MCP/Properties/launchSettings.json diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs index 7e1bf9bf..9bee7d9a 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs @@ -21,5 +21,49 @@ public void GetSchema_Returns_Metadata_For_Chart_Type(string chartType, string c 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 index 10de4ae3..c407d4ec 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs @@ -33,4 +33,191 @@ public void Generate_Bar_Datalabels_Uses_Plugin() 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 output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(120_000); + + Assert.True(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..7a382914 --- /dev/null +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs @@ -0,0 +1,111 @@ +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 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()); + } +} diff --git a/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs index 7638dcba..29e2c106 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs +++ b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs @@ -18,6 +18,10 @@ public void Preview_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files() 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"))); } @@ -56,6 +60,44 @@ public void Apply_Rejects_Stale_Preview() 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}"); diff --git a/BlazorExpress.ChartJS.MCP/ChartCatalog.cs b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs index c479be7f..2980b005 100644 --- a/BlazorExpress.ChartJS.MCP/ChartCatalog.cs +++ b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs @@ -7,15 +7,15 @@ public static class ChartCatalog { private static readonly IReadOnlyDictionary DefinitionsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["bar"] = new("Bar", "BarChart", "BarChartOptions", "BarChartDataset", typeof(BarChart), typeof(BarChartOptions), typeof(BarChartDataset), true, true, true), - ["bubble"] = new("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset", typeof(BubbleChart), typeof(BubbleChartOptions), typeof(BubbleChartDataset), true, false, false), - ["doughnut"] = new("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset", typeof(DoughnutChart), typeof(DoughnutChartOptions), typeof(DoughnutChartDataset), true, false, false), - ["line"] = new("Line", "LineChart", "LineChartOptions", "LineChartDataset", typeof(LineChart), typeof(LineChartOptions), typeof(LineChartDataset), true, true, false), - ["pie"] = new("Pie", "PieChart", "PieChartOptions", "PieChartDataset", typeof(PieChart), typeof(PieChartOptions), typeof(PieChartDataset), true, false, false), - ["polararea"] = new("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false), - ["polar-area"] = new("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false), - ["radar"] = new("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset", typeof(RadarChart), typeof(RadarChartOptions), typeof(RadarChartDataset), true, false, false), - ["scatter"] = new("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset", typeof(ScatterChart), typeof(ScatterChartOptions), typeof(ScatterChartDataset), true, false, false), + ["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 @@ -46,6 +46,9 @@ public static ChartGenerationSchema GetSchema(string chartType) SupportsDatalabels: definition.SupportsDatalabels, SupportsStacking: definition.SupportsStacking, SupportsOrientation: definition.SupportsOrientation, + SupportsPluginOptions: definition.SupportsPluginOptions, + SupportsTitleOptions: definition.SupportsTitleOptions, + SupportsLegendOptions: definition.SupportsLegendOptions, CommonInputs: [ "title", @@ -59,9 +62,46 @@ public static ChartGenerationSchema GetSchema(string chartType) "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(); @@ -90,6 +130,20 @@ private static IReadOnlyList GetChartSpecificInputs(ChartDefinition defi ["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; @@ -104,8 +158,14 @@ private static IReadOnlyList GetPublicPropertyMetadata(Type ty .OrderBy(x => x.Name) .ToList(); - private static string NormalizeKey(string value) => + 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 index 1f52bac8..e961025b 100644 --- a/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs +++ b/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs @@ -20,76 +20,176 @@ public GeneratedChartExample Generate(ChartGenerationRequest request) { ArgumentNullException.ThrowIfNull(request); - 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, definition); - var labels = NormalizeLabels(request.Labels, definition); - var datasets = NormalizeDatasets(request.Datasets, labels, definition); - var width = request.Width is > 0 ? request.Width.Value : 700; - var height = request.Height is > 0 ? request.Height.Value : 400; - 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 chartField = LowerFirst(definition.ComponentName); - var optionsField = LowerFirst(definition.OptionsTypeName); + 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($"<{definition.ComponentName} @ref=\"{chartField}\" Width=\"{width}\" Height=\"{height}\" />"); + 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 {"); - code.AppendLine($" private {definition.ComponentName} {chartField} = default!;"); - code.AppendLine($" private {definition.OptionsTypeName} {optionsField} = default!;"); - code.AppendLine(" private ChartData chartData = default!;"); - code.AppendLine(); + foreach (var model in models) + { + AppendChartFields(code, model); + code.AppendLine(); + } code.AppendLine(" protected override void OnInitialized()"); code.AppendLine(" {"); - code.AppendLine(" chartData = new ChartData"); + 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(", ", labels.Select(ToCSharpString))} }},"); + code.AppendLine($" Labels = new List {{ {string.Join(", ", model.Labels.Select(ToCSharpString))} }},"); code.AppendLine(" Datasets = new List"); code.AppendLine(" {"); - foreach (var dataset in datasets) - AppendDataset(code, definition, dataset, labels.Count, stacked); + foreach (var dataset in model.Datasets) + AppendDataset(code, model.Definition, dataset, model.Labels.Count, model.Stacked); code.AppendLine(" },"); code.AppendLine(" };"); code.AppendLine(); - code.AppendLine($" {optionsField} = new {definition.OptionsTypeName}"); + code.AppendLine($" {model.OptionsField} = new {model.Definition.OptionsTypeName}"); code.AppendLine(" {"); code.AppendLine(" Responsive = true,"); code.AppendLine(" MaintainAspectRatio = false,"); - if (definition.Name is "Bar" && horizontal) + if (model.Definition.Name is "Bar" && model.Horizontal) code.AppendLine(" IndexAxis = \"y\","); code.AppendLine(" };"); code.AppendLine(); - AppendOptionsCustomization(code, definition, optionsField, title, legendPosition, stacked); - 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)"); - if (datalabels) - code.AppendLine($" await {chartField}.InitializeAsync(chartData: chartData, chartOptions: {optionsField}, plugins: new string[] {{ \"ChartDataLabels\" }});"); - else - code.AppendLine($" await {chartField}.InitializeAsync(chartData, {optionsField});"); + 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(" }"); - code.AppendLine("}"); - - return new GeneratedChartExample( - ChartType: definition.Name, - Route: route, - PageName: pageName, - Code: code.ToString(), - RequiredScripts: RequiredScripts(datalabels)); } private static void AppendDataset(StringBuilder code, ChartDefinition definition, ChartDatasetRequest dataset, int labelCount, bool stacked) @@ -117,49 +217,62 @@ private static void AppendDataset(StringBuilder code, ChartDefinition definition code.AppendLine($" Data = new List {{ {string.Join(", ", values.Select(FormatNullableNumber))} }},"); } - code.AppendLine($" BackgroundColor = new List {{ {string.Join(", ", colors.Select(ToCSharpString))} }},"); - code.AppendLine($" BorderColor = new List {{ {string.Join(", ", borderColors.Select(ToCSharpString))} }},"); + 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")) + if (stacked && !string.IsNullOrWhiteSpace(dataset.Stack) && definition.Name is "Bar") code.AppendLine($" Stack = {ToCSharpString(dataset.Stack)},"); code.AppendLine(" },"); } - private static void AppendOptionsCustomization(StringBuilder code, ChartDefinition definition, string optionsField, string title, string legendPosition, bool stacked) + private static void AppendOptionsCustomization(StringBuilder code, ChartRenderModel model) { - if (definition.Name is "Bubble") + var definition = model.Definition; + + if (definition.SupportsTitleOptions) { - code.AppendLine($" // BubbleChartOptions currently inherits the common ChartOptions surface."); - return; + code.AppendLine($" {model.OptionsField}.Plugins.Title!.Text = {ToCSharpString(model.Title)};"); + code.AppendLine($" {model.OptionsField}.Plugins.Title.Display = true;"); } - code.AppendLine($" {optionsField}.Plugins.Title!.Text = {ToCSharpString(title)};"); - code.AppendLine($" {optionsField}.Plugins.Title.Display = true;"); - code.AppendLine($" {optionsField}.Plugins.Legend.Position = {ToCSharpString(legendPosition)};"); + 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;"); + } + } - if (stacked && definition.Name is "Bar" or "Line") + 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($" {optionsField}.Scales.X!.Stacked = true;"); - code.AppendLine($" {optionsField}.Scales.Y!.Stacked = true;"); + code.AppendLine($" {propertyName} = {ToCSharpString(colors.FirstOrDefault() ?? DefaultColors[0])},"); + return; } + + code.AppendLine($" {propertyName} = new List {{ {string.Join(", ", colors.Select(ToCSharpString))} }},"); } - private static IReadOnlyList RequiredScripts(bool includeDatalabels) + private static IReadOnlyList RequiredScripts(IReadOnlyList models) { var scripts = new List { "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js", - "_content/BlazorExpress.ChartJS/blazorexpress.chartjs.js" }; - if (includeDatalabels) - scripts.Insert(1, "https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.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"); - return scripts; + scripts.Add("_content/BlazorExpress.ChartJS/blazorexpress.chartjs.js"); + return scripts.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } private static IReadOnlyList NormalizeLabels(IReadOnlyList? labels, ChartDefinition definition) @@ -232,11 +345,22 @@ private static IReadOnlyList NormalizePoints(ChartDatasetRequ private static IReadOnlyList NormalizeColors(IReadOnlyList? colors, int count) { if (colors is { Count: > 0 }) - return colors; + 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(); @@ -247,12 +371,15 @@ private static IReadOnlyList Pad(IReadOnlyList values, int count, T fal return result; } - private static string NormalizeRoute(string? route, ChartDefinition definition) + private static string NormalizeRoute(string? route, string fallback, string field) { if (string.IsNullOrWhiteSpace(route)) - return $"/charts/{ToKebabCase(definition.Name)}"; + 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}"; } @@ -291,4 +418,21 @@ private static string FormatNullableNumber(double? value) => 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 index dd321052..dde3e23b 100644 --- a/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs +++ b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs @@ -9,7 +9,7 @@ public static class ChartJsMcpTools [McpServerTool(Name = "list_chart_types")] [Description("Lists chart types supported by BlazorExpress.ChartJS code generation.")] public static string ListChartTypes() => - Json.Serialize(ChartCatalog.All.Select(x => new + McpToolExecution.Run(() => ChartCatalog.All.Select(x => new { x.Name, x.ComponentName, @@ -18,6 +18,9 @@ public static string ListChartTypes() => x.SupportsDatalabels, x.SupportsStacking, x.SupportsOrientation, + x.SupportsPluginOptions, + x.SupportsTitleOptions, + x.SupportsLegendOptions, })); [McpServerTool(Name = "get_chart_generation_schema")] @@ -25,7 +28,7 @@ public static string ListChartTypes() => public static string GetChartGenerationSchema( [Description("Chart type, for example Bar, Line, Pie, Doughnut, Bubble, Scatter, Radar, or PolarArea.")] string chartType) => - Json.Serialize(ChartCatalog.GetSchema(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 }.")] @@ -42,11 +45,33 @@ public static string GenerateChartExample( bool? datalabels = null, bool? stacked = null, string? orientation = null) - { - var request = CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation); - var generator = new ChartExampleGenerator(); - return Json.Serialize(generator.Generate(request)); - } + => 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.")] @@ -64,56 +89,55 @@ public static string PreviewProjectIntegration( bool? datalabels = null, bool? stacked = null, string? orientation = null) - { - var request = new PreviewIntegrationRequest + => 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(() => { - TargetProjectPath = targetProjectPath, - Chart = CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation), - }; + 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 Json.Serialize(service.Preview(request)); - } + 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) - { - var plan = Json.Deserialize(integrationPlanJson) - ?? throw new ArgumentException("integrationPlanJson must contain a serialized integration plan.", nameof(integrationPlanJson)); - - var service = new ProjectIntegrationService(new ChartExampleGenerator()); - return Json.Serialize(service.Apply(plan)); - } - - private 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() + => McpToolExecution.Run(() => { - ChartType = chartType, - Title = title, - Route = route, - PageName = pageName, - Labels = Json.Deserialize>(labelsJson), - Datasets = Json.Deserialize>(datasetsJson), - Width = width, - Height = height, - LegendPosition = legendPosition, - Datalabels = datalabels, - Stacked = stacked, - Orientation = orientation, - }; + 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 index d817ebcf..d0e026aa 100644 --- a/BlazorExpress.ChartJS.MCP/Json.cs +++ b/BlazorExpress.ChartJS.MCP/Json.cs @@ -7,6 +7,7 @@ public static class Json public static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, WriteIndented = true, }; 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 index b45ccf41..014e21dd 100644 --- a/BlazorExpress.ChartJS.MCP/Models.cs +++ b/BlazorExpress.ChartJS.MCP/Models.cs @@ -12,7 +12,11 @@ public sealed record ChartDefinition( Type DatasetType, bool SupportsDatalabels, bool SupportsStacking, - bool SupportsOrientation); + bool SupportsOrientation, + bool SupportsPluginOptions, + bool SupportsTitleOptions, + bool SupportsLegendOptions, + bool SupportsScales); public sealed record ChartGenerationSchema( string ChartType, @@ -22,8 +26,12 @@ public sealed record ChartGenerationSchema( bool SupportsDatalabels, bool SupportsStacking, bool SupportsOrientation, + bool SupportsPluginOptions, + bool SupportsTitleOptions, + bool SupportsLegendOptions, IReadOnlyList CommonInputs, IReadOnlyList ChartSpecificInputs, + IReadOnlyDictionary Examples, IReadOnlyDictionary Metadata); public sealed record PropertyMetadata( @@ -77,12 +85,33 @@ public sealed record GeneratedChartExample( 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; } = ""; @@ -106,3 +135,25 @@ 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/ProjectIntegrationService.cs b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs index dfb77a3c..6d9fed0b 100644 --- a/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs +++ b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs @@ -22,8 +22,37 @@ public IntegrationPlan Preview(PreviewIntegrationRequest request) { ArgumentNullException.ThrowIfNull(request); - var project = ResolveProject(request.TargetProjectPath); 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(); @@ -32,7 +61,7 @@ public IntegrationPlan Preview(PreviewIntegrationRequest request) AddImportsEdit(project.RootDirectory, edits); AddScriptEdit(project, hostModel, generated.RequiredScripts, edits, manualSteps); AddPageEdit(project, hostModel, generated, edits); - AddNavigationEdit(project.RootDirectory, generated.Route, request.Chart.Title ?? generated.ChartType + " Chart", edits, manualSteps); + AddNavigationEdit(project.RootDirectory, generated.Route, generated.Title, edits, manualSteps); var plan = new IntegrationPlan { @@ -201,7 +230,7 @@ private static void AddScriptEdit(ProjectContext project, string hostModel, IRea AddReplaceEdit(scriptFile, content, newContent, "Add BlazorExpress.ChartJS script references.", edits); } - private static void AddPageEdit(ProjectContext project, string hostModel, GeneratedChartExample generated, List edits) + private static void AddPageEdit(ProjectContext project, string hostModel, GeneratedPage generated, List edits) { var pagesDirectory = hostModel switch { @@ -212,7 +241,7 @@ _ when Directory.Exists(Path.Combine(project.RootDirectory, "Pages")) => Path.Co 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 {generated.ChartType} chart page.", edits); + 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) @@ -306,4 +335,12 @@ private static string HashText(string text) } private sealed record ProjectContext(string ProjectFilePath, string RootDirectory); + + 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 index b320a320..27133f28 100644 --- a/BlazorExpress.ChartJS.MCP/README.md +++ b/BlazorExpress.ChartJS.MCP/README.md @@ -18,7 +18,19 @@ 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, previewing project integration edits, and applying approved integration plans. +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. @@ -159,7 +171,7 @@ In VS Code: 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` or `generate_chart_example`. +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. From edd3e1994ecd8686c7226e7adde0612ab702e31c Mon Sep 17 00:00:00 2001 From: Vikram Reddy Date: Wed, 8 Jul 2026 01:29:58 +0530 Subject: [PATCH 4/4] feat: Add project integration tests and enhance project resolution logic --- .../ChartExampleGeneratorTests.cs | 16 +- .../ChartJsMcpToolsTests.cs | 87 +++++++++ .../ProjectIntegrationServiceTests.cs | 170 ++++++++++++++++-- .../ProjectIntegrationService.cs | 114 +++++++++++- 4 files changed, 364 insertions(+), 23 deletions(-) diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs index c407d4ec..a0e470c5 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs @@ -193,11 +193,19 @@ public void Build() process.StartInfo.UseShellExecute = false; process.Start(); - var output = process.StandardOutput.ReadToEnd(); - var error = process.StandardError.ReadToEnd(); - process.WaitForExit(120_000); + 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(process.ExitCode == 0, output + Environment.NewLine + error); + Assert.True(exited && process.ExitCode == 0, output + Environment.NewLine + error); } public void Dispose() diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs index 7a382914..d67dfa8a 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs +++ b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs @@ -44,6 +44,45 @@ public void PreviewProjectIntegration_Returns_Structured_Error_For_Invalid_Targe 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() { @@ -108,4 +147,52 @@ private static void AssertToolError(string json, string? expectedField = null) 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/ProjectIntegrationServiceTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs index 29e2c106..6c95f0b4 100644 --- a/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs +++ b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs @@ -25,6 +25,113 @@ public void Preview_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files() 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() { @@ -102,35 +209,78 @@ private sealed class TemporaryWorkspace : IDisposable { private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tests-{Guid.NewGuid():N}"); - public string CreateWebAssemblyProject() + public string Root => root; + + public string CreateWebAssemblyProject(string relativePath = "", string projectName = "Sample") { - Directory.CreateDirectory(root); - Directory.CreateDirectory(Path.Combine(root, "wwwroot")); - Directory.CreateDirectory(Path.Combine(root, "Pages")); - Directory.CreateDirectory(Path.Combine(root, "Shared")); + 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(root, "Sample.csproj"), """ + File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """ net10.0 """); - File.WriteAllText(Path.Combine(root, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine); - File.WriteAllText(Path.Combine(root, "wwwroot", "index.html"), """ + 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(root, "Shared", "NavMenu.razor"), """ + File.WriteAllText(Path.Combine(projectRoot, "Shared", "NavMenu.razor"), """ """); - return root; + 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() diff --git a/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs index 6d9fed0b..7e804432 100644 --- a/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs +++ b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs @@ -10,6 +10,15 @@ 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; @@ -120,22 +129,107 @@ public static string ComputePlanHash(IntegrationPlan plan) private static ProjectContext ResolveProject(string targetProjectPath) { if (string.IsNullOrWhiteSpace(targetProjectPath)) - throw new ArgumentException("A target project path is required.", nameof(targetProjectPath)); + throw new ToolInputException("A target project path is required.", "targetProjectPath"); var path = Path.GetFullPath(targetProjectPath); - if (File.Exists(path) && string.Equals(Path.GetExtension(path), ".csproj", StringComparison.OrdinalIgnoreCase)) - return new ProjectContext(path, Path.GetDirectoryName(path)!); + 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}"); - var projectFiles = Directory.GetFiles(path, "*.csproj", SearchOption.TopDirectoryOnly); - if (projectFiles.Length == 0) - throw new FileNotFoundException($"No .csproj file was found in {path}."); - if (projectFiles.Length > 1) - throw new InvalidOperationException($"Multiple .csproj files were found in {path}. Pass the exact project file 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 new ProjectContext(projectFiles[0], path); + 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) @@ -336,6 +430,8 @@ private static string HashText(string text) 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,