using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Sbroenne.ExcelMcp.Generators.Common;
namespace Sbroenne.ExcelMcp.Generators.Mcp;
/// <summary>
/// Generates MCP Server tool classes from Core [ServiceCategory] interfaces.
/// Discovers interfaces from referenced assemblies (Core) and generates
/// [McpServerToolType] classes with properly typed parameters.
///
/// Key features:
/// - [FromString] enum parameters become typed C# enums (not strings) for proper JSON schema
/// - TimeSpan parameters become int (seconds) for JSON compatibility
/// - FileOrValue parameters generate dual parameters (value + file path)
/// - XML docs from Core interfaces become MCP tool descriptions
/// </summary>
[Generator]
public class McpToolGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(context.CompilationProvider,
static (spc, compilation) =>
{
var services = DiscoverServices(compilation);
if (services.Count == 0)
return;
foreach (var info in services)
{
var code = GenerateToolClass(info);
spc.AddSource($"McpTool.{info.CategoryPascal}.g.cs", SourceText.From(code, Encoding.UTF8));
}
});
}
/// <summary>
/// Discovers [ServiceCategory] interfaces from referenced assemblies and extracts ServiceInfo.
/// </summary>
private static List<ServiceInfo> DiscoverServices(Compilation compilation)
{
var result = new List<ServiceInfo>();
foreach (var reference in compilation.References)
{
if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol assembly)
continue;
foreach (var type in GetAllTypes(assembly.GlobalNamespace))
{
if (type.TypeKind != TypeKind.Interface)
continue;
var info = ServiceInfoExtractor.ExtractServiceInfo(type);
if (info != null && info.HasMcpToolAttribute)
result.Add(info);
}
}
return result;
}
private static IEnumerable<INamedTypeSymbol> GetAllTypes(INamespaceSymbol ns)
{
foreach (var type in ns.GetTypeMembers())
yield return type;
foreach (var child in ns.GetNamespaceMembers())
foreach (var type in GetAllTypes(child))
yield return type;
}
/// <summary>
/// Generates a complete MCP tool class for a service category.
/// </summary>
private static string GenerateToolClass(ServiceInfo info)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("// Generator version: nullable-fix-v2");
sb.AppendLine("#nullable enable");
sb.AppendLine("#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member");
sb.AppendLine();
sb.AppendLine("using System.ComponentModel;");
sb.AppendLine("using ModelContextProtocol.Server;");
sb.AppendLine("using Sbroenne.ExcelMcp.Generated;");
sb.AppendLine();
sb.AppendLine("namespace Sbroenne.ExcelMcp.McpServer.Tools;");
sb.AppendLine();
// Class XML doc
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// Generated MCP tool for {info.CategoryPascal} operations.");
sb.AppendLine("/// </summary>");
sb.AppendLine("[McpServerToolType]");
var className = GetClassName(info);
sb.AppendLine($"public static partial class {className}");
sb.AppendLine("{");
// Generate the tool method
GenerateToolMethod(sb, info);
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Generates the MCP tool method with XML docs, attributes, parameters, and body.
/// </summary>
private static void GenerateToolMethod(StringBuilder sb, ServiceInfo info)
{
var enumTypeName = $"{info.CategoryPascal}Action";
// Get all exposed parameters (aggregated across methods)
var exposedParams = ServiceInfoExtractor.GetAllExposedParameters(info);
// Build the enhanced parameter list for MCP
var mcpParams = BuildMcpParameters(info, exposedParams);
// XML doc: interface-level summary
if (!string.IsNullOrEmpty(info.XmlDocSummary))
{
sb.AppendLine(" /// <summary>");
// Wrap the summary text, respecting line breaks
foreach (var line in WrapXmlDocLines(info.XmlDocSummary))
{
sb.AppendLine($" /// {line}");
}
sb.AppendLine(" /// </summary>");
}
// XML doc: parameter descriptions
sb.AppendLine($" /// <param name=\"action\">The action to perform</param>");
if (!info.NoSession)
{
sb.AppendLine($" /// <param name=\"session_id\">Session ID from file 'open' action</param>");
}
foreach (var p in mcpParams)
{
if (!string.IsNullOrEmpty(p.Description))
{
var escapedDesc = EscapeXml(p.Description);
sb.AppendLine($" /// <param name=\"{p.Name}\">{escapedDesc}</param>");
}
}
// Attributes
var title = info.McpToolTitle ?? $"Excel {info.CategoryPascal} Operations";
var destructive = info.McpToolDestructive ? "true" : "false";
sb.AppendLine($" [McpServerTool(Name = \"{info.McpToolName}\", Title = \"{title}\", Destructive = {destructive})]");
var category = info.McpToolCategory ?? "data";
sb.AppendLine($" [McpMeta(\"category\", \"{category}\")]");
sb.AppendLine($" [McpMeta(\"requiresSession\", {(!info.NoSession).ToString().ToLower()})]");
// Method-level [Description] from [McpTool(Description = "...")] attribute.
// Source generators can't read XML docs from metadata references.
if (!string.IsNullOrEmpty(info.McpToolDescription))
{
var methodDesc = EscapeStringLiteral(info.McpToolDescription);
sb.AppendLine($" [Description(\"{methodDesc}\")]");
}
// Method signature — non-partial because MCP SDK's XmlToDescriptionGenerator
// cannot see our generator output to create a matching defining declaration.
var methodName = GetMethodName(info);
sb.Append($" public static string {methodName}(");
sb.AppendLine();
// Action parameter (nullable to prevent SDK-level exception on missing param)
sb.AppendLine($" [Description(\"The action to perform\"), DefaultValue(null)] {enumTypeName}? action,");
// Session parameter (if required)
if (!info.NoSession)
{
sb.Append(" [Description(\"Session ID from file 'open' action\")] string session_id");
if (mcpParams.Count > 0) sb.Append(",");
sb.AppendLine();
}
// Exposed parameters with [Description] and [DefaultValue]
for (int i = 0; i < mcpParams.Count; i++)
{
var p = mcpParams[i];
var defaultExpr = p.DefaultExpression ?? "null";
if (!string.IsNullOrEmpty(p.Description))
{
var desc = EscapeStringLiteral(p.Description);
sb.Append($" [Description(\"{desc}\"), DefaultValue({defaultExpr})] {p.McpTypeName} {p.Name}");
}
else
{
sb.Append($" [DefaultValue({defaultExpr})] {p.McpTypeName} {p.Name}");
}
if (i < mcpParams.Count - 1) sb.Append(",");
sb.AppendLine();
}
sb.AppendLine(" )");
sb.AppendLine(" {");
// Method body: pre-processing and RouteAction call
GenerateMethodBody(sb, info, mcpParams, enumTypeName);
sb.AppendLine(" }");
}
/// <summary>
/// Generates the method body that calls ServiceRegistry.RouteAction.
/// </summary>
private static void GenerateMethodBody(StringBuilder sb, ServiceInfo info, List<McpParameter> mcpParams, string enumTypeName)
{
var registryName = info.CategoryPascal;
var toolName = info.McpToolName;
// Null check: action is nullable to prevent SDK-level exception when param is missing.
// Return a helpful error so the LLM can retry with the correct action.
sb.AppendLine($" if (action == null)");
sb.AppendLine($" return ExcelToolsBase.MissingActionError(\"{toolName}\");");
sb.AppendLine();
var hasPreProcessing = false;
foreach (var p in mcpParams)
{
if (p.PreProcessingCode != null)
{
if (!hasPreProcessing)
{
sb.AppendLine(" // Pre-process parameters");
hasPreProcessing = true;
}
sb.AppendLine($" {p.PreProcessingCode}");
}
}
if (hasPreProcessing) sb.AppendLine();
sb.AppendLine($" return ExcelToolsBase.ExecuteToolAction(");
sb.AppendLine($" \"{toolName}\",");
sb.AppendLine($" ServiceRegistry.{registryName}.ToActionString(action.Value),");
sb.AppendLine($" () => ServiceRegistry.{registryName}.RouteAction(");
sb.AppendLine($" action.Value,");
if (!info.NoSession)
{
sb.AppendLine($" session_id,");
}
else
{
sb.AppendLine($" \"\",");
}
sb.AppendLine($" ExcelToolsBase.ForwardToServiceFunc,");
// Named arguments to RouteAction
for (int i = 0; i < mcpParams.Count; i++)
{
var p = mcpParams[i];
var routeArgName = p.RouteActionParamName;
var routeArgValue = p.RouteActionValue;
var comma = i < mcpParams.Count - 1 ? "," : "";
sb.AppendLine($" {routeArgName}: {routeArgValue}{comma}");
}
sb.AppendLine($" ));");
}
/// <summary>
/// Builds MCP parameter descriptors from the exposed parameters.
/// Handles type conversions: FromString enum → typed enum, TimeSpan → int seconds, etc.
/// </summary>
private static List<McpParameter> BuildMcpParameters(ServiceInfo info, List<ExposedParameter> exposedParams)
{
var result = new List<McpParameter>();
// Build a lookup of param info by exposed name for type resolution
var paramInfoByName = new Dictionary<string, ParameterInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var method in info.Methods)
{
foreach (var p in method.Parameters)
{
var exposedName = p.ExposedName ?? p.Name;
if (!paramInfoByName.ContainsKey(exposedName))
paramInfoByName[exposedName] = p;
}
}
foreach (var ep in exposedParams)
{
paramInfoByName.TryGetValue(ep.Name, out var pInfo);
var snakeName = StringHelper.ToSnakeCase(ep.Name);
// Determine MCP type and conversion
if (pInfo != null && pInfo.IsFromString && pInfo.IsEnum && pInfo.EnumTypeName != null)
{
// [FromString] enum → use typed enum in MCP, convert via .ToString() for RouteAction
result.Add(new McpParameter(
name: snakeName,
mcpTypeName: $"{pInfo.EnumTypeName}?",
routeActionParamName: ep.Name,
routeActionValue: $"{snakeName}?.ToString()",
description: ep.DescriptionWithRequired,
defaultExpression: "null",
preProcessingCode: null));
}
else if (ep.TypeName.Contains("TimeSpan"))
{
// TimeSpan → int seconds in MCP
var secondsName = ep.Name.EndsWith("timeout", StringComparison.OrdinalIgnoreCase)
? ep.Name + "Seconds"
: ep.Name + "Seconds";
var snakeSecondsName = StringHelper.ToSnakeCase(secondsName);
// Use the original name for MCP (the int seconds version)
var localVarName = $"_{ep.Name}";
result.Add(new McpParameter(
name: snakeSecondsName,
mcpTypeName: "int?",
routeActionParamName: ep.Name,
routeActionValue: localVarName,
description: ep.DescriptionWithRequired != null
? ep.DescriptionWithRequired + " (in seconds)"
: "Timeout in seconds",
defaultExpression: "null",
preProcessingCode: $"var {localVarName} = {snakeSecondsName}.HasValue ? System.TimeSpan.FromSeconds({snakeSecondsName}.Value) : (System.TimeSpan?)null;"));
}
else if (ep.TypeName.StartsWith("System.Collections.Generic.List<string>") ||
ep.TypeName.StartsWith("List<string>"))
{
// List<string> → string (JSON array) in MCP, parse via ParseJsonList
var localVarName = $"_{ep.Name}Parsed";
result.Add(new McpParameter(
name: snakeName,
mcpTypeName: "string?",
routeActionParamName: ep.Name,
routeActionValue: localVarName,
description: ep.DescriptionWithRequired != null
? ep.DescriptionWithRequired + " (JSON array, e.g., '[\"value1\",\"value2\"]')"
: "JSON array of strings",
defaultExpression: "null",
preProcessingCode: $"var {localVarName} = Sbroenne.ExcelMcp.Core.Utilities.ParameterTransforms.ParseJsonList({snakeName}, nameof({snakeName}));"));
}
else
{
// Direct passthrough — type matches between MCP and RouteAction
var defaultExpr = GetDefaultExpression(ep.TypeName, pInfo);
var mcpType = ep.TypeName;
// All exposed params are optional in MCP (not all actions use every param).
// If the default is null, ensure the type is nullable to match RouteAction.
if (defaultExpr == "null" && !mcpType.EndsWith("?"))
mcpType += "?";
result.Add(new McpParameter(
name: snakeName,
mcpTypeName: mcpType,
routeActionParamName: ep.Name,
routeActionValue: snakeName,
description: ep.DescriptionWithRequired,
defaultExpression: defaultExpr,
preProcessingCode: null));
}
}
return result;
}
private static string GetDefaultExpression(string typeName, ParameterInfo? pInfo)
{
if (pInfo?.DefaultValue != null)
return pInfo.DefaultValue;
// All optional params default to null
if (typeName.EndsWith("?"))
return "null";
// Bool defaults
if (typeName == "bool")
return "false";
// Int defaults
if (typeName == "int")
return "0";
return "null";
}
private static string GetClassName(ServiceInfo info)
{
// Use CategoryPascal which is already correctly cased
// e.g., "PowerQuery" → "ExcelPowerQueryTool"
return $"Excel{info.CategoryPascal}Tool";
}
private static string GetMethodName(ServiceInfo info)
{
// Use CategoryPascal which is already correctly cased
// e.g., "PowerQuery" → "ExcelPowerQuery"
return $"Excel{info.CategoryPascal}";
}
private static string[] WrapXmlDocLines(string text)
{
// Split on actual newlines, trim each line
return text.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 0)
.ToArray();
}
private static string EscapeXml(string text)
{
return text
.Replace("&", "&")
.Replace("<", "<")
.Replace(">", ">")
.Replace("\"", """);
}
/// <summary>
/// Escapes a string for use inside a C# string literal (double-quoted).
/// </summary>
private static string EscapeStringLiteral(string text)
{
return text
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "")
.Replace("\n", " ");
}
/// <summary>
/// Represents a parameter as it appears in the generated MCP tool method.
/// </summary>
private sealed class McpParameter
{
public string Name { get; }
public string McpTypeName { get; }
public string RouteActionParamName { get; }
public string RouteActionValue { get; }
public string? Description { get; }
public string? DefaultExpression { get; }
public string? PreProcessingCode { get; }
public McpParameter(string name, string mcpTypeName, string routeActionParamName,
string routeActionValue, string? description, string? defaultExpression,
string? preProcessingCode)
{
Name = name;
McpTypeName = mcpTypeName;
RouteActionParamName = routeActionParamName;
RouteActionValue = routeActionValue;
Description = description;
DefaultExpression = defaultExpression;
PreProcessingCode = preProcessingCode;
}
}
}