PowerBiResourceProvider.cs•9.64 kB
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using pbi_local_mcp.Configuration;
using pbi_local_mcp.Core;
namespace pbi_local_mcp.Resources;
/// <summary>
/// Provides Power BI specific metadata and predefined DAX template resources to MCP clients.
/// Resources exposed (URIs):
///  - powerbi://server/info        (basic connection/server metadata)
///  - powerbi://instances          (discovered local Power BI Desktop instances - cached 5s)
///  - powerbi://schema/summary     (lightweight model schema counts - internally cached in ITabularConnection)
///  - dax://templates/*            (static DAX template descriptors)
/// </summary>
public sealed class PowerBiResourceProvider
{
    // EventIds centralized in LogEvents
    private readonly ITabularConnection _tabular;
    private readonly IInstanceDiscovery? _instanceDiscovery;
    private readonly ILogger<PowerBiResourceProvider> _logger;
    private readonly IMemoryCache _cache;
    private readonly ServerInfo _serverInfo;
    private static readonly IReadOnlyDictionary<string, DaxTemplateDescriptor> _templates = BuildTemplates();
    /// <summary>
    /// Initializes a new instance of the <see cref="PowerBiResourceProvider"/> class.
    /// </summary>
    /// <param name="tabularConnection">Connection used to query the tabular model for metadata and schema information.</param>
    /// <param name="logger">Logger instance for diagnostic messages.</param>
    /// <param name="memoryCache">Memory cache used for short-lived resource caching.</param>
    /// <param name="instanceDiscovery">Optional instance discovery service for enumerating local Power BI instances.</param>
    /// <param name="config">Optional Power BI configuration options.</param>
    public PowerBiResourceProvider(
        ITabularConnection tabularConnection,
        ILogger<PowerBiResourceProvider> logger,
        IMemoryCache memoryCache,
        IInstanceDiscovery? instanceDiscovery = null,
        IOptions<PowerBiConfig>? config = null)
    {
        _tabular = tabularConnection ?? throw new ArgumentNullException(nameof(tabularConnection));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _cache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
        _instanceDiscovery = instanceDiscovery;
        _serverInfo = new ServerInfo(
            _tabular.Port,
            _tabular.DatabaseId,
            _tabular.StartupUtc,
            _tabular.AssemblyVersion);
    }
    /// <summary>
    /// Lists available resource descriptors exposed by this provider.
    /// </summary>
    public Task<IEnumerable<ResourceDescriptor>> ListResourcesAsync(CancellationToken ct = default)
    {
        _logger.LogDebug(LogEvents.ResourceRequest, "Listing resources");
        var list = new List<ResourceDescriptor>
        {
            new("powerbi://server/info",        "Power BI connection/server metadata"),
            new("powerbi://instances",          "Discovered local Power BI Desktop instances (cached 5s)"),
            new("powerbi://schema/summary",     "Lightweight model schema summary (tables/measures/columns)"),
        };
        list.AddRange(_templates.Keys.Select(k => new ResourceDescriptor(k, _templates[k].Description)));
        return Task.FromResult<IEnumerable<ResourceDescriptor>>(list);
    }
    /// <summary>
    /// Reads a specific resource by URI.
    /// </summary>
    /// <param name="uri">Resource URI.</param>
    /// <param name="ct">Cancellation token.</param>
    public async Task<object> ReadResourceAsync(string uri, CancellationToken ct = default)
    {
        _logger.LogDebug(LogEvents.ResourceRequest, "ReadResource start Uri={Uri}", uri);
        try
        {
            return uri switch
            {
                "powerbi://server/info" => _serverInfo,
                "powerbi://instances" => await GetInstancesAsync(ct).ConfigureAwait(false),
                "powerbi://schema/summary" => await _tabular.GetSchemaSummaryAsync(ct).ConfigureAwait(false),
                _ when _templates.ContainsKey(uri) => _templates[uri],
                _ => throw new Exception($"[ResourceProvider] Unknown resource URI: {uri}")
            };
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(LogEvents.ResourceError, ex, "Failed reading resource {Uri}", uri);
            throw new Exception($"[ResourceProvider] Failed to read resource '{uri}': {ex.Message}", ex);
        }
    }
    private async Task<IEnumerable<InstanceInfo>> GetInstancesAsync(CancellationToken ct)
    {
        if (_instanceDiscovery == null)
        {
            return Array.Empty<InstanceInfo>();
        }
        const string cacheKey = "PowerBiResourceProvider.Instances";
        if (_cache.TryGetValue(cacheKey, out IEnumerable<InstanceInfo> cached))
        {
            return cached;
        }
        _logger.LogDebug(LogEvents.CacheMiss, "Instance list cache miss");
        var instances = await _instanceDiscovery.DiscoverInstances().ConfigureAwait(false);
        _cache.Set(cacheKey, instances, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5)
        });
        return instances;
    }
    // ----- Static template catalog -----
    private static IReadOnlyDictionary<string, DaxTemplateDescriptor> BuildTemplates()
    {
        var dict = new Dictionary<string, DaxTemplateDescriptor>(StringComparer.OrdinalIgnoreCase);
        dict["dax://templates/topn"] = new DaxTemplateDescriptor(
            "dax://templates/topn",
            "Top N template",
            "Return top N rows ordered by an expression",
            "EVALUATE TOPN(@N, @Table, @OrderExpression)",
            "EVALUATE TOPN(10, 'Sales', [Total Sales])",
            new[]
            {
                new DaxTemplateParameter("N","Number of rows to return", true, "10"),
                new DaxTemplateParameter("Table","Source table reference", true, "'Sales'"),
                new DaxTemplateParameter("OrderExpression","Ordering expression (descending)", true, "[Total Sales]")
            });
        dict["dax://templates/distinct"] = new DaxTemplateDescriptor(
            "dax://templates/distinct",
            "Distinct values template",
            "Return distinct values from a column",
            "EVALUATE DISTINCT(SELECTCOLUMNS(@Table, \"Value\", @Column))",
            "EVALUATE DISTINCT(SELECTCOLUMNS('Customers', \"Value\", 'Customers'[Country]))",
            new[]
            {
                new DaxTemplateParameter("Table","Source table", true, "'Customers'"),
                new DaxTemplateParameter("Column","Column reference", true, "'Customers'[Country]")
            });
        dict["dax://templates/calculate"] = new DaxTemplateDescriptor(
            "dax://templates/calculate",
            "CALCULATE with filter",
            "Apply a filter to an expression using CALCULATE",
            "EVALUATE ROW(\"Value\", CALCULATE(@Expression, @Filter))",
            "EVALUATE ROW(\"Value\", CALCULATE([Total Sales], 'Calendar'[Year]=2024))",
            new[]
            {
                new DaxTemplateParameter("Expression","Base measure or expression", true, "[Total Sales]"),
                new DaxTemplateParameter("Filter","Filter predicate", true, "'Calendar'[Year]=2024")
            });
        dict["dax://templates/filter"] = new DaxTemplateDescriptor(
            "dax://templates/filter",
            "FILTER wrapper",
            "Filter a table expression with a predicate",
            "EVALUATE FILTER(@Table, @Predicate)",
            "EVALUATE FILTER('Sales', [Total Sales] > 1000)",
            new[]
            {
                new DaxTemplateParameter("Table","Table expression", true, "'Sales'"),
                new DaxTemplateParameter("Predicate","Filter predicate", true, "[Total Sales] > 1000")
            });
        dict["dax://templates/summarize"] = new DaxTemplateDescriptor(
            "dax://templates/summarize",
            "Summarize template",
            "Summarize a table by grouping columns and aggregating measures",
            "EVALUATE SUMMARIZE(@Table, @GroupByColumns, \"Value\", @MeasureExpression)",
            "EVALUATE SUMMARIZE('Sales', 'Sales'[Region], \"Value\", [Total Sales])",
            new[]
            {
                new DaxTemplateParameter("Table","Base table", true, "'Sales'"),
                new DaxTemplateParameter("GroupByColumns","Grouping columns (comma-separated)", true, "'Sales'[Region]"),
                new DaxTemplateParameter("MeasureExpression","Aggregation measure/expression", true, "[Total Sales]")
            });
        return dict;
    }
    // ----- Records -----
    /// <summary>Descriptor for a resource URI.</summary>
    public record ResourceDescriptor(string Uri, string Description);
    /// <summary>Power BI server / connection metadata.</summary>
    public record ServerInfo(int Port, string? DatabaseId, DateTime StartupUtc, Version AssemblyVersion);
    /// <summary>Template parameter descriptor.</summary>
    public record DaxTemplateParameter(string Name, string Description, bool Required, string? DefaultValue);
    /// <summary>DAX template descriptor.</summary>
    public record DaxTemplateDescriptor(
        string Uri,
        string Name,
        string Description,
        string Template,
        string Sample,
        IReadOnlyList<DaxTemplateParameter> Parameters);
}