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}