()?.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}