+ Selected: @selectedChartItem.DatasetLabel (dataset @selectedChartItem.DatasetIndex), @selectedChartItem.Label (index @selectedChartItem.Index), value @selectedChartItem.Value
+
+}
Randomize
@@ -12,6 +19,7 @@
private BarChart barChart = default!;
private BarChartOptions barChartOptions = default!;
private ChartData chartData = default!;
+ private ChartClickEventArgs? selectedChartItem;
private int datasetsCount = 0;
private int labelsCount = 0;
@@ -33,6 +41,12 @@
await base.OnAfterRenderAsync(firstRender);
}
+ private Task HandleClickAsync(ChartClickEventArgs eventArgs)
+ {
+ selectedChartItem = eventArgs;
+ return Task.CompletedTask;
+ }
+
private async Task RandomizeAsync()
{
if (chartData is null || chartData.Datasets is null || !chartData.Datasets.Any()) return;
diff --git a/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj
new file mode 100644
index 00000000..1cc2b4fa
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs
new file mode 100644
index 00000000..9bee7d9a
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs
@@ -0,0 +1,69 @@
+namespace BlazorExpress.ChartJS.MCP.Tests;
+
+public class ChartCatalogTests
+{
+ [Fact]
+ public void All_Returns_Current_Public_Chart_Types()
+ {
+ var names = ChartCatalog.All.Select(x => x.Name).ToArray();
+
+ Assert.Equal(["Bar", "Bubble", "Doughnut", "Line", "Pie", "PolarArea", "Radar", "Scatter"], names);
+ }
+
+ [Theory]
+ [InlineData("Bar", "BarChart", "BarChartOptions", "BarChartDataset")]
+ [InlineData("polar-area", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset")]
+ public void GetSchema_Returns_Metadata_For_Chart_Type(string chartType, string component, string options, string dataset)
+ {
+ var schema = ChartCatalog.GetSchema(chartType);
+
+ Assert.Equal(component, schema.Component);
+ Assert.Equal(options, schema.OptionsType);
+ Assert.Equal(dataset, schema.DatasetType);
+ Assert.NotEmpty(schema.CommonInputs);
+ Assert.NotEmpty(schema.Examples);
+ }
+
+ [Fact]
+ public void Radar_Is_Not_Datalabel_Safe()
+ {
+ var schema = ChartCatalog.GetSchema("Radar");
+
+ Assert.False(schema.SupportsDatalabels);
+ }
+
+ [Theory]
+ [InlineData("Bubble")]
+ [InlineData("Radar")]
+ public void Charts_Without_Plugins_Report_No_Title_Or_Legend_Support(string chartType)
+ {
+ var schema = ChartCatalog.GetSchema(chartType);
+
+ Assert.False(schema.SupportsPluginOptions);
+ Assert.False(schema.SupportsTitleOptions);
+ Assert.False(schema.SupportsLegendOptions);
+ }
+
+ [Theory]
+ [InlineData("Bar")]
+ [InlineData("Scatter")]
+ public void Charts_With_Plugins_Report_Title_And_Legend_Support(string chartType)
+ {
+ var schema = ChartCatalog.GetSchema(chartType);
+
+ Assert.True(schema.SupportsPluginOptions);
+ Assert.True(schema.SupportsTitleOptions);
+ Assert.True(schema.SupportsLegendOptions);
+ }
+
+ [Theory]
+ [InlineData("Bar", "numericDatasetsJson")]
+ [InlineData("Scatter", "pointDatasetsJson")]
+ [InlineData("Bubble", "pointDatasetsJson")]
+ public void GetSchema_Includes_Dataset_Examples(string chartType, string exampleKey)
+ {
+ var schema = ChartCatalog.GetSchema(chartType);
+
+ Assert.True(schema.Examples.ContainsKey(exampleKey));
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartClickEventArgsTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartClickEventArgsTests.cs
new file mode 100644
index 00000000..8e5c78da
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/ChartClickEventArgsTests.cs
@@ -0,0 +1,87 @@
+using BlazorExpress.ChartJS;
+using Microsoft.AspNetCore.Components;
+using System.Text.Json;
+
+namespace BlazorExpress.ChartJS.MCP.Tests;
+
+public class ChartClickEventArgsTests
+{
+ [Fact]
+ public async Task HandleClickAsync_Invokes_Configured_Callback_With_Selected_Item_Metadata()
+ {
+ var component = new TestChartComponent();
+ ChartClickEventArgs? receivedEventArgs = null;
+ component.ConfigureOnClick(EventCallback.Factory.Create
(new object(), eventArgs => receivedEventArgs = eventArgs));
+
+ using var document = JsonDocument.Parse("""{"value":42}""");
+ var eventArgs = new ChartClickEventArgs
+ {
+ DatasetIndex = 1,
+ DatasetLabel = "Revenue",
+ Index = 2,
+ Label = "March",
+ Value = document.RootElement.Clone()
+ };
+
+ await component.HandleClickAsync(eventArgs);
+
+ Assert.NotNull(receivedEventArgs);
+ Assert.Equal(1, receivedEventArgs.DatasetIndex);
+ Assert.Equal("Revenue", receivedEventArgs.DatasetLabel);
+ Assert.Equal(2, receivedEventArgs.Index);
+ Assert.Equal("March", receivedEventArgs.Label);
+ Assert.Equal(42, receivedEventArgs.Value?.GetProperty("value").GetInt32());
+ }
+
+ [Fact]
+ public async Task HandleClickAsync_Without_Callback_Completes_Without_Throwing()
+ {
+ var component = new TestChartComponent();
+
+ var exception = await Record.ExceptionAsync(() => component.HandleClickAsync(new ChartClickEventArgs
+ {
+ DatasetIndex = 0,
+ Index = 0,
+ Value = null
+ }));
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void JavaScript_Click_Bridge_Maps_Selected_Item_And_Protects_No_Item_Path()
+ {
+ var source = File.ReadAllText(GetRepositoryFile("BlazorExpress.ChartJS", "wwwroot", "blazorexpress.chartjs.js"));
+
+ Assert.Equal(9, source.Split("initialize: (elementId, type, data, options, plugins, dotNetObjectReference)").Length - 1);
+ Assert.Contains("chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }, true)", source);
+ Assert.Contains("if (!activeElements || activeElements.length === 0) return;", source);
+ Assert.Contains("dotNetObjectReference.invokeMethodAsync('HandleClickAsync'", source);
+ Assert.Contains("datasetIndex: activeElement.datasetIndex", source);
+ Assert.Contains("datasetLabel: dataset?.label ?? null", source);
+ Assert.Contains("index: activeElement.index", source);
+ Assert.Contains("label: chart.data.labels?.[activeElement.index] ?? null", source);
+ Assert.Contains("value: dataset?.data?.[activeElement.index] ?? null", source);
+ Assert.DoesNotContain("options.onClick", source);
+ }
+
+ private static string GetRepositoryFile(params string[] pathSegments)
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "BlazorExpress.ChartJS.sln")))
+ return Path.Combine([directory.FullName, .. pathSegments]);
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("The ChartJS repository root could not be located.");
+ }
+
+ private sealed class TestChartComponent : ChartComponentBase
+ {
+ public void ConfigureOnClick(EventCallback onClick) => OnClick = onClick;
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs
new file mode 100644
index 00000000..a0e470c5
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs
@@ -0,0 +1,231 @@
+namespace BlazorExpress.ChartJS.MCP.Tests;
+
+public class ChartExampleGeneratorTests
+{
+ [Theory]
+ [InlineData("Bar", "BarChart", "BarChartOptions", "BarChartDataset")]
+ [InlineData("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset")]
+ [InlineData("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset")]
+ [InlineData("Line", "LineChart", "LineChartOptions", "LineChartDataset")]
+ [InlineData("Pie", "PieChart", "PieChartOptions", "PieChartDataset")]
+ [InlineData("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset")]
+ [InlineData("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset")]
+ [InlineData("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset")]
+ public void Generate_Includes_Expected_Razor_Types_For_All_Charts(string chartType, string component, string options, string dataset)
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.Generate(new ChartGenerationRequest { ChartType = chartType, Title = $"{chartType} Sample" });
+
+ Assert.Contains($"<{component} @ref=", generated.Code);
+ Assert.Contains($"private {options}", generated.Code);
+ Assert.Contains($"new {dataset}", generated.Code);
+ Assert.Contains("@using BlazorExpress.ChartJS", generated.Code);
+ }
+
+ [Fact]
+ public void Generate_Bar_Datalabels_Uses_Plugin()
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.Generate(new ChartGenerationRequest { ChartType = "Bar", Datalabels = true });
+
+ Assert.Contains("ChartDataLabels", generated.Code);
+ Assert.Contains("chartjs-plugin-datalabels", string.Join(" ", generated.RequiredScripts));
+ }
+
+ [Fact]
+ public void Generate_Radar_Does_Not_Use_Datalabels()
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.Generate(new ChartGenerationRequest { ChartType = "Radar", Datalabels = true });
+
+ Assert.DoesNotContain("ChartDataLabels", generated.Code);
+ Assert.DoesNotContain("chartjs-plugin-datalabels", string.Join(" ", generated.RequiredScripts));
+ }
+
+ [Theory]
+ [InlineData("Bubble")]
+ [InlineData("Radar")]
+ public void Generate_Charts_Without_Plugin_Options_Do_Not_Emit_Plugin_Title(string chartType)
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.Generate(new ChartGenerationRequest { ChartType = chartType });
+
+ Assert.DoesNotContain(".Plugins.Title", generated.Code);
+ Assert.DoesNotContain(".Plugins.Legend", generated.Code);
+ }
+
+ [Fact]
+ public void Generate_Scatter_Uses_Point_Data()
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.Generate(new ChartGenerationRequest
+ {
+ ChartType = "Scatter",
+ Datasets =
+ [
+ new()
+ {
+ Label = "Samples",
+ Points =
+ [
+ new ChartPointRequest { X = 1, Y = 2 },
+ new ChartPointRequest { X = 3, Y = 4 },
+ ],
+ },
+ ],
+ });
+
+ Assert.Contains("new List { new(1, 2), new(3, 4)", generated.Code);
+ }
+
+ [Theory]
+ [InlineData(null, "new(1, 2, 8)")]
+ [InlineData(9.0, "new(1, 2, 9)")]
+ public void Generate_Bubble_Uses_Point_Data_With_Defaultable_Radius(double? radius, string expected)
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.Generate(new ChartGenerationRequest
+ {
+ ChartType = "Bubble",
+ Datasets =
+ [
+ new()
+ {
+ Label = "Samples",
+ Points = [new ChartPointRequest { X = 1, Y = 2, R = radius }],
+ },
+ ],
+ });
+
+ Assert.Contains(expected, generated.Code);
+ }
+
+ [Fact]
+ public void GenerateDashboard_Default_Includes_All_Chart_Components()
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.GenerateDashboard(new ChartDashboardRequest());
+
+ foreach (var definition in ChartCatalog.All)
+ Assert.Contains($"<{definition.ComponentName} @ref=", generated.Code);
+
+ Assert.Equal(8, generated.ChartTypes.Count);
+ }
+
+ [Fact]
+ public void GenerateDashboard_Custom_Uses_Unique_Field_Names_And_Deduplicated_Scripts()
+ {
+ var generator = new ChartExampleGenerator();
+
+ var generated = generator.GenerateDashboard(new ChartDashboardRequest
+ {
+ Charts =
+ [
+ new ChartGenerationRequest { ChartType = "Bar", Datalabels = true },
+ new ChartGenerationRequest { ChartType = "Bar", Datalabels = true },
+ ],
+ });
+
+ Assert.Contains("barChart1", generated.Code);
+ Assert.Contains("barChart2", generated.Code);
+ Assert.Equal(generated.RequiredScripts.Count, generated.RequiredScripts.Distinct().Count());
+ }
+
+ [Fact]
+ public void Generated_Single_And_Dashboard_Pages_Compile()
+ {
+ using var workspace = new RazorCompileWorkspace();
+ var generator = new ChartExampleGenerator();
+ var single = generator.Generate(new ChartGenerationRequest { ChartType = "Bubble", Title = "Bubble Compile" });
+ var dashboard = generator.GenerateDashboard(new ChartDashboardRequest());
+
+ workspace.AddPage(single.PageName, single.Code);
+ workspace.AddPage(dashboard.PageName, dashboard.Code);
+
+ workspace.Build();
+ }
+
+ private sealed class RazorCompileWorkspace : IDisposable
+ {
+ private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-compile-{Guid.NewGuid():N}");
+
+ public RazorCompileWorkspace()
+ {
+ Directory.CreateDirectory(root);
+ Directory.CreateDirectory(Path.Combine(root, "Pages"));
+
+ File.WriteAllText(Path.Combine(root, "CompileHost.csproj"), $$"""
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+ """);
+
+ File.WriteAllText(Path.Combine(root, "_Imports.razor"), "@using BlazorExpress.ChartJS" + Environment.NewLine);
+ }
+
+ public void AddPage(string pageName, string code) =>
+ File.WriteAllText(Path.Combine(root, "Pages", $"{pageName}.razor"), code);
+
+ public void Build()
+ {
+ using var process = new System.Diagnostics.Process();
+ process.StartInfo.FileName = "dotnet";
+ process.StartInfo.ArgumentList.Add("build");
+ process.StartInfo.ArgumentList.Add(Path.Combine(root, "CompileHost.csproj"));
+ process.StartInfo.ArgumentList.Add("--nologo");
+ process.StartInfo.RedirectStandardOutput = true;
+ process.StartInfo.RedirectStandardError = true;
+ process.StartInfo.UseShellExecute = false;
+ process.Start();
+
+ var outputTask = process.StandardOutput.ReadToEndAsync();
+ var errorTask = process.StandardError.ReadToEndAsync();
+ var exited = process.WaitForExit(120_000);
+ if (!exited)
+ {
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit(30_000);
+ }
+
+ var output = outputTask.GetAwaiter().GetResult();
+ var error = errorTask.GetAwaiter().GetResult();
+
+ Assert.True(exited && process.ExitCode == 0, output + Environment.NewLine + error);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(root))
+ Directory.Delete(root, recursive: true);
+ }
+
+ private static string FindRepoRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "BlazorExpress.ChartJS.sln")))
+ return directory.FullName;
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Could not locate repository root.");
+ }
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs
new file mode 100644
index 00000000..d67dfa8a
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/ChartJsMcpToolsTests.cs
@@ -0,0 +1,198 @@
+using System.Text.Json;
+
+namespace BlazorExpress.ChartJS.MCP.Tests;
+
+public class ChartJsMcpToolsTests
+{
+ [Fact]
+ public void GenerateChartExample_Returns_Structured_Error_For_Malformed_Labels()
+ {
+ var json = ChartJsMcpTools.GenerateChartExample("Bar", labelsJson: "[");
+
+ AssertToolError(json, "labelsJson");
+ }
+
+ [Fact]
+ public void GenerateChartExample_Returns_Structured_Error_For_Malformed_Datasets()
+ {
+ var json = ChartJsMcpTools.GenerateChartExample("Bar", datasetsJson: """{"label":"Bad"}""");
+
+ AssertToolError(json, "datasetsJson");
+ }
+
+ [Fact]
+ public void GenerateChartExample_Returns_Structured_Error_For_Unsupported_Chart()
+ {
+ var json = ChartJsMcpTools.GenerateChartExample("Nope");
+
+ AssertToolError(json, "chartType");
+ }
+
+ [Fact]
+ public void ApplyProjectIntegration_Returns_Structured_Error_For_Bad_Plan_Json()
+ {
+ var json = ChartJsMcpTools.ApplyProjectIntegration("[");
+
+ AssertToolError(json);
+ }
+
+ [Fact]
+ public void PreviewProjectIntegration_Returns_Structured_Error_For_Invalid_Target_Project()
+ {
+ var json = ChartJsMcpTools.PreviewProjectIntegration(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), "Bar");
+
+ AssertToolError(json, "targetProjectPath");
+ }
+
+ [Fact]
+ public void PreviewProjectIntegration_Returns_Structured_Error_For_Ambiguous_Folder()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var firstProject = workspace.CreateWebAssemblyProject(Path.Combine("src", "FirstApp"), "FirstApp");
+ var secondProject = workspace.CreateWebAssemblyProject(Path.Combine("src", "SecondApp"), "SecondApp");
+
+ var json = ChartJsMcpTools.PreviewProjectIntegration(workspace.Root, "Bar");
+
+ AssertToolError(json, "targetProjectPath");
+ Assert.Contains(EscapeJsonPath(Path.Combine(firstProject, "FirstApp.csproj")), json);
+ Assert.Contains(EscapeJsonPath(Path.Combine(secondProject, "SecondApp.csproj")), json);
+ }
+
+ [Fact]
+ public void PreviewProjectIntegration_Resolves_Nested_Single_Blazor_Project()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var project = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App");
+
+ var json = ChartJsMcpTools.PreviewProjectIntegration(workspace.Root, "Bar");
+
+ Assert.DoesNotContain("\"success\": false", json);
+ Assert.Contains(EscapeJsonPath(Path.Combine(project, "App.csproj")), json);
+ }
+
+ [Fact]
+ public void PreviewDashboardIntegration_Uses_Same_Project_Resolver()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var project = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App");
+
+ var json = ChartJsMcpTools.PreviewDashboardIntegration(workspace.Root, title: "Charts");
+
+ Assert.DoesNotContain("\"success\": false", json);
+ Assert.Contains("ChartDashboardPage.razor", json);
+ Assert.Contains(EscapeJsonPath(Path.Combine(project, "App.csproj")), json);
+ }
+
+ [Fact]
+ public void GenerateChartExample_Accepts_Color_String()
+ {
+ var json = ChartJsMcpTools.GenerateChartExample(
+ "Bar",
+ datasetsJson: """[{"label":"Revenue","data":[1,2],"backgroundColor":"#123456","borderColor":"#654321"}]""");
+
+ Assert.DoesNotContain("\"success\": false", json);
+ Assert.Contains("#123456", json);
+ Assert.Contains("#654321", json);
+ }
+
+ [Fact]
+ public void GenerateChartExample_Accepts_Color_Array()
+ {
+ var json = ChartJsMcpTools.GenerateChartExample(
+ "Line",
+ datasetsJson: """[{"label":"Revenue","data":[1,2],"backgroundColor":["#123456"],"borderColor":["#654321"]}]""");
+
+ Assert.DoesNotContain("\"success\": false", json);
+ Assert.Contains("#123456", json);
+ Assert.Contains("#654321", json);
+ }
+
+ [Fact]
+ public void GenerateChartDashboard_Returns_Structured_Error_For_Invalid_Child_Spec()
+ {
+ var json = ChartJsMcpTools.GenerateChartDashboard(chartsJson: """[{"title":"Missing type"}]""");
+
+ AssertToolError(json, "chartsJson[0]");
+ }
+
+ [Fact]
+ public void GenerateChartDashboard_Accepts_Custom_Chart_Specs()
+ {
+ var json = ChartJsMcpTools.GenerateChartDashboard(chartsJson: """
+ [
+ {
+ "chartType": "Scatter",
+ "datasets": [
+ {
+ "label": "Samples",
+ "points": [{ "x": 1, "y": 2 }],
+ "backgroundColor": "#123456"
+ }
+ ]
+ }
+ ]
+ """);
+
+ Assert.DoesNotContain("\"success\": false", json);
+ Assert.Contains("ScatterChart", json);
+ Assert.Contains("new(1, 2)", json);
+ }
+
+ private static void AssertToolError(string json, string? expectedField = null)
+ {
+ using var document = JsonDocument.Parse(json);
+ Assert.False(document.RootElement.GetProperty("success").GetBoolean());
+ Assert.Equal("invalid_input", document.RootElement.GetProperty("error").GetProperty("code").GetString());
+
+ if (expectedField is not null)
+ Assert.Equal(expectedField, document.RootElement.GetProperty("error").GetProperty("field").GetString());
+ }
+
+ private static string EscapeJsonPath(string path) =>
+ path.Replace("\\", "\\\\", StringComparison.Ordinal);
+
+ private sealed class TemporaryWorkspace : IDisposable
+ {
+ private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tool-tests-{Guid.NewGuid():N}");
+
+ public string Root => root;
+
+ public string CreateWebAssemblyProject(string relativePath, string projectName)
+ {
+ var projectRoot = Path.Combine(root, relativePath);
+ Directory.CreateDirectory(projectRoot);
+ Directory.CreateDirectory(Path.Combine(projectRoot, "wwwroot"));
+ Directory.CreateDirectory(Path.Combine(projectRoot, "Pages"));
+ Directory.CreateDirectory(Path.Combine(projectRoot, "Shared"));
+
+ File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """
+
+
+ net10.0
+
+
+ """);
+ File.WriteAllText(Path.Combine(projectRoot, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine);
+ File.WriteAllText(Path.Combine(projectRoot, "wwwroot", "index.html"), """
+
+
+
+
+
+ """);
+ File.WriteAllText(Path.Combine(projectRoot, "Shared", "NavMenu.razor"), """
+
+ Home
+
+ """);
+
+ return projectRoot;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(root))
+ Directory.Delete(root, recursive: true);
+ }
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs b/BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs
new file mode 100644
index 00000000..abc7c980
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/McpApplicationTests.cs
@@ -0,0 +1,115 @@
+using Microsoft.AspNetCore.TestHost;
+
+namespace BlazorExpress.ChartJS.MCP.Tests;
+
+public class McpApplicationTests
+{
+ [Fact]
+ public void ParseOptions_NoArgs_Selects_Http()
+ {
+ var options = McpApplication.ParseOptions([]);
+
+ Assert.Equal(McpTransportMode.Http, options.Transport);
+ Assert.Empty(options.RemainingArgs);
+ }
+
+ [Theory]
+ [InlineData("--stdio")]
+ [InlineData("--transport", "stdio")]
+ public void ParseOptions_StdioArgs_Select_Stdio(params string[] args)
+ {
+ var options = McpApplication.ParseOptions(args);
+
+ Assert.Equal(McpTransportMode.Stdio, options.Transport);
+ Assert.Empty(options.RemainingArgs);
+ }
+
+ [Fact]
+ public void ParseOptions_HttpArgs_Select_Http()
+ {
+ var options = McpApplication.ParseOptions(["--http"]);
+
+ Assert.Equal(McpTransportMode.Http, options.Transport);
+ Assert.Empty(options.RemainingArgs);
+ }
+
+ [Fact]
+ public void CreateHttpApp_Defaults_To_Localhost_5000()
+ {
+ using var app = McpApplication.CreateHttpApp([], "Development");
+
+ Assert.Contains(McpApplication.DefaultHttpUrl, app.Urls);
+ }
+
+ [Fact]
+ public async Task Health_Returns_Success()
+ {
+ await using var app = CreateTestHttpApp("Development");
+ await app.StartAsync();
+
+ var response = await app.GetTestClient().GetAsync(McpApplication.HealthPath);
+
+ Assert.True(response.IsSuccessStatusCode);
+ }
+
+ [Fact]
+ public async Task Development_Mcp_Allows_Missing_Bearer_Token()
+ {
+ await using var app = CreateTestHttpApp("Development");
+ await app.StartAsync();
+
+ var response = await app.GetTestClient().GetAsync(McpApplication.McpPath);
+
+ Assert.NotEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public void Production_Http_Requires_Token_At_Startup()
+ {
+ var exception = Assert.Throws(() => CreateTestHttpApp("Production"));
+
+ Assert.Contains(McpApplication.TokenConfigurationKey, exception.Message);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("wrong-token")]
+ public async Task Production_Mcp_Rejects_Missing_Or_Wrong_Bearer_Token(string? token)
+ {
+ await using var app = CreateTestHttpApp("Production", "correct-token");
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Get, McpApplication.McpPath);
+ if (token is not null)
+ request.Headers.Authorization = new("Bearer", token);
+
+ var response = await app.GetTestClient().SendAsync(request);
+
+ Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Production_Mcp_Allows_Correct_Bearer_Token()
+ {
+ await using var app = CreateTestHttpApp("Production", "correct-token");
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Get, McpApplication.McpPath);
+ request.Headers.Authorization = new("Bearer", "correct-token");
+
+ var response = await app.GetTestClient().SendAsync(request);
+
+ Assert.NotEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ private static Microsoft.AspNetCore.Builder.WebApplication CreateTestHttpApp(string environmentName, string? token = null)
+ {
+ var configuration = token is null
+ ? null
+ : new Dictionary { [McpApplication.TokenConfigurationKey] = token };
+
+ return McpApplication.CreateHttpApp(
+ [],
+ environmentName,
+ webHost => webHost.UseTestServer(),
+ configuration);
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs
new file mode 100644
index 00000000..6c95f0b4
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs
@@ -0,0 +1,292 @@
+namespace BlazorExpress.ChartJS.MCP.Tests;
+
+public class ProjectIntegrationServiceTests
+{
+ [Fact]
+ public void Preview_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject();
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var plan = service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = projectDirectory,
+ Chart = new ChartGenerationRequest { ChartType = "Line", Title = "Sales Trend", Route = "/charts/sales-trend" },
+ });
+
+ Assert.Equal("BlazorWebAssembly", plan.DetectedHostModel);
+ Assert.NotEmpty(plan.PlanHash);
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("LineChartPage.razor", StringComparison.Ordinal));
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("Sample.csproj", StringComparison.Ordinal) && x.NewContent.Contains("BlazorExpress.ChartJS", StringComparison.Ordinal));
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("_Imports.razor", StringComparison.Ordinal) && x.NewContent.Contains("@using BlazorExpress.ChartJS", StringComparison.Ordinal));
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("index.html", StringComparison.Ordinal) && x.NewContent.Contains("chart.umd.js", StringComparison.Ordinal));
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("NavMenu.razor", StringComparison.Ordinal) && x.NewContent.Contains("charts/sales-trend", StringComparison.Ordinal));
+ Assert.False(File.Exists(Path.Combine(projectDirectory, "Pages", "LineChartPage.razor")));
+ }
+
+ [Fact]
+ public void Preview_Accepts_Exact_Project_File_Path()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject();
+ var projectFilePath = Path.Combine(projectDirectory, "Sample.csproj");
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var plan = service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = projectFilePath,
+ Chart = new ChartGenerationRequest { ChartType = "Line" },
+ });
+
+ Assert.Equal(projectFilePath, plan.ProjectFilePath);
+ }
+
+ [Fact]
+ public void Preview_Resolves_Repo_Folder_With_One_Nested_Blazor_App()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App");
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var plan = service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = workspace.Root,
+ Chart = new ChartGenerationRequest { ChartType = "Line" },
+ });
+
+ Assert.Equal(Path.Combine(projectDirectory, "App.csproj"), plan.ProjectFilePath);
+ }
+
+ [Fact]
+ public void Preview_Rejects_Repo_Folder_With_Multiple_Blazor_App_Candidates()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var firstProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "FirstApp"), "FirstApp");
+ var secondProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "SecondApp"), "SecondApp");
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var exception = Assert.Throws(() => service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = workspace.Root,
+ Chart = new ChartGenerationRequest { ChartType = "Line" },
+ }));
+
+ Assert.Equal("targetProjectPath", exception.Field);
+ Assert.Contains(Path.Combine(firstProjectDirectory, "FirstApp.csproj"), exception.Details);
+ Assert.Contains(Path.Combine(secondProjectDirectory, "SecondApp.csproj"), exception.Details);
+ }
+
+ [Fact]
+ public void Preview_Rejects_Folder_With_Only_NonBlazor_Projects()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateLibraryProject(Path.Combine("src", "Library"), "Library");
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var exception = Assert.Throws(() => service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = workspace.Root,
+ Chart = new ChartGenerationRequest { ChartType = "Line" },
+ }));
+
+ Assert.Equal("targetProjectPath", exception.Field);
+ Assert.Contains(Path.Combine(projectDirectory, "Library.csproj"), exception.Details);
+ }
+
+ [Fact]
+ public void Preview_Resolves_Solution_With_One_Blazor_App()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "App"), "App");
+ var libraryDirectory = workspace.CreateLibraryProject(Path.Combine("src", "Library"), "Library");
+ var solutionPath = workspace.CreateSolution("Sample.sln", Path.Combine(projectDirectory, "App.csproj"), Path.Combine(libraryDirectory, "Library.csproj"));
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var plan = service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = solutionPath,
+ Chart = new ChartGenerationRequest { ChartType = "Line" },
+ });
+
+ Assert.Equal(Path.Combine(projectDirectory, "App.csproj"), plan.ProjectFilePath);
+ }
+
+ [Fact]
+ public void Preview_Rejects_Solution_With_Multiple_Blazor_App_Candidates()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var firstProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "FirstApp"), "FirstApp");
+ var secondProjectDirectory = workspace.CreateWebAssemblyProject(Path.Combine("src", "SecondApp"), "SecondApp");
+ var solutionPath = workspace.CreateSolution("Sample.sln", Path.Combine(firstProjectDirectory, "FirstApp.csproj"), Path.Combine(secondProjectDirectory, "SecondApp.csproj"));
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var exception = Assert.Throws(() => service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = solutionPath,
+ Chart = new ChartGenerationRequest { ChartType = "Line" },
+ }));
+
+ Assert.Equal("targetProjectPath", exception.Field);
+ Assert.Contains(Path.Combine(firstProjectDirectory, "FirstApp.csproj"), exception.Details);
+ Assert.Contains(Path.Combine(secondProjectDirectory, "SecondApp.csproj"), exception.Details);
+ }
+
+ [Fact]
+ public void Apply_Writes_Files_For_Matching_Preview()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject();
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+ var plan = service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = projectDirectory,
+ Chart = new ChartGenerationRequest { ChartType = "Pie", Title = "Sales Mix" },
+ });
+
+ var result = service.Apply(plan);
+
+ Assert.NotEmpty(result.WrittenFiles);
+ Assert.True(File.Exists(Path.Combine(projectDirectory, "Pages", "PieChartPage.razor")));
+ }
+
+ [Fact]
+ public void Apply_Rejects_Stale_Preview()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject();
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+ var plan = service.Preview(new PreviewIntegrationRequest
+ {
+ TargetProjectPath = projectDirectory,
+ Chart = new ChartGenerationRequest { ChartType = "Bar" },
+ });
+
+ File.AppendAllText(Path.Combine(projectDirectory, "_Imports.razor"), "@using Changed");
+
+ Assert.Throws(() => service.Apply(plan));
+ }
+
+ [Fact]
+ public void PreviewDashboard_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject();
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+
+ var plan = service.PreviewDashboard(new PreviewDashboardIntegrationRequest
+ {
+ TargetProjectPath = projectDirectory,
+ Dashboard = new ChartDashboardRequest { Title = "Charts", Route = "/charts/dashboard" },
+ });
+
+ Assert.Equal("BlazorWebAssembly", plan.DetectedHostModel);
+ Assert.NotEmpty(plan.PlanHash);
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("ChartDashboardPage.razor", StringComparison.Ordinal));
+ Assert.Contains(plan.Edits, x => x.Path.EndsWith("NavMenu.razor", StringComparison.Ordinal) && x.NewContent.Contains("charts/dashboard", StringComparison.Ordinal));
+ Assert.False(File.Exists(Path.Combine(projectDirectory, "Pages", "ChartDashboardPage.razor")));
+ }
+
+ [Fact]
+ public void ApplyDashboard_Writes_Files_For_Matching_Preview()
+ {
+ using var workspace = new TemporaryWorkspace();
+ var projectDirectory = workspace.CreateWebAssemblyProject();
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+ var plan = service.PreviewDashboard(new PreviewDashboardIntegrationRequest
+ {
+ TargetProjectPath = projectDirectory,
+ Dashboard = new ChartDashboardRequest { Title = "Charts" },
+ });
+
+ var result = service.Apply(plan);
+
+ Assert.NotEmpty(result.WrittenFiles);
+ Assert.True(File.Exists(Path.Combine(projectDirectory, "Pages", "ChartDashboardPage.razor")));
+ }
+
+ private sealed class TemporaryWorkspace : IDisposable
+ {
+ private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tests-{Guid.NewGuid():N}");
+
+ public string Root => root;
+
+ public string CreateWebAssemblyProject(string relativePath = "", string projectName = "Sample")
+ {
+ var projectRoot = string.IsNullOrWhiteSpace(relativePath) ? root : Path.Combine(root, relativePath);
+ Directory.CreateDirectory(projectRoot);
+ Directory.CreateDirectory(Path.Combine(projectRoot, "wwwroot"));
+ Directory.CreateDirectory(Path.Combine(projectRoot, "Pages"));
+ Directory.CreateDirectory(Path.Combine(projectRoot, "Shared"));
+
+ File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """
+
+
+ net10.0
+
+
+ """);
+ File.WriteAllText(Path.Combine(projectRoot, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine);
+ File.WriteAllText(Path.Combine(projectRoot, "wwwroot", "index.html"), """
+
+
+
+
+
+ """);
+ File.WriteAllText(Path.Combine(projectRoot, "Shared", "NavMenu.razor"), """
+
+ Home
+
+ """);
+
+ return projectRoot;
+ }
+
+ public string CreateLibraryProject(string relativePath, string projectName)
+ {
+ var projectRoot = Path.Combine(root, relativePath);
+ Directory.CreateDirectory(projectRoot);
+ File.WriteAllText(Path.Combine(projectRoot, $"{projectName}.csproj"), """
+
+
+ net10.0
+
+
+ """);
+
+ return projectRoot;
+ }
+
+ public string CreateSolution(string fileName, params string[] projectPaths)
+ {
+ Directory.CreateDirectory(root);
+ var solutionPath = Path.Combine(root, fileName);
+ var lines = new List
+ {
+ "Microsoft Visual Studio Solution File, Format Version 12.00",
+ "# Visual Studio Version 17",
+ };
+
+ foreach (var projectPath in projectPaths)
+ {
+ var relativePath = Path.GetRelativePath(root, projectPath);
+ var projectName = Path.GetFileNameWithoutExtension(projectPath);
+ lines.Add($"Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"{projectName}\", \"{relativePath}\", \"{{{Guid.NewGuid():D}}}\"");
+ lines.Add("EndProject");
+ }
+
+ lines.Add("Global");
+ lines.Add("EndGlobal");
+ File.WriteAllText(solutionPath, string.Join(Environment.NewLine, lines));
+
+ return solutionPath;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(root))
+ Directory.Delete(root, recursive: true);
+ }
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP.Tests/Usings.cs b/BlazorExpress.ChartJS.MCP.Tests/Usings.cs
new file mode 100644
index 00000000..9bfc7113
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP.Tests/Usings.cs
@@ -0,0 +1,2 @@
+global using BlazorExpress.ChartJS.MCP;
+global using Xunit;
diff --git a/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj
new file mode 100644
index 00000000..ee2412d9
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj
@@ -0,0 +1,37 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+ true
+ blazorexpress-chartjs-mcp
+ BlazorExpress.ChartJS.MCP
+ 0.1.0
+ 0.1.0
+ Apache-2.0
+ https://chartjs.blazorexpress.com
+ https://github.com/BlazorExpress/BlazorExpress.ChartJS
+ README.md
+ Model Context Protocol server for generating and integrating BlazorExpress.ChartJS charts.
+ Vikram Reddy
+ Blazor Express
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BlazorExpress.ChartJS.MCP/ChartCatalog.cs b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs
new file mode 100644
index 00000000..2980b005
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/ChartCatalog.cs
@@ -0,0 +1,171 @@
+using System.ComponentModel;
+using System.Reflection;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+public static class ChartCatalog
+{
+ private static readonly IReadOnlyDictionary DefinitionsByKey = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["bar"] = Create("Bar", "BarChart", "BarChartOptions", "BarChartDataset", typeof(BarChart), typeof(BarChartOptions), typeof(BarChartDataset), true, true, true),
+ ["bubble"] = Create("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset", typeof(BubbleChart), typeof(BubbleChartOptions), typeof(BubbleChartDataset), true, false, false),
+ ["doughnut"] = Create("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset", typeof(DoughnutChart), typeof(DoughnutChartOptions), typeof(DoughnutChartDataset), true, false, false),
+ ["line"] = Create("Line", "LineChart", "LineChartOptions", "LineChartDataset", typeof(LineChart), typeof(LineChartOptions), typeof(LineChartDataset), true, true, false),
+ ["pie"] = Create("Pie", "PieChart", "PieChartOptions", "PieChartDataset", typeof(PieChart), typeof(PieChartOptions), typeof(PieChartDataset), true, false, false),
+ ["polararea"] = Create("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false),
+ ["polar-area"] = Create("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false),
+ ["radar"] = Create("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset", typeof(RadarChart), typeof(RadarChartOptions), typeof(RadarChartDataset), false, false, false),
+ ["scatter"] = Create("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset", typeof(ScatterChart), typeof(ScatterChartOptions), typeof(ScatterChartDataset), true, false, false),
+ };
+
+ public static IReadOnlyList All { get; } = DefinitionsByKey.Values
+ .GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(x => x.First())
+ .OrderBy(x => x.Name)
+ .ToList();
+
+ public static ChartDefinition Get(string chartType)
+ {
+ var key = NormalizeKey(chartType);
+
+ if (DefinitionsByKey.TryGetValue(key, out var definition))
+ return definition;
+
+ throw new ArgumentException($"Unsupported chart type '{chartType}'. Supported values are: {string.Join(", ", All.Select(x => x.Name))}.", nameof(chartType));
+ }
+
+ public static ChartGenerationSchema GetSchema(string chartType)
+ {
+ var definition = Get(chartType);
+
+ return new ChartGenerationSchema(
+ ChartType: definition.Name,
+ Component: definition.ComponentName,
+ OptionsType: definition.OptionsTypeName,
+ DatasetType: definition.DatasetTypeName,
+ SupportsDatalabels: definition.SupportsDatalabels,
+ SupportsStacking: definition.SupportsStacking,
+ SupportsOrientation: definition.SupportsOrientation,
+ SupportsPluginOptions: definition.SupportsPluginOptions,
+ SupportsTitleOptions: definition.SupportsTitleOptions,
+ SupportsLegendOptions: definition.SupportsLegendOptions,
+ CommonInputs:
+ [
+ "title",
+ "route",
+ "pageName",
+ "labelsJson",
+ "datasetsJson",
+ "width",
+ "height",
+ "legendPosition",
+ "datalabels"
+ ],
+ ChartSpecificInputs: GetChartSpecificInputs(definition),
+ Examples: GetExamples(definition),
+ Metadata: GetTypeMetadata(definition));
+ }
+
+ private static ChartDefinition Create(
+ string name,
+ string componentName,
+ string optionsTypeName,
+ string datasetTypeName,
+ Type componentType,
+ Type optionsType,
+ Type datasetType,
+ bool supportsDatalabels,
+ bool supportsStacking,
+ bool supportsOrientation)
+ {
+ var pluginsProperty = optionsType.GetProperty("Plugins", BindingFlags.Public | BindingFlags.Instance);
+ var supportsPluginOptions = pluginsProperty is not null;
+ var pluginType = pluginsProperty?.PropertyType;
+ var supportsTitleOptions = pluginType?.GetProperty("Title", BindingFlags.Public | BindingFlags.Instance) is not null;
+ var supportsLegendOptions = pluginType?.GetProperty("Legend", BindingFlags.Public | BindingFlags.Instance) is not null;
+ var supportsScales = optionsType.GetProperty("Scales", BindingFlags.Public | BindingFlags.Instance) is not null;
+
+ return new ChartDefinition(
+ name,
+ componentName,
+ optionsTypeName,
+ datasetTypeName,
+ componentType,
+ optionsType,
+ datasetType,
+ supportsDatalabels,
+ supportsStacking,
+ supportsOrientation,
+ supportsPluginOptions,
+ supportsTitleOptions,
+ supportsLegendOptions,
+ supportsScales);
+ }
+
+ private static IReadOnlyList GetChartSpecificInputs(ChartDefinition definition)
+ {
+ var inputs = new List();
+
+ if (definition.SupportsStacking)
+ inputs.Add("stacked");
+
+ if (definition.SupportsOrientation)
+ inputs.Add("orientation: 'vertical' or 'horizontal'");
+
+ if (definition.Name is "Bubble")
+ inputs.Add("datasetsJson data points may include x, y, r");
+ else if (definition.Name is "Scatter")
+ inputs.Add("datasetsJson data points may include x, y");
+ else
+ inputs.Add("datasetsJson data values are numeric");
+
+ return inputs;
+ }
+
+ private static IReadOnlyDictionary GetTypeMetadata(ChartDefinition definition) =>
+ new Dictionary
+ {
+ ["componentDescription"] = GetDescription(definition.ComponentType),
+ ["optionsProperties"] = GetPublicPropertyMetadata(definition.OptionsType),
+ ["datasetProperties"] = GetPublicPropertyMetadata(definition.DatasetType),
+ };
+
+ private static IReadOnlyDictionary GetExamples(ChartDefinition definition)
+ {
+ var common = new Dictionary
+ {
+ ["labelsJson"] = """["Jan","Feb","Mar"]""",
+ ["numericDatasetsJson"] = """[{"label":"Revenue","data":[12,19,7],"backgroundColor":"rgba(54, 162, 235, 0.7)","borderColor":["rgba(54, 162, 235, 1)"]}]""",
+ };
+
+ if (definition.Name is "Scatter" or "Bubble")
+ common["pointDatasetsJson"] = """[{"label":"Samples","points":[{"x":1,"y":12,"r":6},{"x":2,"y":19,"r":8}],"backgroundColor":"rgba(255, 99, 132, 0.7)"}]""";
+
+ return common;
+ }
+
+ private static string? GetDescription(MemberInfo member) =>
+ member.GetCustomAttribute()?.Description;
+
+ private static IReadOnlyList GetPublicPropertyMetadata(Type type) =>
+ type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(x => x.GetMethod is not null)
+ .Select(x => new PropertyMetadata(
+ Name: x.Name,
+ Type: x.PropertyType.Name,
+ Description: x.GetCustomAttribute()?.Description,
+ DefaultValue: x.GetCustomAttribute()?.Value?.ToString()))
+ .OrderBy(x => x.Name)
+ .ToList();
+
+ private static string NormalizeKey(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentException("A chart type is required.", nameof(value));
+
+ return
+ value.Replace(" ", "", StringComparison.Ordinal)
+ .Replace("_", "", StringComparison.Ordinal)
+ .ToLowerInvariant();
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs b/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs
new file mode 100644
index 00000000..e961025b
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/ChartExampleGenerator.cs
@@ -0,0 +1,438 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+public sealed class ChartExampleGenerator
+{
+ private static readonly string[] DefaultColors =
+ [
+ "rgba(54, 162, 235, 0.7)",
+ "rgba(255, 99, 132, 0.7)",
+ "rgba(255, 205, 86, 0.7)",
+ "rgba(75, 192, 192, 0.7)",
+ "rgba(153, 102, 255, 0.7)",
+ "rgba(255, 159, 64, 0.7)"
+ ];
+
+ public GeneratedChartExample Generate(ChartGenerationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var model = CreateChartModel(request, identifierSuffix: "");
+ var code = new StringBuilder();
+
+ code.AppendLine($"@page \"{model.Route}\"");
+ code.AppendLine("@using BlazorExpress.ChartJS");
+ code.AppendLine();
+ code.AppendLine($"{EscapeHtml(model.Title)} ");
+ code.AppendLine();
+ AppendChartMarkup(code, model, indent: "");
+ code.AppendLine();
+ code.AppendLine("@code {");
+ AppendChartFields(code, model);
+ code.AppendLine();
+ code.AppendLine(" protected override void OnInitialized()");
+ code.AppendLine(" {");
+ AppendChartInitialization(code, model);
+ code.AppendLine(" }");
+ code.AppendLine();
+ AppendAfterRender(code, [model]);
+ code.AppendLine("}");
+
+ return new GeneratedChartExample(
+ ChartType: model.Definition.Name,
+ Route: model.Route,
+ PageName: model.PageName,
+ Code: code.ToString(),
+ RequiredScripts: RequiredScripts([model]));
+ }
+
+ public GeneratedChartDashboard GenerateDashboard(ChartDashboardRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var chartRequests = request.Charts is { Count: > 0 }
+ ? request.Charts
+ : ChartCatalog.All.Select(x => new ChartGenerationRequest
+ {
+ ChartType = x.Name,
+ Title = $"{x.Name} Example",
+ Datalabels = false,
+ }).ToList();
+
+ var title = string.IsNullOrWhiteSpace(request.Title) ? "Chart Dashboard" : request.Title.Trim();
+ var route = NormalizeRoute(request.Route, "/charts/dashboard", "route");
+ var pageName = MakeIdentifier(string.IsNullOrWhiteSpace(request.PageName) ? "ChartDashboardPage" : request.PageName);
+ var models = chartRequests.Select((chart, index) => CreateChartModel(chart, (index + 1).ToString(CultureInfo.InvariantCulture))).ToList();
+
+ var code = new StringBuilder();
+ code.AppendLine($"@page \"{route}\"");
+ code.AppendLine("@using BlazorExpress.ChartJS");
+ code.AppendLine();
+ code.AppendLine($"{EscapeHtml(title)} ");
+ code.AppendLine();
+ code.AppendLine("");
+ foreach (var model in models)
+ {
+ code.AppendLine(" ");
+ code.AppendLine($" {EscapeHtml(model.Title)} ");
+ AppendChartMarkup(code, model, indent: " ");
+ code.AppendLine(" ");
+ }
+ code.AppendLine("
");
+ code.AppendLine();
+ code.AppendLine("@code {");
+ foreach (var model in models)
+ {
+ AppendChartFields(code, model);
+ code.AppendLine();
+ }
+ code.AppendLine(" protected override void OnInitialized()");
+ code.AppendLine(" {");
+ foreach (var model in models)
+ AppendChartInitialization(code, model);
+ code.AppendLine(" }");
+ code.AppendLine();
+ AppendAfterRender(code, models);
+ code.AppendLine("}");
+
+ return new GeneratedChartDashboard(
+ Route: route,
+ PageName: pageName,
+ Code: code.ToString(),
+ ChartTypes: models.Select(x => x.Definition.Name).ToList(),
+ RequiredScripts: RequiredScripts(models));
+ }
+
+ private static ChartRenderModel CreateChartModel(ChartGenerationRequest request, string identifierSuffix)
+ {
+ var definition = ChartCatalog.Get(request.ChartType);
+ var title = string.IsNullOrWhiteSpace(request.Title) ? $"{definition.Name} Chart" : request.Title.Trim();
+ var pageName = MakeIdentifier(string.IsNullOrWhiteSpace(request.PageName) ? $"{definition.Name}ChartPage" : request.PageName);
+ var route = NormalizeRoute(request.Route, $"/charts/{ToKebabCase(definition.Name)}", "route");
+ var labels = NormalizeLabels(request.Labels, definition);
+ var datasets = NormalizeDatasets(request.Datasets, labels, definition);
+ var width = NormalizeDimension(request.Width, 700, "width");
+ var height = NormalizeDimension(request.Height, 400, "height");
+ var datalabels = request.Datalabels == true && definition.SupportsDatalabels;
+ var legendPosition = NormalizeLegendPosition(request.LegendPosition);
+ var stacked = request.Stacked == true && definition.SupportsStacking;
+ var horizontal = definition.SupportsOrientation && string.Equals(request.Orientation, "horizontal", StringComparison.OrdinalIgnoreCase);
+ var suffix = string.IsNullOrWhiteSpace(identifierSuffix) ? "" : identifierSuffix;
+
+ return new ChartRenderModel(
+ Definition: definition,
+ Title: title,
+ Route: route,
+ PageName: pageName,
+ Labels: labels,
+ Datasets: datasets,
+ Width: width,
+ Height: height,
+ IncludeDatalabels: datalabels,
+ LegendPosition: legendPosition,
+ Stacked: stacked,
+ Horizontal: horizontal,
+ ChartField: $"{LowerFirst(definition.ComponentName)}{suffix}",
+ OptionsField: $"{LowerFirst(definition.OptionsTypeName)}{suffix}",
+ DataField: $"chartData{suffix}");
+ }
+
+ private static void AppendChartMarkup(StringBuilder code, ChartRenderModel model, string indent) =>
+ code.AppendLine($"{indent}<{model.Definition.ComponentName} @ref=\"{model.ChartField}\" Width=\"{model.Width}\" Height=\"{model.Height}\" />");
+
+ private static void AppendChartFields(StringBuilder code, ChartRenderModel model)
+ {
+ code.AppendLine($" private {model.Definition.ComponentName} {model.ChartField} = default!;");
+ code.AppendLine($" private {model.Definition.OptionsTypeName} {model.OptionsField} = default!;");
+ code.AppendLine($" private ChartData {model.DataField} = default!;");
+ }
+
+ private static void AppendChartInitialization(StringBuilder code, ChartRenderModel model)
+ {
+ code.AppendLine($" {model.DataField} = new ChartData");
+ code.AppendLine(" {");
+ code.AppendLine($" Labels = new List {{ {string.Join(", ", model.Labels.Select(ToCSharpString))} }},");
+ code.AppendLine(" Datasets = new List");
+ code.AppendLine(" {");
+ foreach (var dataset in model.Datasets)
+ AppendDataset(code, model.Definition, dataset, model.Labels.Count, model.Stacked);
+ code.AppendLine(" },");
+ code.AppendLine(" };");
+ code.AppendLine();
+ code.AppendLine($" {model.OptionsField} = new {model.Definition.OptionsTypeName}");
+ code.AppendLine(" {");
+ code.AppendLine(" Responsive = true,");
+ code.AppendLine(" MaintainAspectRatio = false,");
+ if (model.Definition.Name is "Bar" && model.Horizontal)
+ code.AppendLine(" IndexAxis = \"y\",");
+ code.AppendLine(" };");
+ code.AppendLine();
+ AppendOptionsCustomization(code, model);
+ }
+
+ private static void AppendAfterRender(StringBuilder code, IReadOnlyList models)
+ {
+ code.AppendLine(" protected override async Task OnAfterRenderAsync(bool firstRender)");
+ code.AppendLine(" {");
+ code.AppendLine(" if (firstRender)");
+ code.AppendLine(" {");
+ foreach (var model in models)
+ {
+ if (model.IncludeDatalabels)
+ code.AppendLine($" await {model.ChartField}.InitializeAsync({model.DataField}, {model.OptionsField}, new string[] {{ \"ChartDataLabels\" }});");
+ else
+ code.AppendLine($" await {model.ChartField}.InitializeAsync({model.DataField}, {model.OptionsField});");
+ }
+ code.AppendLine(" }");
+ code.AppendLine();
+ code.AppendLine(" await base.OnAfterRenderAsync(firstRender);");
+ code.AppendLine(" }");
+ }
+
+ private static void AppendDataset(StringBuilder code, ChartDefinition definition, ChartDatasetRequest dataset, int labelCount, bool stacked)
+ {
+ var colors = NormalizeColors(dataset.BackgroundColor, labelCount);
+ var borderColors = NormalizeColors(dataset.BorderColor, labelCount);
+
+ code.AppendLine($" new {definition.DatasetTypeName}");
+ code.AppendLine(" {");
+ code.AppendLine($" Label = {ToCSharpString(dataset.Label ?? "Dataset")},");
+
+ if (definition.Name is "Bubble")
+ {
+ var points = NormalizePoints(dataset, labelCount, includeRadius: true);
+ code.AppendLine($" Data = new List {{ {string.Join(", ", points.Select(x => $"new({FormatNumber(x.X)}, {FormatNumber(x.Y)}, {FormatNumber(x.R ?? 8)})"))} }},");
+ }
+ else if (definition.Name is "Scatter")
+ {
+ var points = NormalizePoints(dataset, labelCount, includeRadius: false);
+ code.AppendLine($" Data = new List {{ {string.Join(", ", points.Select(x => $"new({FormatNumber(x.X)}, {FormatNumber(x.Y)})"))} }},");
+ }
+ else
+ {
+ var values = NormalizeData(dataset.Data, labelCount);
+ code.AppendLine($" Data = new List {{ {string.Join(", ", values.Select(FormatNullableNumber))} }},");
+ }
+
+ AppendColorProperty(code, definition, "BackgroundColor", colors);
+ AppendColorProperty(code, definition, "BorderColor", borderColors);
+
+ if (definition.Name is "Bar")
+ code.AppendLine(" BorderWidth = new List { 1 },");
+
+ if (stacked && !string.IsNullOrWhiteSpace(dataset.Stack) && definition.Name is "Bar")
+ code.AppendLine($" Stack = {ToCSharpString(dataset.Stack)},");
+
+ code.AppendLine(" },");
+ }
+
+ private static void AppendOptionsCustomization(StringBuilder code, ChartRenderModel model)
+ {
+ var definition = model.Definition;
+
+ if (definition.SupportsTitleOptions)
+ {
+ code.AppendLine($" {model.OptionsField}.Plugins.Title!.Text = {ToCSharpString(model.Title)};");
+ code.AppendLine($" {model.OptionsField}.Plugins.Title.Display = true;");
+ }
+
+ if (definition.SupportsLegendOptions)
+ code.AppendLine($" {model.OptionsField}.Plugins.Legend.Position = {ToCSharpString(model.LegendPosition)};");
+
+ if (model.Stacked && definition.SupportsScales)
+ {
+ code.AppendLine($" {model.OptionsField}.Scales.X!.Stacked = true;");
+ code.AppendLine($" {model.OptionsField}.Scales.Y!.Stacked = true;");
+ }
+ }
+
+ private static void AppendColorProperty(StringBuilder code, ChartDefinition definition, string propertyName, IReadOnlyList colors)
+ {
+ var propertyType = definition.DatasetType.GetProperty(propertyName)?.PropertyType;
+ if (propertyType == typeof(string))
+ {
+ code.AppendLine($" {propertyName} = {ToCSharpString(colors.FirstOrDefault() ?? DefaultColors[0])},");
+ return;
+ }
+
+ code.AppendLine($" {propertyName} = new List {{ {string.Join(", ", colors.Select(ToCSharpString))} }},");
+ }
+
+ private static IReadOnlyList RequiredScripts(IReadOnlyList models)
+ {
+ var scripts = new List
+ {
+ "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js",
+ };
+
+ if (models.Any(x => x.IncludeDatalabels))
+ scripts.Add("https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-datalabels/2.2.0/chartjs-plugin-datalabels.min.js");
+
+ scripts.Add("_content/BlazorExpress.ChartJS/blazorexpress.chartjs.js");
+ return scripts.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ private static IReadOnlyList NormalizeLabels(IReadOnlyList? labels, ChartDefinition definition)
+ {
+ if (labels is { Count: > 0 })
+ return labels.Select((x, index) => string.IsNullOrWhiteSpace(x) ? $"Label {index + 1}" : x.Trim()).ToList();
+
+ return definition.Name is "Bubble" or "Scatter"
+ ? ["Point 1", "Point 2", "Point 3", "Point 4"]
+ : ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
+ }
+
+ private static IReadOnlyList NormalizeDatasets(IReadOnlyList? datasets, IReadOnlyList labels, ChartDefinition definition)
+ {
+ if (datasets is { Count: > 0 })
+ return datasets;
+
+ if (definition.Name is "Pie" or "Doughnut" or "PolarArea")
+ {
+ return
+ [
+ new()
+ {
+ Label = "Revenue",
+ Data = labels.Select((_, index) => (double?)(20 + (index + 1) * 8)).ToList(),
+ BackgroundColor = DefaultColors.Take(labels.Count).ToList(),
+ BorderColor = DefaultColors.Take(labels.Count).ToList(),
+ }
+ ];
+ }
+
+ return
+ [
+ new()
+ {
+ Label = "Current",
+ Data = labels.Select((_, index) => (double?)(12 + (index + 1) * 6)).ToList(),
+ BackgroundColor = [DefaultColors[0]],
+ BorderColor = [DefaultColors[0]],
+ Points = labels.Select((_, index) => new ChartPointRequest { X = index + 1, Y = 12 + (index + 1) * 6, R = 6 + index }).ToList(),
+ },
+ new()
+ {
+ Label = "Previous",
+ Data = labels.Select((_, index) => (double?)(10 + (index + 1) * 4)).ToList(),
+ BackgroundColor = [DefaultColors[1]],
+ BorderColor = [DefaultColors[1]],
+ Points = labels.Select((_, index) => new ChartPointRequest { X = index + 1, Y = 10 + (index + 1) * 4, R = 5 + index }).ToList(),
+ }
+ ];
+ }
+
+ private static IReadOnlyList NormalizeData(IReadOnlyList? values, int count)
+ {
+ if (values is { Count: > 0 })
+ return Pad(values, count, 0);
+
+ return Enumerable.Range(1, count).Select(x => (double?)(x * 10)).ToList();
+ }
+
+ private static IReadOnlyList NormalizePoints(ChartDatasetRequest dataset, int count, bool includeRadius)
+ {
+ if (dataset.Points is { Count: > 0 })
+ return Pad(dataset.Points, count, new ChartPointRequest());
+
+ var data = NormalizeData(dataset.Data, count);
+ return data.Select((y, index) => new ChartPointRequest { X = index + 1, Y = y ?? 0, R = includeRadius ? 6 + index : null }).ToList();
+ }
+
+ private static IReadOnlyList NormalizeColors(IReadOnlyList? colors, int count)
+ {
+ if (colors is { Count: > 0 })
+ return colors.Select(x => string.IsNullOrWhiteSpace(x) ? DefaultColors[0] : x).ToList();
+
+ return DefaultColors.Take(Math.Max(1, Math.Min(count, DefaultColors.Length))).ToList();
+ }
+
+ private static int NormalizeDimension(int? value, int fallback, string field)
+ {
+ if (value is null)
+ return fallback;
+
+ if (value <= 0)
+ throw new ToolInputException($"{field} must be greater than zero.", field);
+
+ return value.Value;
+ }
+
+ private static IReadOnlyList Pad(IReadOnlyList values, int count, T fallback)
+ {
+ var result = values.Take(count).ToList();
+
+ while (result.Count < count)
+ result.Add(fallback);
+
+ return result;
+ }
+
+ private static string NormalizeRoute(string? route, string fallback, string field)
+ {
+ if (string.IsNullOrWhiteSpace(route))
+ return fallback;
+
+ var normalized = route.Trim();
+ if (normalized.Any(char.IsWhiteSpace) || normalized.Contains('"', StringComparison.Ordinal))
+ throw new ToolInputException($"{field} must be a route path without whitespace or quotes.", field);
+
+ return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized : $"/{normalized}";
+ }
+
+ private static string NormalizeLegendPosition(string? legendPosition)
+ {
+ var value = legendPosition?.Trim().ToLowerInvariant();
+ return value is "left" or "right" or "bottom" or "top" ? value : "top";
+ }
+
+ private static string MakeIdentifier(string? value)
+ {
+ var cleaned = Regex.Replace(value ?? "GeneratedChartPage", "[^a-zA-Z0-9_]", "");
+ if (string.IsNullOrWhiteSpace(cleaned))
+ cleaned = "GeneratedChartPage";
+ if (char.IsDigit(cleaned[0]))
+ cleaned = $"Chart{cleaned}";
+ return cleaned;
+ }
+
+ internal static string ToKebabCase(string value) =>
+ Regex.Replace(value, "([a-z0-9])([A-Z])", "$1-$2").ToLowerInvariant();
+
+ private static string LowerFirst(string value) =>
+ string.IsNullOrWhiteSpace(value) ? value : char.ToLowerInvariant(value[0]) + value[1..];
+
+ private static string EscapeHtml(string value) =>
+ value.Replace("&", "&", StringComparison.Ordinal)
+ .Replace("<", "<", StringComparison.Ordinal)
+ .Replace(">", ">", StringComparison.Ordinal);
+
+ private static string ToCSharpString(string value) =>
+ $"\"{value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
+
+ private static string FormatNullableNumber(double? value) =>
+ value.HasValue ? FormatNumber(value.Value) : "null";
+
+ private static string FormatNumber(double value) =>
+ value.ToString("0.########", CultureInfo.InvariantCulture);
+
+ private sealed record ChartRenderModel(
+ ChartDefinition Definition,
+ string Title,
+ string Route,
+ string PageName,
+ IReadOnlyList Labels,
+ IReadOnlyList Datasets,
+ int Width,
+ int Height,
+ bool IncludeDatalabels,
+ string LegendPosition,
+ bool Stacked,
+ bool Horizontal,
+ string ChartField,
+ string OptionsField,
+ string DataField);
+}
diff --git a/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs
new file mode 100644
index 00000000..dde3e23b
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/ChartJsMcpTools.cs
@@ -0,0 +1,143 @@
+using System.ComponentModel;
+using ModelContextProtocol.Server;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+[McpServerToolType]
+public static class ChartJsMcpTools
+{
+ [McpServerTool(Name = "list_chart_types")]
+ [Description("Lists chart types supported by BlazorExpress.ChartJS code generation.")]
+ public static string ListChartTypes() =>
+ McpToolExecution.Run(() => ChartCatalog.All.Select(x => new
+ {
+ x.Name,
+ x.ComponentName,
+ x.OptionsTypeName,
+ x.DatasetTypeName,
+ x.SupportsDatalabels,
+ x.SupportsStacking,
+ x.SupportsOrientation,
+ x.SupportsPluginOptions,
+ x.SupportsTitleOptions,
+ x.SupportsLegendOptions,
+ }));
+
+ [McpServerTool(Name = "get_chart_generation_schema")]
+ [Description("Returns the code-generation input schema and local API metadata for a BlazorExpress.ChartJS chart type.")]
+ public static string GetChartGenerationSchema(
+ [Description("Chart type, for example Bar, Line, Pie, Doughnut, Bubble, Scatter, Radar, or PolarArea.")]
+ string chartType) =>
+ McpToolExecution.Run(() => ChartCatalog.GetSchema(chartType));
+
+ [McpServerTool(Name = "generate_chart_example")]
+ [Description("Generates a complete ready-to-paste Razor example for a BlazorExpress.ChartJS chart. labelsJson is a JSON string array. datasetsJson is a JSON array of { label, data, points, backgroundColor, borderColor, stack }.")]
+ public static string GenerateChartExample(
+ string chartType,
+ string? title = null,
+ string? route = null,
+ string? pageName = null,
+ string? labelsJson = null,
+ string? datasetsJson = null,
+ int? width = null,
+ int? height = null,
+ string? legendPosition = null,
+ bool? datalabels = null,
+ bool? stacked = null,
+ string? orientation = null)
+ => McpToolExecution.Run(() =>
+ {
+ var request = ChartRequestParser.CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation);
+ var generator = new ChartExampleGenerator();
+ return generator.Generate(request);
+ });
+
+ [McpServerTool(Name = "generate_chart_dashboard")]
+ [Description("Generates a complete ready-to-paste Razor dashboard page. chartsJson is an optional JSON array of chart specs using chartType, title, labels, datasets, width, height, legendPosition, datalabels, stacked, and orientation.")]
+ public static string GenerateChartDashboard(
+ string? title = null,
+ string? route = null,
+ string? pageName = null,
+ string? chartsJson = null)
+ => McpToolExecution.Run(() =>
+ {
+ var request = new ChartDashboardRequest
+ {
+ Title = title,
+ Route = route,
+ PageName = pageName,
+ Charts = ChartRequestParser.ParseDashboardCharts(chartsJson),
+ };
+
+ var generator = new ChartExampleGenerator();
+ return generator.GenerateDashboard(request);
+ });
+
+ [McpServerTool(Name = "preview_project_integration")]
+ [Description("Creates a non-mutating preview plan for adding a generated chart page and required BlazorExpress.ChartJS setup to a target Blazor project.")]
+ public static string PreviewProjectIntegration(
+ string targetProjectPath,
+ string chartType,
+ string? title = null,
+ string? route = null,
+ string? pageName = null,
+ string? labelsJson = null,
+ string? datasetsJson = null,
+ int? width = null,
+ int? height = null,
+ string? legendPosition = null,
+ bool? datalabels = null,
+ bool? stacked = null,
+ string? orientation = null)
+ => McpToolExecution.Run(() =>
+ {
+ var request = new PreviewIntegrationRequest
+ {
+ TargetProjectPath = targetProjectPath,
+ Chart = ChartRequestParser.CreateChartRequest(chartType, title, route, pageName, labelsJson, datasetsJson, width, height, legendPosition, datalabels, stacked, orientation),
+ };
+
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+ return service.Preview(request);
+ });
+
+ [McpServerTool(Name = "preview_dashboard_integration")]
+ [Description("Creates a non-mutating preview plan for adding a generated chart dashboard page and required BlazorExpress.ChartJS setup to a target Blazor project.")]
+ public static string PreviewDashboardIntegration(
+ string targetProjectPath,
+ string? title = null,
+ string? route = null,
+ string? pageName = null,
+ string? chartsJson = null)
+ => McpToolExecution.Run(() =>
+ {
+ var request = new PreviewDashboardIntegrationRequest
+ {
+ TargetProjectPath = targetProjectPath,
+ Dashboard = new ChartDashboardRequest
+ {
+ Title = title,
+ Route = route,
+ PageName = pageName,
+ Charts = ChartRequestParser.ParseDashboardCharts(chartsJson),
+ },
+ };
+
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+ return service.PreviewDashboard(request);
+ });
+
+ [McpServerTool(Name = "apply_project_integration")]
+ [Description("Applies a preview_project_integration plan after validating its plan hash and current file hashes.")]
+ public static string ApplyProjectIntegration(
+ [Description("The full JSON plan returned by preview_project_integration.")]
+ string integrationPlanJson)
+ => McpToolExecution.Run(() =>
+ {
+ var plan = Json.Deserialize(integrationPlanJson)
+ ?? throw new ToolInputException("integrationPlanJson must contain a serialized integration plan.", "integrationPlanJson");
+
+ var service = new ProjectIntegrationService(new ChartExampleGenerator());
+ return service.Apply(plan);
+ });
+}
diff --git a/BlazorExpress.ChartJS.MCP/ChartRequestParser.cs b/BlazorExpress.ChartJS.MCP/ChartRequestParser.cs
new file mode 100644
index 00000000..7068f492
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/ChartRequestParser.cs
@@ -0,0 +1,278 @@
+using System.Text.Json;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+internal static class ChartRequestParser
+{
+ public static ChartGenerationRequest CreateChartRequest(
+ string chartType,
+ string? title,
+ string? route,
+ string? pageName,
+ string? labelsJson,
+ string? datasetsJson,
+ int? width,
+ int? height,
+ string? legendPosition,
+ bool? datalabels,
+ bool? stacked,
+ string? orientation) =>
+ new()
+ {
+ ChartType = chartType,
+ Title = title,
+ Route = route,
+ PageName = pageName,
+ Labels = ParseLabels(labelsJson),
+ Datasets = ParseDatasets(datasetsJson),
+ Width = width,
+ Height = height,
+ LegendPosition = legendPosition,
+ Datalabels = datalabels,
+ Stacked = stacked,
+ Orientation = orientation,
+ };
+
+ public static IReadOnlyList? ParseDashboardCharts(string? chartsJson)
+ {
+ if (string.IsNullOrWhiteSpace(chartsJson))
+ return null;
+
+ try
+ {
+ using var document = JsonDocument.Parse(chartsJson);
+ if (document.RootElement.ValueKind is not JsonValueKind.Array)
+ throw new ToolInputException("chartsJson must be a JSON array of chart specifications.", "chartsJson");
+
+ var charts = new List();
+ var index = 0;
+ foreach (var element in document.RootElement.EnumerateArray())
+ {
+ if (element.ValueKind is not JsonValueKind.Object)
+ throw new ToolInputException($"chartsJson[{index}] must be an object.", "chartsJson");
+
+ charts.Add(ParseChartObject(element, $"chartsJson[{index}]"));
+ index++;
+ }
+
+ return charts;
+ }
+ catch (JsonException exception)
+ {
+ throw new ToolInputException($"chartsJson is not valid JSON: {exception.Message}", "chartsJson");
+ }
+ }
+
+ private static IReadOnlyList? ParseLabels(string? labelsJson)
+ {
+ if (string.IsNullOrWhiteSpace(labelsJson))
+ return null;
+
+ try
+ {
+ var labels = Json.Deserialize>(labelsJson);
+ return labels;
+ }
+ catch (JsonException exception)
+ {
+ throw new ToolInputException($"labelsJson must be a JSON string array: {exception.Message}", "labelsJson");
+ }
+ }
+
+ private static IReadOnlyList? ParseDatasets(string? datasetsJson)
+ {
+ if (string.IsNullOrWhiteSpace(datasetsJson))
+ return null;
+
+ try
+ {
+ using var document = JsonDocument.Parse(datasetsJson);
+ if (document.RootElement.ValueKind is not JsonValueKind.Array)
+ throw new ToolInputException("datasetsJson must be a JSON array.", "datasetsJson");
+
+ return ParseDatasetArray(document.RootElement, "datasetsJson");
+ }
+ catch (JsonException exception)
+ {
+ throw new ToolInputException($"datasetsJson is not valid JSON: {exception.Message}", "datasetsJson");
+ }
+ }
+
+ private static ChartGenerationRequest ParseChartObject(JsonElement element, string field)
+ {
+ var chartType = GetOptionalString(element, "chartType")
+ ?? throw new ToolInputException($"{field}.chartType is required.", field);
+
+ return new ChartGenerationRequest
+ {
+ ChartType = chartType,
+ Title = GetOptionalString(element, "title"),
+ Route = GetOptionalString(element, "route"),
+ PageName = GetOptionalString(element, "pageName"),
+ Labels = element.TryGetProperty("labels", out var labels) ? ParseStringArray(labels, $"{field}.labels") : null,
+ Datasets = element.TryGetProperty("datasets", out var datasets) ? ParseDatasetArray(datasets, $"{field}.datasets") : null,
+ Width = GetOptionalInt(element, "width", field),
+ Height = GetOptionalInt(element, "height", field),
+ LegendPosition = GetOptionalString(element, "legendPosition"),
+ Datalabels = GetOptionalBool(element, "datalabels", field),
+ Stacked = GetOptionalBool(element, "stacked", field),
+ Orientation = GetOptionalString(element, "orientation"),
+ };
+ }
+
+ private static IReadOnlyList ParseDatasetArray(JsonElement element, string field)
+ {
+ if (element.ValueKind is not JsonValueKind.Array)
+ throw new ToolInputException($"{field} must be a JSON array.", field);
+
+ var datasets = new List();
+ var index = 0;
+ foreach (var datasetElement in element.EnumerateArray())
+ {
+ if (datasetElement.ValueKind is not JsonValueKind.Object)
+ throw new ToolInputException($"{field}[{index}] must be an object.", field);
+
+ datasets.Add(new ChartDatasetRequest
+ {
+ Label = GetOptionalString(datasetElement, "label"),
+ Data = datasetElement.TryGetProperty("data", out var data) ? ParseNullableNumberArray(data, $"{field}[{index}].data") : null,
+ Points = datasetElement.TryGetProperty("points", out var points) ? ParsePoints(points, $"{field}[{index}].points") : null,
+ BackgroundColor = datasetElement.TryGetProperty("backgroundColor", out var backgroundColor) ? ParseStringOrStringArray(backgroundColor, $"{field}[{index}].backgroundColor") : null,
+ BorderColor = datasetElement.TryGetProperty("borderColor", out var borderColor) ? ParseStringOrStringArray(borderColor, $"{field}[{index}].borderColor") : null,
+ Stack = GetOptionalString(datasetElement, "stack"),
+ });
+ index++;
+ }
+
+ return datasets;
+ }
+
+ private static IReadOnlyList ParseStringOrStringArray(JsonElement element, string field) =>
+ element.ValueKind switch
+ {
+ JsonValueKind.String => [element.GetString() ?? ""],
+ JsonValueKind.Array => ParseStringArray(element, field),
+ _ => throw new ToolInputException($"{field} must be a string or string array.", field),
+ };
+
+ private static IReadOnlyList ParseStringArray(JsonElement element, string field)
+ {
+ if (element.ValueKind is not JsonValueKind.Array)
+ throw new ToolInputException($"{field} must be a string array.", field);
+
+ var values = new List();
+ foreach (var item in element.EnumerateArray())
+ {
+ if (item.ValueKind is not JsonValueKind.String)
+ throw new ToolInputException($"{field} must contain only strings.", field);
+
+ values.Add(item.GetString() ?? "");
+ }
+
+ return values;
+ }
+
+ private static IReadOnlyList ParseNullableNumberArray(JsonElement element, string field)
+ {
+ if (element.ValueKind is not JsonValueKind.Array)
+ throw new ToolInputException($"{field} must be a number array.", field);
+
+ var values = new List();
+ foreach (var item in element.EnumerateArray())
+ {
+ if (item.ValueKind is JsonValueKind.Null)
+ {
+ values.Add(null);
+ continue;
+ }
+
+ if (item.ValueKind is not JsonValueKind.Number || !item.TryGetDouble(out var value))
+ throw new ToolInputException($"{field} must contain only numbers or null.", field);
+
+ values.Add(value);
+ }
+
+ return values;
+ }
+
+ private static IReadOnlyList ParsePoints(JsonElement element, string field)
+ {
+ if (element.ValueKind is not JsonValueKind.Array)
+ throw new ToolInputException($"{field} must be an array of point objects.", field);
+
+ var points = new List();
+ var index = 0;
+ foreach (var point in element.EnumerateArray())
+ {
+ if (point.ValueKind is not JsonValueKind.Object)
+ throw new ToolInputException($"{field}[{index}] must be an object.", field);
+
+ points.Add(new ChartPointRequest
+ {
+ X = GetRequiredDouble(point, "x", $"{field}[{index}]"),
+ Y = GetRequiredDouble(point, "y", $"{field}[{index}]"),
+ R = GetOptionalDouble(point, "r", $"{field}[{index}]"),
+ });
+ index++;
+ }
+
+ return points;
+ }
+
+ private static string? GetOptionalString(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null)
+ return null;
+
+ if (value.ValueKind is not JsonValueKind.String)
+ throw new ToolInputException($"{propertyName} must be a string.", propertyName);
+
+ return value.GetString();
+ }
+
+ private static int? GetOptionalInt(JsonElement element, string propertyName, string field)
+ {
+ if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null)
+ return null;
+
+ if (value.ValueKind is not JsonValueKind.Number || !value.TryGetInt32(out var result))
+ throw new ToolInputException($"{field}.{propertyName} must be an integer.", $"{field}.{propertyName}");
+
+ return result;
+ }
+
+ private static bool? GetOptionalBool(JsonElement element, string propertyName, string field)
+ {
+ if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null)
+ return null;
+
+ return value.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ _ => throw new ToolInputException($"{field}.{propertyName} must be a boolean.", $"{field}.{propertyName}"),
+ };
+ }
+
+ private static double GetRequiredDouble(JsonElement element, string propertyName, string field)
+ {
+ if (!element.TryGetProperty(propertyName, out var value))
+ throw new ToolInputException($"{field}.{propertyName} is required.", $"{field}.{propertyName}");
+
+ if (value.ValueKind is not JsonValueKind.Number || !value.TryGetDouble(out var result))
+ throw new ToolInputException($"{field}.{propertyName} must be a number.", $"{field}.{propertyName}");
+
+ return result;
+ }
+
+ private static double? GetOptionalDouble(JsonElement element, string propertyName, string field)
+ {
+ if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind is JsonValueKind.Null)
+ return null;
+
+ if (value.ValueKind is not JsonValueKind.Number || !value.TryGetDouble(out var result))
+ throw new ToolInputException($"{field}.{propertyName} must be a number.", $"{field}.{propertyName}");
+
+ return result;
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP/Json.cs b/BlazorExpress.ChartJS.MCP/Json.cs
new file mode 100644
index 00000000..d0e026aa
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/Json.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+public static class Json
+{
+ public static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true,
+ };
+
+ public static string Serialize(T value) =>
+ JsonSerializer.Serialize(value, SerializerOptions);
+
+ public static T? Deserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return default;
+
+ return JsonSerializer.Deserialize(json, SerializerOptions);
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP/McpApplication.cs b/BlazorExpress.ChartJS.MCP/McpApplication.cs
new file mode 100644
index 00000000..7ed07b64
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/McpApplication.cs
@@ -0,0 +1,189 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ModelContextProtocol.AspNetCore;
+using ModelContextProtocol.Server;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+public static class McpApplication
+{
+ public const string DefaultHttpUrl = "http://localhost:5000";
+ public const string McpPath = "/mcp";
+ public const string HealthPath = "/health";
+ public const string TokenConfigurationKey = "CHARTJS_MCP_TOKEN";
+
+ public static Task RunAsync(string[] args, CancellationToken cancellationToken = default)
+ {
+ var options = ParseOptions(args);
+
+ return options.Transport == McpTransportMode.Stdio
+ ? RunStdioAsync(options.RemainingArgs, cancellationToken)
+ : RunHttpAsync(options.RemainingArgs, cancellationToken);
+ }
+
+ public static McpHostOptions ParseOptions(IReadOnlyList args)
+ {
+ var transport = McpTransportMode.Http;
+ var remainingArgs = new List();
+
+ for (var i = 0; i < args.Count; i++)
+ {
+ var arg = args[i];
+
+ if (string.Equals(arg, "--stdio", StringComparison.OrdinalIgnoreCase))
+ {
+ transport = McpTransportMode.Stdio;
+ continue;
+ }
+
+ if (string.Equals(arg, "--http", StringComparison.OrdinalIgnoreCase))
+ {
+ transport = McpTransportMode.Http;
+ continue;
+ }
+
+ if (string.Equals(arg, "--transport", StringComparison.OrdinalIgnoreCase))
+ {
+ if (i + 1 >= args.Count)
+ throw new ArgumentException("Missing value for --transport. Use 'http' or 'stdio'.");
+
+ transport = ParseTransport(args[++i]);
+ continue;
+ }
+
+ if (arg.StartsWith("--transport=", StringComparison.OrdinalIgnoreCase))
+ {
+ transport = ParseTransport(arg["--transport=".Length..]);
+ continue;
+ }
+
+ remainingArgs.Add(arg);
+ }
+
+ return new McpHostOptions(transport, remainingArgs.ToArray());
+ }
+
+ public static WebApplication CreateHttpApp(
+ string[] args,
+ string? environmentName = null,
+ Action? configureWebHost = null,
+ IEnumerable>? configuration = null)
+ {
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ Args = args,
+ EnvironmentName = environmentName,
+ });
+
+ if (configuration is not null)
+ builder.Configuration.AddInMemoryCollection(configuration);
+
+ var useDefaultHttpUrl = !HasUrlOverride(args);
+
+ configureWebHost?.Invoke(builder.WebHost);
+
+ RegisterCoreServices(builder.Services);
+ builder.Services
+ .AddMcpServer()
+ .WithHttpTransport(options =>
+ {
+ options.Stateless = true;
+ })
+ .AddAuthorizationFilters()
+ .WithToolsFromAssembly();
+
+ var token = builder.Configuration[TokenConfigurationKey];
+ if (!builder.Environment.IsDevelopment() && string.IsNullOrWhiteSpace(token))
+ throw new InvalidOperationException($"{TokenConfigurationKey} must be configured when HTTP MCP is running outside Development.");
+
+ var app = builder.Build();
+
+ if (useDefaultHttpUrl)
+ app.Urls.Add(DefaultHttpUrl);
+
+ app.MapGet(HealthPath, () => Results.Ok(new { status = "healthy" }));
+
+ if (!app.Environment.IsDevelopment())
+ app.UseBearerTokenProtection(token!);
+
+ app.MapMcp(McpPath);
+
+ return app;
+ }
+
+ private static async Task RunHttpAsync(string[] args, CancellationToken cancellationToken)
+ {
+ var app = CreateHttpApp(args);
+ await app.RunAsync(cancellationToken);
+ }
+
+ private static async Task RunStdioAsync(string[] args, CancellationToken cancellationToken)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings { Args = args });
+
+ RegisterCoreServices(builder.Services);
+ builder.Services
+ .AddMcpServer()
+ .WithStdioServerTransport()
+ .WithToolsFromAssembly();
+
+ await builder.Build().RunAsync(cancellationToken);
+ }
+
+ private static void RegisterCoreServices(IServiceCollection services)
+ {
+ services
+ .AddSingleton()
+ .AddSingleton();
+ }
+
+ private static McpTransportMode ParseTransport(string value) =>
+ value.ToLowerInvariant() switch
+ {
+ "http" => McpTransportMode.Http,
+ "stdio" => McpTransportMode.Stdio,
+ _ => throw new ArgumentException($"Unsupported MCP transport '{value}'. Use 'http' or 'stdio'."),
+ };
+
+ private static bool HasUrlOverride(IEnumerable args) =>
+ args.Any(arg =>
+ string.Equals(arg, "--urls", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("--urls=", StringComparison.OrdinalIgnoreCase))
+ || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"));
+}
+
+internal static class McpApplicationBuilderExtensions
+{
+ public static IApplicationBuilder UseBearerTokenProtection(this IApplicationBuilder app, string token) =>
+ app.Use(async (context, next) =>
+ {
+ if (!context.Request.Path.StartsWithSegments(McpApplication.McpPath))
+ {
+ await next(context);
+ return;
+ }
+
+ var expectedHeader = $"Bearer {token}";
+ var actualHeader = context.Request.Headers.Authorization.ToString();
+
+ if (!string.Equals(actualHeader, expectedHeader, StringComparison.Ordinal))
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ await next(context);
+ });
+}
+
+public sealed record McpHostOptions(McpTransportMode Transport, string[] RemainingArgs);
+
+public enum McpTransportMode
+{
+ Http,
+ Stdio,
+}
diff --git a/BlazorExpress.ChartJS.MCP/McpToolExecution.cs b/BlazorExpress.ChartJS.MCP/McpToolExecution.cs
new file mode 100644
index 00000000..bb1f792f
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/McpToolExecution.cs
@@ -0,0 +1,47 @@
+using System.Text.Json;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+internal static class McpToolExecution
+{
+ public static string Run(Func action)
+ {
+ try
+ {
+ return Json.Serialize(action());
+ }
+ catch (ToolInputException exception)
+ {
+ return InvalidInput(exception.Message, exception.Field, exception.Details);
+ }
+ catch (ArgumentException exception)
+ {
+ return InvalidInput(exception.Message, exception.ParamName);
+ }
+ catch (JsonException exception)
+ {
+ return InvalidInput(exception.Message, null);
+ }
+ catch (DirectoryNotFoundException exception)
+ {
+ return InvalidInput(exception.Message, "targetProjectPath");
+ }
+ catch (FileNotFoundException exception)
+ {
+ return InvalidInput(exception.Message, "targetProjectPath");
+ }
+ catch (InvalidOperationException exception)
+ {
+ return InvalidInput(exception.Message, null);
+ }
+ }
+
+ private static string InvalidInput(string message, string? field, IReadOnlyList? details = null) =>
+ Json.Serialize(new ToolFailure(
+ Success: false,
+ Error: new ToolError(
+ Code: "invalid_input",
+ Message: message,
+ Field: field,
+ Details: details ?? [])));
+}
diff --git a/BlazorExpress.ChartJS.MCP/Models.cs b/BlazorExpress.ChartJS.MCP/Models.cs
new file mode 100644
index 00000000..014e21dd
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/Models.cs
@@ -0,0 +1,159 @@
+using System.Text.Json.Serialization;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+public sealed record ChartDefinition(
+ string Name,
+ string ComponentName,
+ string OptionsTypeName,
+ string DatasetTypeName,
+ Type ComponentType,
+ Type OptionsType,
+ Type DatasetType,
+ bool SupportsDatalabels,
+ bool SupportsStacking,
+ bool SupportsOrientation,
+ bool SupportsPluginOptions,
+ bool SupportsTitleOptions,
+ bool SupportsLegendOptions,
+ bool SupportsScales);
+
+public sealed record ChartGenerationSchema(
+ string ChartType,
+ string Component,
+ string OptionsType,
+ string DatasetType,
+ bool SupportsDatalabels,
+ bool SupportsStacking,
+ bool SupportsOrientation,
+ bool SupportsPluginOptions,
+ bool SupportsTitleOptions,
+ bool SupportsLegendOptions,
+ IReadOnlyList CommonInputs,
+ IReadOnlyList ChartSpecificInputs,
+ IReadOnlyDictionary Examples,
+ IReadOnlyDictionary Metadata);
+
+public sealed record PropertyMetadata(
+ string Name,
+ string Type,
+ string? Description,
+ string? DefaultValue);
+
+public sealed record ChartGenerationRequest
+{
+ public string ChartType { get; init; } = "Bar";
+ public string? Title { get; init; }
+ public string? Route { get; init; }
+ public string? PageName { get; init; }
+ public IReadOnlyList? Labels { get; init; }
+ public IReadOnlyList? Datasets { get; init; }
+ public int? Width { get; init; }
+ public int? Height { get; init; }
+ public string? LegendPosition { get; init; }
+ public bool? Datalabels { get; init; }
+ public bool? Stacked { get; init; }
+ public string? Orientation { get; init; }
+}
+
+public sealed record ChartDatasetRequest
+{
+ public string? Label { get; init; }
+ public IReadOnlyList? Data { get; init; }
+ public IReadOnlyList? Points { get; init; }
+ public IReadOnlyList? BackgroundColor { get; init; }
+ public IReadOnlyList? BorderColor { get; init; }
+ public string? Stack { get; init; }
+}
+
+public sealed record ChartPointRequest
+{
+ [JsonPropertyName("x")]
+ public double X { get; init; }
+
+ [JsonPropertyName("y")]
+ public double Y { get; init; }
+
+ [JsonPropertyName("r")]
+ public double? R { get; init; }
+}
+
+public sealed record GeneratedChartExample(
+ string ChartType,
+ string Route,
+ string PageName,
+ string Code,
+ IReadOnlyList RequiredScripts);
+
+public sealed record ChartDashboardRequest
+{
+ public string? Title { get; init; }
+ public string? Route { get; init; }
+ public string? PageName { get; init; }
+ public IReadOnlyList? Charts { get; init; }
+}
+
+public sealed record GeneratedChartDashboard(
+ string Route,
+ string PageName,
+ string Code,
+ IReadOnlyList ChartTypes,
+ IReadOnlyList RequiredScripts);
+
+public sealed record PreviewIntegrationRequest
+{
+ public string TargetProjectPath { get; init; } = "";
+ public ChartGenerationRequest Chart { get; init; } = new();
+}
+
+public sealed record PreviewDashboardIntegrationRequest
+{
+ public string TargetProjectPath { get; init; } = "";
+ public ChartDashboardRequest Dashboard { get; init; } = new();
+}
+
+public sealed record IntegrationPlan
+{
+ public string PlanHash { get; init; } = "";
+ public string TargetProjectRoot { get; init; } = "";
+ public string ProjectFilePath { get; init; } = "";
+ public string DetectedHostModel { get; init; } = "";
+ public IReadOnlyList Edits { get; init; } = [];
+ public IReadOnlyList ManualSteps { get; init; } = [];
+}
+
+public sealed record FileEdit
+{
+ public string Path { get; init; } = "";
+ public string Operation { get; init; } = "";
+ public string? OriginalHash { get; init; }
+ public string NewContent { get; init; } = "";
+ public string Description { get; init; } = "";
+}
+
+public sealed record ApplyIntegrationResult(
+ string PlanHash,
+ IReadOnlyList WrittenFiles,
+ IReadOnlyList ManualSteps);
+
+public sealed record ToolFailure(bool Success, ToolError Error);
+
+public sealed record ToolError(
+ string Code,
+ string Message,
+ string? Field = null,
+ IReadOnlyList? Details = null);
+
+public sealed class ToolInputException : Exception
+{
+ public ToolInputException(string message, string? field = null, IReadOnlyList? details = null)
+ : base(message)
+ {
+ Field = field;
+ Details = details ?? [];
+ }
+
+ public string? Field { get; }
+
+ public IReadOnlyList Details { get; }
+}
diff --git a/BlazorExpress.ChartJS.MCP/Program.cs b/BlazorExpress.ChartJS.MCP/Program.cs
new file mode 100644
index 00000000..3eeac2fe
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/Program.cs
@@ -0,0 +1,3 @@
+using BlazorExpress.ChartJS.MCP;
+
+await McpApplication.RunAsync(args);
diff --git a/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs
new file mode 100644
index 00000000..7e804432
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/ProjectIntegrationService.cs
@@ -0,0 +1,442 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+
+namespace BlazorExpress.ChartJS.MCP;
+
+public sealed class ProjectIntegrationService
+{
+ private const string PackageReference = "BlazorExpress.ChartJS";
+ private const string PackageVersion = "1.2.3";
+ private static readonly string[] ExcludedProjectSearchDirectories =
+ [
+ "bin",
+ "obj",
+ ".git",
+ ".vs",
+ "node_modules",
+ "artifacts"
+ ];
+
+ private readonly ChartExampleGenerator generator;
+
+ public ProjectIntegrationService(ChartExampleGenerator generator)
+ {
+ this.generator = generator;
+ }
+
+ public IntegrationPlan Preview(PreviewIntegrationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var generated = generator.Generate(request.Chart);
+ return PreviewGeneratedPage(
+ request.TargetProjectPath,
+ new GeneratedPage(
+ PageName: generated.PageName,
+ Route: generated.Route,
+ Title: request.Chart.Title ?? generated.ChartType + " Chart",
+ Code: generated.Code,
+ RequiredScripts: generated.RequiredScripts,
+ Description: $"generated {generated.ChartType} chart page"));
+ }
+
+ public IntegrationPlan PreviewDashboard(PreviewDashboardIntegrationRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var generated = generator.GenerateDashboard(request.Dashboard);
+ return PreviewGeneratedPage(
+ request.TargetProjectPath,
+ new GeneratedPage(
+ PageName: generated.PageName,
+ Route: generated.Route,
+ Title: request.Dashboard.Title ?? "Chart Dashboard",
+ Code: generated.Code,
+ RequiredScripts: generated.RequiredScripts,
+ Description: "generated chart dashboard page"));
+ }
+
+ public IntegrationPlan PreviewGeneratedPage(string targetProjectPath, GeneratedPage generated)
+ {
+ var project = ResolveProject(targetProjectPath);
+ var hostModel = DetectHostModel(project);
+ var edits = new List();
+ var manualSteps = new List();
+
+ AddProjectReferenceEdit(project, edits);
+ AddImportsEdit(project.RootDirectory, edits);
+ AddScriptEdit(project, hostModel, generated.RequiredScripts, edits, manualSteps);
+ AddPageEdit(project, hostModel, generated, edits);
+ AddNavigationEdit(project.RootDirectory, generated.Route, generated.Title, edits, manualSteps);
+
+ var plan = new IntegrationPlan
+ {
+ TargetProjectRoot = project.RootDirectory,
+ ProjectFilePath = project.ProjectFilePath,
+ DetectedHostModel = hostModel,
+ Edits = edits,
+ ManualSteps = manualSteps,
+ };
+
+ return plan with { PlanHash = ComputePlanHash(plan) };
+ }
+
+ public ApplyIntegrationResult Apply(IntegrationPlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+
+ var expectedHash = ComputePlanHash(plan with { PlanHash = "" });
+ if (!string.Equals(plan.PlanHash, expectedHash, StringComparison.Ordinal))
+ throw new InvalidOperationException("The integration plan hash is stale or invalid. Run preview_project_integration again.");
+
+ var root = Path.GetFullPath(plan.TargetProjectRoot);
+ var writtenFiles = new List();
+
+ foreach (var edit in plan.Edits)
+ {
+ var fullPath = Path.GetFullPath(edit.Path);
+ if (!IsPathInside(root, fullPath))
+ throw new InvalidOperationException($"Refusing to write outside the target project root: {edit.Path}");
+
+ if (File.Exists(fullPath))
+ {
+ var currentHash = HashText(File.ReadAllText(fullPath));
+ if (!string.Equals(edit.OriginalHash, currentHash, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Refusing to overwrite changed file. Re-run preview_project_integration: {fullPath}");
+ }
+ else if (!string.IsNullOrEmpty(edit.OriginalHash))
+ {
+ throw new InvalidOperationException($"Refusing to update a file that no longer exists: {fullPath}");
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
+ File.WriteAllText(fullPath, edit.NewContent, Encoding.UTF8);
+ writtenFiles.Add(fullPath);
+ }
+
+ return new ApplyIntegrationResult(plan.PlanHash, writtenFiles, plan.ManualSteps);
+ }
+
+ public static string ComputePlanHash(IntegrationPlan plan)
+ {
+ var normalized = plan with { PlanHash = "" };
+ return HashText(JsonSerializer.Serialize(normalized, Json.SerializerOptions));
+ }
+
+ private static ProjectContext ResolveProject(string targetProjectPath)
+ {
+ if (string.IsNullOrWhiteSpace(targetProjectPath))
+ throw new ToolInputException("A target project path is required.", "targetProjectPath");
+
+ var path = Path.GetFullPath(targetProjectPath);
+ if (File.Exists(path))
+ {
+ if (string.Equals(Path.GetExtension(path), ".csproj", StringComparison.OrdinalIgnoreCase))
+ return new ProjectContext(path, Path.GetDirectoryName(path)!);
+
+ if (string.Equals(Path.GetExtension(path), ".sln", StringComparison.OrdinalIgnoreCase))
+ return ResolveProjectFromSolution(path);
+
+ throw new ToolInputException("Target project path must be a .csproj file, .sln file, or directory.", "targetProjectPath");
+ }
+
+ if (!Directory.Exists(path))
+ throw new DirectoryNotFoundException($"Target project path was not found: {path}");
+
+ return ResolveProjectFromDirectory(path);
+ }
+
+ private static ProjectContext ResolveProjectFromDirectory(string directory)
+ {
+ var topLevelProjects = Directory.GetFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly);
+ if (topLevelProjects.Length > 0)
+ return ResolveProjectCandidates(topLevelProjects, $"No Blazor app project was found in {directory}.");
+
+ var recursiveProjects = Directory
+ .EnumerateFiles(directory, "*.csproj", SearchOption.AllDirectories)
+ .Where(path => !IsExcludedFromProjectSearch(directory, path))
+ .ToArray();
+
+ return ResolveProjectCandidates(recursiveProjects, $"No Blazor app project was found under {directory}.");
+ }
+
+ private static ProjectContext ResolveProjectFromSolution(string solutionPath)
+ {
+ var solutionDirectory = Path.GetDirectoryName(solutionPath)!;
+ var content = File.ReadAllText(solutionPath);
+ var projectFiles = Regex.Matches(
+ content,
+ @"Project\("".*?""\)\s*=\s*"".*?"",\s*""(?[^""]+\.csproj)""",
+ RegexOptions.IgnoreCase)
+ .Select(match => Path.GetFullPath(Path.Combine(solutionDirectory, match.Groups["path"].Value)))
+ .Where(File.Exists)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ return ResolveProjectCandidates(projectFiles, $"No Blazor app project was found in {solutionPath}.");
+ }
+
+ private static ProjectContext ResolveProjectCandidates(IReadOnlyList projectFiles, string noBlazorProjectMessage)
+ {
+ if (projectFiles.Count == 0)
+ throw new ToolInputException(noBlazorProjectMessage, "targetProjectPath");
+
+ var candidates = projectFiles
+ .Select(CreateProjectCandidate)
+ .OrderBy(x => x.ProjectFilePath, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var blazorAppCandidates = candidates.Where(x => x.IsBlazorApp).ToList();
+
+ if (blazorAppCandidates.Count == 1)
+ return new ProjectContext(blazorAppCandidates[0].ProjectFilePath, blazorAppCandidates[0].RootDirectory);
+
+ if (blazorAppCandidates.Count > 1)
+ throw new ToolInputException(
+ "Multiple Blazor project files were found. Pass the exact .csproj path.",
+ "targetProjectPath",
+ blazorAppCandidates.Select(x => x.ProjectFilePath).ToList());
+
+ throw new ToolInputException(
+ noBlazorProjectMessage,
+ "targetProjectPath",
+ candidates.Select(x => x.ProjectFilePath).ToList());
+ }
+
+ private static ProjectCandidate CreateProjectCandidate(string projectFilePath)
+ {
+ var fullPath = Path.GetFullPath(projectFilePath);
+ var rootDirectory = Path.GetDirectoryName(fullPath)!;
+ var projectText = File.ReadAllText(fullPath);
+
+ return new ProjectCandidate(fullPath, rootDirectory, IsBlazorAppProject(rootDirectory, projectText));
+ }
+
+ private static bool IsBlazorAppProject(string rootDirectory, string projectText) =>
+ projectText.Contains("Microsoft.NET.Sdk.BlazorWebAssembly", StringComparison.OrdinalIgnoreCase)
+ || projectText.Contains("Microsoft.AspNetCore.Components.WebAssembly", StringComparison.OrdinalIgnoreCase)
+ || projectText.Contains("true ", StringComparison.OrdinalIgnoreCase)
+ || File.Exists(Path.Combine(rootDirectory, "App.razor"))
+ || File.Exists(Path.Combine(rootDirectory, "Components", "App.razor"))
+ || File.Exists(Path.Combine(rootDirectory, "wwwroot", "index.html"));
+
+ private static bool IsExcludedFromProjectSearch(string rootDirectory, string path)
+ {
+ var relativePath = Path.GetRelativePath(rootDirectory, path);
+ var segments = relativePath.Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
+
+ return segments.Any(segment => ExcludedProjectSearchDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase));
+ }
+
+ private static string DetectHostModel(ProjectContext project)
+ {
+ var projectText = File.ReadAllText(project.ProjectFilePath);
+
+ if (projectText.Contains("true ", StringComparison.OrdinalIgnoreCase)
+ || projectText.Contains("-ios", StringComparison.OrdinalIgnoreCase)
+ || projectText.Contains("-android", StringComparison.OrdinalIgnoreCase)
+ || projectText.Contains("-maccatalyst", StringComparison.OrdinalIgnoreCase))
+ return "MauiBlazorHybrid";
+
+ if (projectText.Contains("Microsoft.NET.Sdk.BlazorWebAssembly", StringComparison.OrdinalIgnoreCase)
+ || projectText.Contains("Microsoft.AspNetCore.Components.WebAssembly", StringComparison.OrdinalIgnoreCase))
+ return "BlazorWebAssembly";
+
+ if (File.Exists(Path.Combine(project.RootDirectory, "Components", "App.razor"))
+ || File.Exists(Path.Combine(project.RootDirectory, "App.razor")))
+ return "BlazorWebApp";
+
+ if (Directory.Exists(Path.Combine(project.RootDirectory, "wwwroot")))
+ return "BlazorWebAssembly";
+
+ return "UnknownBlazor";
+ }
+
+ private static void AddProjectReferenceEdit(ProjectContext project, List edits)
+ {
+ var content = File.ReadAllText(project.ProjectFilePath);
+ if (content.Contains($"Include=\"{PackageReference}\"", StringComparison.OrdinalIgnoreCase)
+ || content.Contains($"Include='{PackageReference}'", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var document = XDocument.Parse(content, LoadOptions.PreserveWhitespace);
+ var projectElement = document.Root ?? throw new InvalidOperationException("Project file has no root element.");
+ var itemGroup = new XElement("ItemGroup",
+ new XElement("PackageReference",
+ new XAttribute("Include", PackageReference),
+ new XAttribute("Version", PackageVersion)));
+ projectElement.Add(Environment.NewLine, " ", itemGroup, Environment.NewLine);
+
+ AddReplaceEdit(project.ProjectFilePath, content, document.ToString(SaveOptions.DisableFormatting), "Add BlazorExpress.ChartJS package reference.", edits);
+ }
+
+ private static void AddImportsEdit(string root, List edits)
+ {
+ var importsPath = FindFirst(root, "_Imports.razor")
+ ?? Path.Combine(root, "_Imports.razor");
+ var content = File.Exists(importsPath) ? File.ReadAllText(importsPath) : "";
+
+ if (content.Contains("@using BlazorExpress.ChartJS", StringComparison.Ordinal))
+ return;
+
+ var newContent = AppendLine(content, "@using BlazorExpress.ChartJS");
+ AddReplaceEdit(importsPath, content, newContent, "Add BlazorExpress.ChartJS using to _Imports.razor.", edits);
+ }
+
+ private static void AddScriptEdit(ProjectContext project, string hostModel, IReadOnlyList scripts, List edits, List manualSteps)
+ {
+ var candidatePaths = hostModel switch
+ {
+ "BlazorWebApp" => new[]
+ {
+ Path.Combine(project.RootDirectory, "Components", "App.razor"),
+ Path.Combine(project.RootDirectory, "Pages", "_Host.cshtml"),
+ Path.Combine(project.RootDirectory, "App.razor"),
+ },
+ _ => new[]
+ {
+ Path.Combine(project.RootDirectory, "wwwroot", "index.html"),
+ }
+ };
+
+ var scriptFile = candidatePaths.FirstOrDefault(File.Exists);
+ if (scriptFile is null)
+ {
+ manualSteps.Add("Add Chart.js, optional chartjs-plugin-datalabels, and _content/BlazorExpress.ChartJS/blazorexpress.chartjs.js script references to the host page.");
+ return;
+ }
+
+ var content = File.ReadAllText(scriptFile);
+ var newContent = content;
+ foreach (var script in scripts)
+ {
+ if (newContent.Contains(script, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ newContent = InsertBeforeBodyEnd(newContent, $" ");
+ }
+
+ if (!string.Equals(content, newContent, StringComparison.Ordinal))
+ AddReplaceEdit(scriptFile, content, newContent, "Add BlazorExpress.ChartJS script references.", edits);
+ }
+
+ private static void AddPageEdit(ProjectContext project, string hostModel, GeneratedPage generated, List edits)
+ {
+ var pagesDirectory = hostModel switch
+ {
+ "BlazorWebApp" when Directory.Exists(Path.Combine(project.RootDirectory, "Components", "Pages")) => Path.Combine(project.RootDirectory, "Components", "Pages"),
+ _ when Directory.Exists(Path.Combine(project.RootDirectory, "Pages")) => Path.Combine(project.RootDirectory, "Pages"),
+ _ => Path.Combine(project.RootDirectory, "Pages"),
+ };
+
+ var pagePath = Path.Combine(pagesDirectory, $"{generated.PageName}.razor");
+ var original = File.Exists(pagePath) ? File.ReadAllText(pagePath) : "";
+ AddReplaceEdit(pagePath, original, generated.Code, $"Create or update {generated.Description}.", edits);
+ }
+
+ private static void AddNavigationEdit(string root, string route, string title, List edits, List manualSteps)
+ {
+ var navFiles = Directory.GetFiles(root, "NavMenu.razor", SearchOption.AllDirectories)
+ .Where(x => !x.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)
+ && !x.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (navFiles.Count != 1)
+ {
+ manualSteps.Add($"Add a navigation link to {route} ({title}) in your app navigation.");
+ return;
+ }
+
+ var navPath = navFiles[0];
+ var content = File.ReadAllText(navPath);
+ if (content.Contains($"href=\"{route.TrimStart('/')}\"", StringComparison.OrdinalIgnoreCase)
+ || content.Contains($"href=\"{route}\"", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var navLink = $" {title} ";
+ var lines = content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n').ToList();
+ var lastNavLinkIndex = lines.FindLastIndex(x => x.Contains("", StringComparison.OrdinalIgnoreCase);
+ if (bodyEnd >= 0)
+ return content.Insert(bodyEnd, line + Environment.NewLine);
+
+ return AppendLine(content, line);
+ }
+
+ private static string AppendLine(string content, string line)
+ {
+ if (string.IsNullOrEmpty(content))
+ return line + Environment.NewLine;
+
+ return content.EndsWith(Environment.NewLine, StringComparison.Ordinal)
+ ? content + line + Environment.NewLine
+ : content + Environment.NewLine + line + Environment.NewLine;
+ }
+
+ private static string? FindFirst(string root, string fileName) =>
+ Directory.GetFiles(root, fileName, SearchOption.AllDirectories)
+ .Where(x => !x.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)
+ && !x.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(x => x.Length)
+ .FirstOrDefault();
+
+ private static void AddReplaceEdit(string path, string originalContent, string newContent, string description, List edits)
+ {
+ if (string.Equals(originalContent, newContent, StringComparison.Ordinal))
+ return;
+
+ edits.Add(new FileEdit
+ {
+ Path = Path.GetFullPath(path),
+ Operation = File.Exists(path) ? "replace" : "create",
+ OriginalHash = File.Exists(path) ? HashText(originalContent) : null,
+ NewContent = newContent,
+ Description = description,
+ });
+ }
+
+ private static bool IsPathInside(string root, string path)
+ {
+ var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
+ ? root
+ : root + Path.DirectorySeparatorChar;
+ return path.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(root, path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string HashText(string text)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private sealed record ProjectContext(string ProjectFilePath, string RootDirectory);
+
+ private sealed record ProjectCandidate(string ProjectFilePath, string RootDirectory, bool IsBlazorApp);
+
+ public sealed record GeneratedPage(
+ string PageName,
+ string Route,
+ string Title,
+ string Code,
+ IReadOnlyList RequiredScripts,
+ string Description);
+}
diff --git a/BlazorExpress.ChartJS.MCP/Properties/launchSettings.json b/BlazorExpress.ChartJS.MCP/Properties/launchSettings.json
new file mode 100644
index 00000000..c880e613
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/Properties/launchSettings.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "BlazorExpress.ChartJS.MCP": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "BlazorExpress.ChartJS.MCP (stdio)": {
+ "commandName": "Project",
+ "commandLineArgs": "--stdio",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/BlazorExpress.ChartJS.MCP/README.md b/BlazorExpress.ChartJS.MCP/README.md
new file mode 100644
index 00000000..27133f28
--- /dev/null
+++ b/BlazorExpress.ChartJS.MCP/README.md
@@ -0,0 +1,224 @@
+# BlazorExpress.ChartJS.MCP
+
+Model Context Protocol server for generating and integrating BlazorExpress.ChartJS charts.
+
+Requires .NET 10 SDK/runtime.
+
+## Install
+
+```powershell
+dotnet tool install --global BlazorExpress.ChartJS.MCP
+```
+
+## MCP command
+
+```powershell
+blazorexpress-chartjs-mcp
+```
+
+The default command starts a Streamable HTTP MCP server at `http://localhost:5000/mcp`.
+
+The server exposes tools for listing supported chart types, generating complete Razor examples, generating multi-chart dashboard pages, previewing project integration edits, and applying approved integration plans.
+
+Available tools include:
+
+- `list_chart_types`
+- `get_chart_generation_schema`
+- `generate_chart_example`
+- `generate_chart_dashboard`
+- `preview_project_integration`
+- `preview_dashboard_integration`
+- `apply_project_integration`
+
+Successful tool responses return the generated payload directly. Invalid inputs return structured JSON with `success: false` and an `error` object containing `code`, `message`, `field`, and `details`.
+
+HTTP mode does not require authentication when `ASPNETCORE_ENVIRONMENT` is `Development`. In other environments, set `CHARTJS_MCP_TOKEN` and send it as a bearer token.
+
+Use explicit stdio mode for editor integrations that launch the MCP server as a child process:
+
+```powershell
+blazorexpress-chartjs-mcp --stdio
+```
+
+You can also select the transport with:
+
+```powershell
+blazorexpress-chartjs-mcp --transport http
+blazorexpress-chartjs-mcp --transport stdio
+```
+
+## How to test in local
+
+From the repository root, run the solution tests:
+
+```powershell
+dotnet test .\BlazorExpress.ChartJS.sln
+```
+
+Create the local .NET tool package:
+
+```powershell
+dotnet pack .\BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj -c Release
+```
+
+Install the generated package locally:
+
+```powershell
+dotnet tool install --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release
+```
+
+If the tool is already installed, update it instead:
+
+```powershell
+dotnet tool update --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release
+```
+
+Run the MCP server:
+
+```powershell
+$env:ASPNETCORE_ENVIRONMENT = "Development"
+blazorexpress-chartjs-mcp
+```
+
+The command starts a Streamable HTTP MCP server on `http://localhost:5000/mcp`.
+
+Check health:
+
+```powershell
+Invoke-RestMethod http://localhost:5000/health
+```
+
+Run the stdio MCP server for local editor integration:
+
+```powershell
+blazorexpress-chartjs-mcp --stdio
+```
+
+## How to run as a Kestrel service
+
+For local development, no bearer token is required:
+
+```powershell
+$env:ASPNETCORE_ENVIRONMENT = "Development"
+blazorexpress-chartjs-mcp
+```
+
+For production, configure the URL and bearer token:
+
+```powershell
+$env:ASPNETCORE_ENVIRONMENT = "Production"
+$env:ASPNETCORE_URLS = "http://localhost:5000"
+$env:CHARTJS_MCP_TOKEN = ""
+blazorexpress-chartjs-mcp
+```
+
+Production MCP clients must send:
+
+```text
+Authorization: Bearer
+```
+
+The Streamable HTTP endpoint is:
+
+```text
+http://localhost:5000/mcp
+```
+
+The health endpoint is:
+
+```text
+http://localhost:5000/health
+```
+
+## How to integrate with VS Code
+
+Install or update the tool locally first:
+
+```powershell
+dotnet pack .\BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj -c Release
+dotnet tool install --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release
+```
+
+If the tool is already installed:
+
+```powershell
+dotnet tool update --global BlazorExpress.ChartJS.MCP --add-source .\BlazorExpress.ChartJS.MCP\bin\Release
+```
+
+Create or update `.vscode/mcp.json` in your workspace:
+
+```json
+{
+ "servers": {
+ "blazorexpress-chartjs": {
+ "type": "stdio",
+ "command": "blazorexpress-chartjs-mcp",
+ "args": ["--stdio"]
+ }
+ }
+}
+```
+
+For an HTTP MCP client, use this URL:
+
+```text
+http://localhost:5000/mcp
+```
+
+In VS Code:
+
+1. Open Command Palette.
+2. Run `MCP: List Servers`.
+3. Start `blazorexpress-chartjs` if it is not already running.
+4. Open Copilot Chat in Agent mode.
+5. Use the tools exposed by the server, such as `list_chart_types`, `generate_chart_example`, or `generate_chart_dashboard`.
+
+You can also add the server through Command Palette using `MCP: Add Server`, choose a command/stdio server, and use `blazorexpress-chartjs-mcp` as the command.
+
+## How to integrate with Visual Studio
+
+Prerequisite: Visual Studio 2022 version 17.14 or later, or Visual Studio 2026, with GitHub Copilot Agent mode enabled.
+
+Option 1: use Visual Studio chat.
+
+1. Open the Copilot chat pane.
+2. Switch to Agent mode.
+3. Select Tools.
+4. Select the plus (`+`) button.
+5. Select `Add custom MCP server`.
+6. Enter:
+ - Name: `blazorexpress-chartjs`
+ - Transport: `stdio`
+ - Command: `blazorexpress-chartjs-mcp`
+ - Arguments: `--stdio`
+7. Save the server.
+8. Enable the MCP tools from the Tools picker.
+
+Option 2: use a config file.
+
+Create one of these files:
+
+- `\.mcp.json` for a solution-level config that can be checked in.
+- `%USERPROFILE%\.mcp.json` for a user-level config.
+- `\.vscode\mcp.json` if you want VS Code and Visual Studio to share the same workspace config.
+
+Use this configuration:
+
+```json
+{
+ "servers": {
+ "blazorexpress-chartjs": {
+ "type": "stdio",
+ "command": "blazorexpress-chartjs-mcp",
+ "args": ["--stdio"]
+ }
+ }
+}
+```
+
+After saving the file, open Copilot Chat in Agent mode, select Tools, and enable the BlazorExpress.ChartJS MCP tools. Visual Studio may ask for permission before running a tool.
+
+References:
+
+- VS Code MCP servers: https://code.visualstudio.com/docs/agent-customization/mcp-servers
+- Visual Studio MCP servers: https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers
diff --git a/BlazorExpress.ChartJS.sln b/BlazorExpress.ChartJS.sln
index 29244aa1..29d9dad9 100644
--- a/BlazorExpress.ChartJS.sln
+++ b/BlazorExpress.ChartJS.sln
@@ -9,10 +9,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.Demo.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.Demo.WebAssembly", "BlazorExpress.ChartJS.Demo.WebAssembly\BlazorExpress.ChartJS.Demo.WebAssembly.csproj", "{7064944C-CF0A-4D64-AF1B-1F57EFBDA108}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.MCP", "BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj", "{B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorExpress.ChartJS.MCP.Tests", "BlazorExpress.ChartJS.MCP.Tests\BlazorExpress.ChartJS.MCP.Tests.csproj", "{E4609336-CF76-4C99-A40A-B529302D4B9F}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "demos", "demos", "{1B73E52F-BE4E-4EF2-B8E5-A795BA44FF5F}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9F6BB711-0B8B-4790-8B16-F554B9B6462E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -31,6 +37,14 @@ Global
{7064944C-CF0A-4D64-AF1B-1F57EFBDA108}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7064944C-CF0A-4D64-AF1B-1F57EFBDA108}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7064944C-CF0A-4D64-AF1B-1F57EFBDA108}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E4609336-CF76-4C99-A40A-B529302D4B9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E4609336-CF76-4C99-A40A-B529302D4B9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E4609336-CF76-4C99-A40A-B529302D4B9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E4609336-CF76-4C99-A40A-B529302D4B9F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -39,6 +53,8 @@ Global
{F618FB87-B86A-4A57-950A-9DD032E090CB} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{B719B79F-9D20-4D39-96EB-7386B40EE693} = {1B73E52F-BE4E-4EF2-B8E5-A795BA44FF5F}
{7064944C-CF0A-4D64-AF1B-1F57EFBDA108} = {1B73E52F-BE4E-4EF2-B8E5-A795BA44FF5F}
+ {B7E32930-DF90-4F37-BE2E-78D2AF2E5E2F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {E4609336-CF76-4C99-A40A-B529302D4B9F} = {9F6BB711-0B8B-4790-8B16-F554B9B6462E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F3AC9875-2CDD-4111-B39A-FFBF5066FFFF}
diff --git a/BlazorExpress.ChartJS/ChartComponents/BarChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/BarChart.razor.cs
index 13118e35..1951554a 100644
--- a/BlazorExpress.ChartJS/ChartComponents/BarChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/BarChart.razor.cs
@@ -177,7 +177,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
{
var datasets = BarLineChartSupport.GetSupportedDatasets(chartData);
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(BarChartInterop.Initialize, Id, GetChartType(), data, (BarChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(BarChartInterop.Initialize, Id, GetChartType(), data, (BarChartOptions)chartOptions, plugins, DotNetObjectReference);
}
}
diff --git a/BlazorExpress.ChartJS/ChartComponents/BubbleChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/BubbleChart.razor.cs
index 1547854e..6d3d770c 100644
--- a/BlazorExpress.ChartJS/ChartComponents/BubbleChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/BubbleChart.razor.cs
@@ -157,7 +157,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
var datasets = chartData.Datasets.OfType();
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(BubbleChartInterop.Initialize, Id, GetChartType(), data, (BubbleChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(BubbleChartInterop.Initialize, Id, GetChartType(), data, (BubbleChartOptions)chartOptions, plugins, DotNetObjectReference);
}
///
diff --git a/BlazorExpress.ChartJS/ChartComponents/Core/ChartComponentBase.cs b/BlazorExpress.ChartJS/ChartComponents/Core/ChartComponentBase.cs
index ff37ca5c..b9e6859d 100644
--- a/BlazorExpress.ChartJS/ChartComponents/Core/ChartComponentBase.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/Core/ChartComponentBase.cs
@@ -12,6 +12,8 @@ public abstract class ChartComponentBase : BlazorExpressComponentCore, IDisposab
internal ChartType _chartType;
+ private DotNetObjectReference? dotNetObjectReference;
+
#endregion
#region Methods
@@ -76,10 +78,24 @@ public virtual async Task InitializeAsync(ChartData chartData, IChartOptions cha
if (chartData is not null && chartData.Datasets is not null && chartData.Datasets.Any())
{
var _data = GetChartDataObject(chartData);
- await JSRuntime.InvokeVoidAsync(ChartInterop.Initialize, Id, GetChartType(), _data, chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(ChartInterop.Initialize, Id, GetChartType(), _data, chartOptions, plugins, DotNetObjectReference);
}
}
+ ///
+ /// Dispatches a chart-item click to the configured callback.
+ ///
+ /// The selected chart data-item metadata.
+ /// A task that represents the asynchronous callback dispatch operation.
+ [JSInvokable]
+ [AddedVersion("1.2.4")]
+ [Description("Dispatches a chart-item click to the configured callback.")]
+ public async Task HandleClickAsync(ChartClickEventArgs eventArgs)
+ {
+ if (OnClick.HasDelegate)
+ await OnClick.InvokeAsync(eventArgs);
+ }
+
//public async Task Render() { }
//public async Task Reset() { }
@@ -139,6 +155,9 @@ protected string GetChartType() =>
protected string ContainerClassNames => ContainerClass!;
+ protected DotNetObjectReference DotNetObjectReference =>
+ dotNetObjectReference ??= Microsoft.JSInterop.DotNetObjectReference.Create(this);
+
protected string ContainerStyleNames =>
BuildStyleNames(
ContainerStyle,
@@ -241,6 +260,18 @@ private object GetChartDataObject(ChartData chartData)
protected bool IsRenderComplete { get; private set; }
+ ///
+ /// Gets or sets the callback that is invoked when a chart data item is selected.
+ ///
+ /// Default value is .
+ ///
+ ///
+ [AddedVersion("1.2.4")]
+ [DefaultValue(null)]
+ [Description("Gets or sets the callback that is invoked when a chart data item is selected.")]
+ [Parameter]
+ public EventCallback OnClick { get; set; }
+
///
/// Gets or sets chart container width.
/// The default unit of measure is .
@@ -272,6 +303,14 @@ private object GetChartDataObject(ChartData chartData)
#region Other
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ dotNetObjectReference?.Dispose();
+
+ base.Dispose(disposing);
+ }
+
~ChartComponentBase()
{
Dispose(false);
diff --git a/BlazorExpress.ChartJS/ChartComponents/DoughnutChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/DoughnutChart.razor.cs
index 1bce4978..573007a1 100644
--- a/BlazorExpress.ChartJS/ChartComponents/DoughnutChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/DoughnutChart.razor.cs
@@ -156,7 +156,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
{
var datasets = chartData.Datasets.OfType();
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(DoughnutChartInterop.Initialize, Id, GetChartType(), data, (DoughnutChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(DoughnutChartInterop.Initialize, Id, GetChartType(), data, (DoughnutChartOptions)chartOptions, plugins, DotNetObjectReference);
}
}
diff --git a/BlazorExpress.ChartJS/ChartComponents/LineChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/LineChart.razor.cs
index 8c9c7f0d..5ef99166 100644
--- a/BlazorExpress.ChartJS/ChartComponents/LineChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/LineChart.razor.cs
@@ -184,7 +184,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
var datasets = BarLineChartSupport.GetSupportedDatasets(chartData);
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(LineChartInterop.Initialize, Id, GetChartType(), data, (LineChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(LineChartInterop.Initialize, Id, GetChartType(), data, (LineChartOptions)chartOptions, plugins, DotNetObjectReference);
}
///
diff --git a/BlazorExpress.ChartJS/ChartComponents/PieChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/PieChart.razor.cs
index bf303122..2aa9c019 100644
--- a/BlazorExpress.ChartJS/ChartComponents/PieChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/PieChart.razor.cs
@@ -156,7 +156,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
{
var datasets = chartData.Datasets.OfType();
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(PieChartInterop.Initialize, Id, GetChartType(), data, (PieChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(PieChartInterop.Initialize, Id, GetChartType(), data, (PieChartOptions)chartOptions, plugins, DotNetObjectReference);
}
}
diff --git a/BlazorExpress.ChartJS/ChartComponents/PolarAreaChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/PolarAreaChart.razor.cs
index e97ae02a..e258b800 100644
--- a/BlazorExpress.ChartJS/ChartComponents/PolarAreaChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/PolarAreaChart.razor.cs
@@ -150,7 +150,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
{
var datasets = chartData.Datasets.OfType();
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(PolarAreaChartInterop.Initialize, Id, GetChartType(), data, (PolarAreaChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(PolarAreaChartInterop.Initialize, Id, GetChartType(), data, (PolarAreaChartOptions)chartOptions, plugins, DotNetObjectReference);
}
}
diff --git a/BlazorExpress.ChartJS/ChartComponents/RadarChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/RadarChart.razor.cs
index fe27d8da..a92ca63f 100644
--- a/BlazorExpress.ChartJS/ChartComponents/RadarChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/RadarChart.razor.cs
@@ -150,7 +150,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
{
var datasets = chartData.Datasets.OfType();
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(RadarChartInterop.Initialize, Id, GetChartType(), data, (RadarChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(RadarChartInterop.Initialize, Id, GetChartType(), data, (RadarChartOptions)chartOptions, plugins, DotNetObjectReference);
}
}
diff --git a/BlazorExpress.ChartJS/ChartComponents/ScatterChart.razor.cs b/BlazorExpress.ChartJS/ChartComponents/ScatterChart.razor.cs
index 2321dcf1..fb68f974 100644
--- a/BlazorExpress.ChartJS/ChartComponents/ScatterChart.razor.cs
+++ b/BlazorExpress.ChartJS/ChartComponents/ScatterChart.razor.cs
@@ -157,7 +157,7 @@ public override async Task InitializeAsync(ChartData chartData, IChartOptions ch
var datasets = chartData.Datasets.OfType();
var data = new { chartData.Labels, Datasets = datasets };
- await JSRuntime.InvokeVoidAsync(ScatterChartInterop.Initialize, Id, GetChartType(), data, (ScatterChartOptions)chartOptions, plugins);
+ await JSRuntime.InvokeVoidAsync(ScatterChartInterop.Initialize, Id, GetChartType(), data, (ScatterChartOptions)chartOptions, plugins, DotNetObjectReference);
}
///
diff --git a/BlazorExpress.ChartJS/Models/ChartClickEventArgs.cs b/BlazorExpress.ChartJS/Models/ChartClickEventArgs.cs
new file mode 100644
index 00000000..d2f25c0d
--- /dev/null
+++ b/BlazorExpress.ChartJS/Models/ChartClickEventArgs.cs
@@ -0,0 +1,49 @@
+namespace BlazorExpress.ChartJS;
+
+///
+/// Provides information about a selected chart data item.
+///
+public class ChartClickEventArgs
+{
+ #region Properties, Indexers
+
+ ///
+ /// Gets or sets the index of the selected dataset.
+ ///
+ [AddedVersion("1.2.4")]
+ [Description("Gets or sets the index of the selected dataset.")]
+ public int DatasetIndex { get; set; }
+
+ ///
+ /// Gets or sets the label of the selected dataset.
+ ///
+ [AddedVersion("1.2.4")]
+ [DefaultValue(null)]
+ [Description("Gets or sets the label of the selected dataset.")]
+ public string? DatasetLabel { get; set; }
+
+ ///
+ /// Gets or sets the index of the selected data item.
+ ///
+ [AddedVersion("1.2.4")]
+ [Description("Gets or sets the index of the selected data item.")]
+ public int Index { get; set; }
+
+ ///
+ /// Gets or sets the label of the selected data item.
+ ///
+ [AddedVersion("1.2.4")]
+ [DefaultValue(null)]
+ [Description("Gets or sets the label of the selected data item.")]
+ public string? Label { get; set; }
+
+ ///
+ /// Gets or sets the raw value of the selected data item.
+ ///
+ [AddedVersion("1.2.4")]
+ [DefaultValue(null)]
+ [Description("Gets or sets the raw value of the selected data item.")]
+ public JsonElement? Value { get; set; }
+
+ #endregion
+}
diff --git a/BlazorExpress.ChartJS/Usings.cs b/BlazorExpress.ChartJS/Usings.cs
index 7f28513a..55872776 100644
--- a/BlazorExpress.ChartJS/Usings.cs
+++ b/BlazorExpress.ChartJS/Usings.cs
@@ -4,4 +4,5 @@
global using System.ComponentModel;
global using System.Drawing;
global using System.Globalization;
+global using System.Text.Json;
global using System.Text.Json.Serialization;
diff --git a/BlazorExpress.ChartJS/wwwroot/blazorexpress.chartjs.js b/BlazorExpress.ChartJS/wwwroot/blazorexpress.chartjs.js
index c8746fd6..6ba5fdbb 100644
--- a/BlazorExpress.ChartJS/wwwroot/blazorexpress.chartjs.js
+++ b/BlazorExpress.ChartJS/wwwroot/blazorexpress.chartjs.js
@@ -39,6 +39,26 @@ if (!window.blazorexpress.chartjs.scatter) {
}
window.blazorexpress.chartjs = {
+ addClickHandler: (chart, dotNetObjectReference) => {
+ if (!chart || !chart.canvas || !dotNetObjectReference || chart.blazorExpressClickHandler) return;
+
+ chart.blazorExpressClickHandler = true;
+ chart.canvas.addEventListener('click', (event) => {
+ const activeElements = chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }, true);
+ if (!activeElements || activeElements.length === 0) return;
+
+ const activeElement = activeElements[0];
+ const dataset = chart.data.datasets?.[activeElement.datasetIndex];
+
+ dotNetObjectReference.invokeMethodAsync('HandleClickAsync', {
+ datasetIndex: activeElement.datasetIndex,
+ datasetLabel: dataset?.label ?? null,
+ index: activeElement.index,
+ label: chart.data.labels?.[activeElement.index] ?? null,
+ value: dataset?.data?.[activeElement.index] ?? null
+ }).catch(() => { });
+ });
+ },
create: (elementId, type, data, options, plugins) => {
let chartEl = document.getElementById(elementId);
let _plugins = [];
@@ -72,11 +92,14 @@ window.blazorexpress.chartjs = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.get(elementId);
@@ -201,11 +224,14 @@ window.blazorexpress.chartjs.bar = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.bar.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.bar.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.bar.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.bar.get(elementId);
@@ -313,11 +339,14 @@ window.blazorexpress.chartjs.bubble = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.bubble.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.bubble.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.bubble.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.bubble.get(elementId);
@@ -431,11 +460,14 @@ window.blazorexpress.chartjs.doughnut = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.doughnut.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.doughnut.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.doughnut.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.doughnut.get(elementId);
@@ -581,12 +613,14 @@ window.blazorexpress.chartjs.line = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.line.get(elementId);
- if (chart)
- return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.line.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.line.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.line.get(elementId);
@@ -701,11 +735,14 @@ window.blazorexpress.chartjs.pie = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.pie.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.pie.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.pie.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.pie.get(elementId);
@@ -820,11 +857,14 @@ window.blazorexpress.chartjs.polarArea = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.polarArea.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.polarArea.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.polarArea.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.polarArea.get(elementId);
@@ -938,11 +978,14 @@ window.blazorexpress.chartjs.radar = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.radar.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.radar.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.radar.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.radar.get(elementId);
@@ -1073,11 +1116,14 @@ window.blazorexpress.chartjs.scatter = {
return chart;
},
- initialize: (elementId, type, data, options, plugins) => {
+ initialize: (elementId, type, data, options, plugins, dotNetObjectReference) => {
let chart = window.blazorexpress.chartjs.scatter.get(elementId);
- if (chart) return;
- else
+ if (!chart) {
window.blazorexpress.chartjs.scatter.create(elementId, type, data, options, plugins);
+ chart = window.blazorexpress.chartjs.scatter.get(elementId);
+ }
+
+ window.blazorexpress.chartjs.addClickHandler(chart, dotNetObjectReference);
},
resize: (elementId, width, height) => {
let chart = window.blazorexpress.chartjs.scatter.get(elementId);
@@ -1121,4 +1167,4 @@ window.blazorexpress.chartjs.scatter = {
chart.update();
}
}
-}
\ No newline at end of file
+}