Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\BlazorExpress.ChartJS.MCP\BlazorExpress.ChartJS.MCP.csproj" />
</ItemGroup>

</Project>
25 changes: 25 additions & 0 deletions BlazorExpress.ChartJS.MCP.Tests/ChartCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace BlazorExpress.ChartJS.MCP.Tests;

public class ChartCatalogTests
{
[Fact]
public void All_Returns_Current_Public_Chart_Types()
{
var names = ChartCatalog.All.Select(x => x.Name).ToArray();

Assert.Equal(["Bar", "Bubble", "Doughnut", "Line", "Pie", "PolarArea", "Radar", "Scatter"], names);
}

[Theory]
[InlineData("Bar", "BarChart", "BarChartOptions", "BarChartDataset")]
[InlineData("polar-area", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset")]
public void GetSchema_Returns_Metadata_For_Chart_Type(string chartType, string component, string options, string dataset)
{
var schema = ChartCatalog.GetSchema(chartType);

Assert.Equal(component, schema.Component);
Assert.Equal(options, schema.OptionsType);
Assert.Equal(dataset, schema.DatasetType);
Assert.NotEmpty(schema.CommonInputs);
}
}
36 changes: 36 additions & 0 deletions BlazorExpress.ChartJS.MCP.Tests/ChartExampleGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace BlazorExpress.ChartJS.MCP.Tests;

public class ChartExampleGeneratorTests
{
[Theory]
[InlineData("Bar", "BarChart", "BarChartOptions", "BarChartDataset")]
[InlineData("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset")]
[InlineData("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset")]
[InlineData("Line", "LineChart", "LineChartOptions", "LineChartDataset")]
[InlineData("Pie", "PieChart", "PieChartOptions", "PieChartDataset")]
[InlineData("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset")]
[InlineData("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset")]
[InlineData("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset")]
public void Generate_Includes_Expected_Razor_Types_For_All_Charts(string chartType, string component, string options, string dataset)
{
var generator = new ChartExampleGenerator();

var generated = generator.Generate(new ChartGenerationRequest { ChartType = chartType, Title = $"{chartType} Sample" });

Assert.Contains($"<{component} @ref=", generated.Code);
Assert.Contains($"private {options}", generated.Code);
Assert.Contains($"new {dataset}", generated.Code);
Assert.Contains("@using BlazorExpress.ChartJS", generated.Code);
}

[Fact]
public void Generate_Bar_Datalabels_Uses_Plugin()
{
var generator = new ChartExampleGenerator();

var generated = generator.Generate(new ChartGenerationRequest { ChartType = "Bar", Datalabels = true });

Assert.Contains("ChartDataLabels", generated.Code);
Assert.Contains("chartjs-plugin-datalabels", string.Join(" ", generated.RequiredScripts));
}
}
100 changes: 100 additions & 0 deletions BlazorExpress.ChartJS.MCP.Tests/ProjectIntegrationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace BlazorExpress.ChartJS.MCP.Tests;

public class ProjectIntegrationServiceTests
{
[Fact]
public void Preview_For_WebAssembly_Project_Creates_Plan_Without_Writing_Files()
{
using var workspace = new TemporaryWorkspace();
var projectDirectory = workspace.CreateWebAssemblyProject();
var service = new ProjectIntegrationService(new ChartExampleGenerator());

var plan = service.Preview(new PreviewIntegrationRequest
{
TargetProjectPath = projectDirectory,
Chart = new ChartGenerationRequest { ChartType = "Line", Title = "Sales Trend", Route = "/charts/sales-trend" },
});

Assert.Equal("BlazorWebAssembly", plan.DetectedHostModel);
Assert.NotEmpty(plan.PlanHash);
Assert.Contains(plan.Edits, x => x.Path.EndsWith("LineChartPage.razor", StringComparison.Ordinal));
Assert.False(File.Exists(Path.Combine(projectDirectory, "Pages", "LineChartPage.razor")));
}

[Fact]
public void Apply_Writes_Files_For_Matching_Preview()
{
using var workspace = new TemporaryWorkspace();
var projectDirectory = workspace.CreateWebAssemblyProject();
var service = new ProjectIntegrationService(new ChartExampleGenerator());
var plan = service.Preview(new PreviewIntegrationRequest
{
TargetProjectPath = projectDirectory,
Chart = new ChartGenerationRequest { ChartType = "Pie", Title = "Sales Mix" },
});

var result = service.Apply(plan);

Assert.NotEmpty(result.WrittenFiles);
Assert.True(File.Exists(Path.Combine(projectDirectory, "Pages", "PieChartPage.razor")));
}

[Fact]
public void Apply_Rejects_Stale_Preview()
{
using var workspace = new TemporaryWorkspace();
var projectDirectory = workspace.CreateWebAssemblyProject();
var service = new ProjectIntegrationService(new ChartExampleGenerator());
var plan = service.Preview(new PreviewIntegrationRequest
{
TargetProjectPath = projectDirectory,
Chart = new ChartGenerationRequest { ChartType = "Bar" },
});

File.AppendAllText(Path.Combine(projectDirectory, "_Imports.razor"), "@using Changed");

Assert.Throws<InvalidOperationException>(() => service.Apply(plan));
}

private sealed class TemporaryWorkspace : IDisposable
{
private readonly string root = Path.Combine(Path.GetTempPath(), $"bex-chartjs-mcp-tests-{Guid.NewGuid():N}");

public string CreateWebAssemblyProject()
{
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, "wwwroot"));
Directory.CreateDirectory(Path.Combine(root, "Pages"));
Directory.CreateDirectory(Path.Combine(root, "Shared"));

File.WriteAllText(Path.Combine(root, "Sample.csproj"), """
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
""");
File.WriteAllText(Path.Combine(root, "_Imports.razor"), "@using Microsoft.AspNetCore.Components" + Environment.NewLine);
File.WriteAllText(Path.Combine(root, "wwwroot", "index.html"), """
<html>
<body>
<div id="app"></div>
</body>
</html>
""");
File.WriteAllText(Path.Combine(root, "Shared", "NavMenu.razor"), """
<nav>
<NavLink class="nav-link" href="">Home</NavLink>
</nav>
""");

return root;
}

public void Dispose()
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}
}
2 changes: 2 additions & 0 deletions BlazorExpress.ChartJS.MCP.Tests/Usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using BlazorExpress.ChartJS.MCP;
global using Xunit;
36 changes: 36 additions & 0 deletions BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

