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}