<PackAsTool>true</PackAsTool>
<ToolCommandName>blazorexpress-chartjs-mcp</ToolCommandName>
<PackageId>BlazorExpress.ChartJS.MCP</PackageId>
<Version>0.1.0</Version>
<PackageVersion>0.1.0</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://chartjs.blazorexpress.com</PackageProjectUrl>
<RepositoryUrl>https://github.com/BlazorExpress/BlazorExpress.ChartJS</RepositoryUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Model Context Protocol server for generating and integrating BlazorExpress.ChartJS charts.</Description>
<Authors>Vikram Reddy</Authors>
<Company>Blazor Express</Company>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.4.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\BlazorExpress.ChartJS\BlazorExpress.ChartJS.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

</Project>
111 changes: 111 additions & 0 deletions BlazorExpress.ChartJS.MCP/ChartCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.ComponentModel;
using System.Reflection;

namespace BlazorExpress.ChartJS.MCP;

public static class ChartCatalog
{
private static readonly IReadOnlyDictionary<string, ChartDefinition> DefinitionsByKey = new Dictionary<string, ChartDefinition>(StringComparer.OrdinalIgnoreCase)
{
["bar"] = new("Bar", "BarChart", "BarChartOptions", "BarChartDataset", typeof(BarChart), typeof(BarChartOptions), typeof(BarChartDataset), true, true, true),
["bubble"] = new("Bubble", "BubbleChart", "BubbleChartOptions", "BubbleChartDataset", typeof(BubbleChart), typeof(BubbleChartOptions), typeof(BubbleChartDataset), true, false, false),
["doughnut"] = new("Doughnut", "DoughnutChart", "DoughnutChartOptions", "DoughnutChartDataset", typeof(DoughnutChart), typeof(DoughnutChartOptions), typeof(DoughnutChartDataset), true, false, false),
["line"] = new("Line", "LineChart", "LineChartOptions", "LineChartDataset", typeof(LineChart), typeof(LineChartOptions), typeof(LineChartDataset), true, true, false),
["pie"] = new("Pie", "PieChart", "PieChartOptions", "PieChartDataset", typeof(PieChart), typeof(PieChartOptions), typeof(PieChartDataset), true, false, false),
["polararea"] = new("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false),
["polar-area"] = new("PolarArea", "PolarAreaChart", "PolarAreaChartOptions", "PolarAreaChartDataset", typeof(PolarAreaChart), typeof(PolarAreaChartOptions), typeof(PolarAreaChartDataset), true, false, false),
["radar"] = new("Radar", "RadarChart", "RadarChartOptions", "RadarChartDataset", typeof(RadarChart), typeof(RadarChartOptions), typeof(RadarChartDataset), true, false, false),
["scatter"] = new("Scatter", "ScatterChart", "ScatterChartOptions", "ScatterChartDataset", typeof(ScatterChart), typeof(ScatterChartOptions), typeof(ScatterChartDataset), true, false, false),
};

public static IReadOnlyList<ChartDefinition> All { get; } = DefinitionsByKey.Values
.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.Select(x => x.First())
.OrderBy(x => x.Name)
.ToList();

public static ChartDefinition Get(string chartType)
{
var key = NormalizeKey(chartType);

if (DefinitionsByKey.TryGetValue(key, out var definition))
return definition;

throw new ArgumentException($"Unsupported chart type '{chartType}'. Supported values are: {string.Join(", ", All.Select(x => x.Name))}.", nameof(chartType));
}

public static ChartGenerationSchema GetSchema(string chartType)
{
var definition = Get(chartType);

return new ChartGenerationSchema(
ChartType: definition.Name,
Component: definition.ComponentName,
OptionsType: definition.OptionsTypeName,
DatasetType: definition.DatasetTypeName,
SupportsDatalabels: definition.SupportsDatalabels,
SupportsStacking: definition.SupportsStacking,
SupportsOrientation: definition.SupportsOrientation,
CommonInputs:
[
"title",
"route",
"pageName",
"labelsJson",
"datasetsJson",
"width",
"height",
"legendPosition",
"datalabels"
],
ChartSpecificInputs: GetChartSpecificInputs(definition),
Metadata: GetTypeMetadata(definition));
}

private static IReadOnlyList<string> GetChartSpecificInputs(ChartDefinition definition)
{
var inputs = new List<string>();

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<string, object?> GetTypeMetadata(ChartDefinition definition) =>
new Dictionary<string, object?>
{
["componentDescription"] = GetDescription(definition.ComponentType),
["optionsProperties"] = GetPublicPropertyMetadata(definition.OptionsType),
["datasetProperties"] = GetPublicPropertyMetadata(definition.DatasetType),
};

private static string? GetDescription(MemberInfo member) =>
member.GetCustomAttribute<DescriptionAttribute>()?.Description;

private static IReadOnlyList<PropertyMetadata> 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<DescriptionAttribute>()?.Description,
DefaultValue: x.GetCustomAttribute<DefaultValueAttribute>()?.Value?.ToString()))
.OrderBy(x => x.Name)
.ToList();

private static string NormalizeKey(string value) =>
value.Replace(" ", "", StringComparison.Ordinal)
.Replace("_", "", StringComparison.Ordinal)
.ToLowerInvariant();
}
Loading
Loading