diff --git a/.github/workflows/release-mcp-server.yml b/.github/workflows/release-mcp-server.yml index aecd549c..0d14eaa0 100644 --- a/.github/workflows/release-mcp-server.yml +++ b/.github/workflows/release-mcp-server.yml @@ -96,26 +96,14 @@ jobs: dotnet restore src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj dotnet restore src/ExcelMcp.CLI/ExcelMcp.CLI.csproj - - name: Inject Application Insights Connection String - run: | - $telemetryFile = "src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs" - $connectionString = "${{ secrets.APPINSIGHTS_CONNECTION_STRING }}" - - if ([string]::IsNullOrWhiteSpace($connectionString)) { - Write-Output "⚠️ APPINSIGHTS_CONNECTION_STRING secret not configured - telemetry will be disabled" - } else { - Write-Output "Injecting Application Insights connection string..." - $content = Get-Content $telemetryFile -Raw - $content = $content -replace '__APPINSIGHTS_CONNECTION_STRING__', $connectionString - Set-Content $telemetryFile $content - Write-Output "✅ Telemetry connection string injected" - } - shell: pwsh - - name: Build MCP Server & CLI run: | dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj --configuration Release --no-restore dotnet build src/ExcelMcp.CLI/ExcelMcp.CLI.csproj --configuration Release --no-restore + env: + # Application Insights connection string is embedded at build time by MSBuild + # See ExcelMcp.McpServer.csproj for the GenerateTelemetryConfig target + APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} - name: Skip Tests (require Excel) run: | diff --git a/.github/workflows/release-vscode-extension.yml b/.github/workflows/release-vscode-extension.yml index 30e3bcba..93d07dc6 100644 --- a/.github/workflows/release-vscode-extension.yml +++ b/.github/workflows/release-vscode-extension.yml @@ -75,28 +75,19 @@ jobs: cd vscode-extension npm install - - name: Inject Application Insights Connection String - run: | - $telemetryFile = "src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs" - $connectionString = "${{ secrets.APPINSIGHTS_CONNECTION_STRING }}" - - if ([string]::IsNullOrWhiteSpace($connectionString)) { - Write-Output "⚠️ APPINSIGHTS_CONNECTION_STRING secret not configured - telemetry will be disabled" - } else { - Write-Output "Injecting Application Insights connection string..." - $content = Get-Content $telemetryFile -Raw - $content = $content -replace '__APPINSIGHTS_CONNECTION_STRING__', $connectionString - Set-Content $telemetryFile $content - Write-Output "✅ Telemetry connection string injected" - } - shell: pwsh - - name: Build and Package Extension run: | cd vscode-extension # Run the package script which does: build:mcp-server + vsce package npm run package + env: + # Application Insights connection string is embedded at build time by MSBuild + # See ExcelMcp.McpServer.csproj for the GenerateTelemetryConfig target + APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }} + - name: Prepare VSIX + run: | + cd vscode-extension $version = "${{ env.PACKAGE_VERSION }}" # vsce creates filename based on package.json name (excel-mcp) diff --git a/.gitignore b/.gitignore index 6c05ec8f..c4e29247 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,11 @@ gh-pages/_site/* codeql-db/* infrastructure/azure/appinsights.secrets.local + +# Local environment files (secrets, connection strings) +.env +.env.local +.env.*.local + +# Local MSBuild properties (secrets, connection strings) +Directory.Build.props.user diff --git a/Directory.Build.props b/Directory.Build.props index e440a82d..649a8fc4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -49,4 +49,7 @@ + + + diff --git a/Directory.Build.props.user.template b/Directory.Build.props.user.template new file mode 100644 index 00000000..c147d1de --- /dev/null +++ b/Directory.Build.props.user.template @@ -0,0 +1,17 @@ + + + + + + YOUR_CONNECTION_STRING_HERE + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 30aa27a7..626c530b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,10 @@ - + + + + @@ -37,4 +40,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 9d181c99..6401d0bd 100644 --- a/README.md +++ b/README.md @@ -140,3 +140,4 @@ This means you get: ### SEO & Discovery `Excel Automation` • `Automate Excel with AI` • `MCP Server` • `Model Context Protocol` • `GitHub Copilot Excel` • `AI Excel Assistant` • `Power Query Automation` • `Power Query M Code` • `Power Pivot Automation` • `DAX Measures` • `DAX Automation` • `Data Model Automation` • `PivotTable Automation` • `VBA Automation` • `Excel Tables Automation` • `Excel AI Integration` • `COM Interop` • `Windows Excel Automation` • `Excel Development Tools` • `Excel Productivity` • `Excel Scripting` • `Conversational Excel` • `Natural Language Excel` +# test diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8caa098a..c61fa241 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -373,13 +373,23 @@ dotnet build -c Release ## 📊 **Application Insights / Telemetry Setup** -ExcelMcp uses Azure Application Insights for anonymous usage telemetry and crash reporting. Telemetry is **opt-out** (enabled by default in release builds). +ExcelMcp uses Azure Application Insights (Classic SDK with WorkerService integration) for anonymous usage telemetry and crash reporting. Telemetry is **opt-out** (enabled by default in release builds). + +### **How It Works** + +The Application Insights connection string is **embedded at build time** via MSBuild - there is no runtime environment variable lookup. + +**Build-time flow:** +1. MSBuild reads `AppInsightsConnectionString` property (from `Directory.Build.props.user` or env var) +2. Generates `TelemetryConfig.g.cs` with the connection string as a `const string` +3. Compiled assembly contains the embedded connection string ### **What is Tracked** - **Tool invocations**: Tool name, action, duration (ms), success/failure - **Unhandled exceptions**: Exception type and redacted stack trace -- **Session ID**: Random GUID per process (no user identification) +- **User ID**: SHA256 hash of machine identity (anonymous, 16 chars) +- **Session ID**: Random GUID per process (8 chars) ### **What is NOT Tracked** @@ -396,6 +406,30 @@ All telemetry passes through `SensitiveDataRedactingProcessor` which removes: - Connection string secrets (`Password=...` → `[REDACTED_CREDENTIAL]`) - Email addresses → `[REDACTED_EMAIL]` +### **Local Development with Telemetry** + +To enable telemetry in local builds: + +```powershell +# 1. Copy the template file +Copy-Item "Directory.Build.props.user.template" "Directory.Build.props.user" + +# 2. Edit Directory.Build.props.user and add your connection string +# InstrumentationKey=xxx;IngestionEndpoint=... + +# 3. Build - connection string is embedded at compile time +dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj + +# 4. Run - telemetry is automatically sent to Azure +dotnet run --project src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj +``` + +**Note:** `Directory.Build.props.user` is gitignored - your connection string won't be committed. + +### **Local Development without Telemetry** + +If you don't create `Directory.Build.props.user`, builds will have an empty connection string and telemetry will be disabled. This is the default for local development. + ### **Azure Resources Setup (Maintainers Only)** To deploy the Application Insights infrastructure: @@ -419,106 +453,37 @@ After deploying Azure resources: 2. Add new secret: `APPINSIGHTS_CONNECTION_STRING` 3. Paste the connection string from deployment output -The release workflow automatically injects this at build time. - -### **Local Development** - -During local development, telemetry is **disabled by default** because the placeholder connection string is not replaced. This is intentional - no telemetry data is sent from dev builds. - -#### **Debug Mode: Console Output** - -To test telemetry locally without Azure, enable debug mode which logs to stderr: - -```powershell -# Enable debug telemetry (logs to console instead of Azure) -$env:EXCELMCP_DEBUG_TELEMETRY = "true" - -# Build and run the MCP server -dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -dotnet run --project src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj - -# You'll see telemetry output like: -# [Telemetry] Debug mode enabled - logging to stderr -# Activity.TraceId: abc123... -# Activity.DisplayName: ToolInvocation -# Activity.Tags: -# tool.name: excel_file -# tool.action: list -# tool.duration_ms: 42 -# tool.success: true -``` - -#### **Testing with Real Azure Resources** - -To test with actual Application Insights: - -```powershell -# 1. Deploy Azure resources -.\infrastructure\azure\deploy-appinsights.ps1 -SubscriptionId "" - -# 2. Temporarily inject connection string (DON'T COMMIT!) -$connStr = "InstrumentationKey=xxx;IngestionEndpoint=https://..." -(Get-Content "src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs") -replace ` - '__APPINSIGHTS_CONNECTION_STRING__', $connStr | ` - Set-Content "src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs" - -# 3. Build and run -dotnet build src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj -dotnet run --project src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj - -# 4. Check Azure Portal → Application Insights → Transaction search - -# 5. IMPORTANT: Revert the file (don't commit connection string!) -git checkout src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs -``` - -To verify telemetry state: -```csharp -// ExcelMcpTelemetry.IsEnabled returns false when: -// - Connection string is placeholder "__APPINSIGHTS_CONNECTION_STRING__" -// - User has opted out via EXCELMCP_TELEMETRY_OPTOUT=true - -// ExcelMcpTelemetry.IsEnabled returns true when: -// - EXCELMCP_DEBUG_TELEMETRY=true (console output mode) -// - Connection string is real (injected at build time) -``` - -### **User Opt-Out** - -Users can disable telemetry by setting an environment variable: - -```powershell -# Windows -$env:EXCELMCP_TELEMETRY_OPTOUT = "true" - -# Or permanently via System Properties → Environment Variables -``` +The release workflow sets this as an environment variable, and MSBuild embeds it at build time. ### **Telemetry Architecture** -``` -MCP Tool Invocation - │ - ▼ -ExcelToolsBase.ExecuteToolAction() - │ (tracks: tool, action, duration, success) - ▼ -ExcelMcpTelemetry.TrackToolInvocation() - │ - ▼ -SensitiveDataRedactingProcessor - │ (removes: paths, credentials, emails) - ▼ -Azure Monitor Exporter → Application Insights +```text +Build Time: + MSBuild → reads AppInsightsConnectionString → generates TelemetryConfig.g.cs + +Runtime: + MCP Tool Invocation + │ + ▼ + ExcelMcpTelemetry.TrackToolInvocation() + │ (tracks: tool, action, duration, success) + ▼ + SensitiveDataRedactingProcessor + │ (removes: paths, credentials, emails) + ▼ + TelemetryClient → Application Insights ``` ### **Files Overview** | File | Purpose | |------|---------| -| `Telemetry/ExcelMcpTelemetry.cs` | Static helper for tracking | +| `Telemetry/ExcelMcpTelemetry.cs` | Static helper for tracking events | +| `Telemetry/ExcelMcpTelemetryInitializer.cs` | Sets User.Id and Session.Id on telemetry | | `Telemetry/SensitiveDataRedactingProcessor.cs` | Redacts PII before transmission | -| `Program.cs` | OpenTelemetry configuration | +| `Program.cs` | Application Insights WorkerService configuration | +| `ExcelMcp.McpServer.csproj` | MSBuild target that generates TelemetryConfig.g.cs | +| `Directory.Build.props.user.template` | Template for local dev connection string | | `infrastructure/azure/appinsights.bicep` | Azure resource definitions | | `infrastructure/azure/deploy-appinsights.ps1` | Deployment script | diff --git a/docs/EXCEPTION-PATTERN-MIGRATION.md b/docs/EXCEPTION-PATTERN-MIGRATION.md deleted file mode 100644 index ce9205f9..00000000 --- a/docs/EXCEPTION-PATTERN-MIGRATION.md +++ /dev/null @@ -1,175 +0,0 @@ -# Exception Pattern Migration - Test Fixes Required - -> **Status**: 82 test compilation errors (fixed 2 of 84, 97.6% remaining) -> **Root Cause**: Commands converted to throw exceptions (void methods) but tests still use `var result = ` pattern -> **Solution**: Systematically remove variable assignments for void method calls - -## Progress Summary - -**Completed**: -- ✅ void Execute infrastructure added to IExcelBatch and ExcelBatch (non-breaking) -- ✅ Documented all 84 errors with clear categorization -- ✅ Fixed 2/84 errors (PowerQueryCommandsTests.cs: 4 fixes → 2 remaining) -- ✅ Fixed helper fixtures (PowerQueryTestsFixture, TableTestsFixture, DataModelTestsFixture, PivotTableRealisticFixture) -- ✅ Fixed FileCommandsTests.CreateEmptyThenList -- ✅ Fixed SheetCommandsTests.Lifecycle (all 4 errors) -- ✅ Fixed TableCommandsTests.cs (14 fixes - all void Create/Delete/Rename/etc patterns) - -**Remaining**: 82 errors across: -- SheetCommandsTests.Move.cs (10 errors - Move, CopyToWorkbook, MoveToWorkbook calls) -- SheetCommandsTests.TabColor.cs (2 errors) -- SheetCommandsTests.Visibility.cs (6 errors) -- PowerQueryCommandsTests.cs (16 remaining errors) -- DataModelCommandsTests.cs (8 remaining errors - await void patterns) -- PivotTableCommandsTests.Creation.cs (1 error) -- PivotTableCommandsTests.OlapFields.cs (2 errors) - -## Error Summary - -Total errors: **82** (down from 84) - -### Error Types - -| Error Type | Count | Pattern | Fix | -|-----------|-------|---------|-----| -| CS0815 | 75 | `var result = _command.VoidMethod()` | Remove `var result = ` | -| CS4008 | 9 | `await _command.VoidMethod()` | Remove `await` | - -### Affected Test Files - -| File | Line Count | Errors | Status | -|------|-----------|--------|--------| -| TableCommandsTests.cs | 500+ | 16 | 🔴 Pending | -| SheetCommandsTests.Lifecycle.cs | 150+ | 4 | 🔴 Pending | -| SheetCommandsTests.Move.cs | 370+ | 10 | 🔴 Pending | -| SheetCommandsTests.TabColor.cs | 140+ | 2 | 🔴 Pending | -| SheetCommandsTests.Visibility.cs | 180+ | 6 | 🔴 Pending | -| PowerQueryCommandsTests.cs | 850+ | 20 | 🔴 Pending | -| DataModelCommandsTests.cs | 320+ | 8 | 🔴 Pending | -| PivotTableCommandsTests.Creation.cs | 80+ | 1 | 🔴 Pending | -| PivotTableCommandsTests.OlapFields.cs | 250+ | 2 | 🔴 Pending | -| PowerQueryTestsFixture.cs (helper) | 100+ | 3 | 🔴 Pending | -| DataModelTestsFixture.cs (helper) | 150+ | 8 | 🔴 Pending | -| PivotTableRealisticFixture.cs (helper) | 150+ | 2 | 🔴 Pending | -| TableTestsFixture.cs (helper) | 120+ | 1 | 🔴 Pending | -| FileCommandsTests.CreateEmptyThenList.cs | 100+ | 1 | 🔴 Pending | - -## Fix Pattern - -### Pattern 1: Simple Void Call (CS0815) - -**Before:** -```csharp -var result = await _commands.CreateAsync(batch, "Name"); -Assert.NotNull(result); // ❌ result is void -``` - -**After:** -```csharp -await _commands.CreateAsync(batch, "Name"); -// Exceptions throw if operation fails -``` - -### Pattern 2: Void Call Without Await (CS0815) - -**Before:** -```csharp -var result = _testHelper.SetupTable(batch, data); -Assert.NotNull(result); -``` - -**After:** -```csharp -_testHelper.SetupTable(batch, data); -// Exceptions throw if operation fails -``` - -### Pattern 3: Void with Await (CS4008) - -**Before:** -```csharp -var result = await _commands.DeleteAsync(batch, name); -Assert.True(result.Success); // ❌ Can't await void -``` - -**After:** -```csharp -await _commands.DeleteAsync(batch, name); -// Exceptions throw if operation fails -``` - -## Verification Strategy - -**Per-file approach:** -1. Fix `var result = ` assignments to void methods -2. Remove `Assert.NotNull(result)`, `Assert.True(result.Success)` checks (void methods throw on error) -3. Keep assertions that verify actual Excel state (round-trip validation) -4. Run `dotnet build` to confirm file's errors resolved -5. Move to next file - -**Build validation:** -```bash -dotnet build # Must complete with 0 errors -``` - -## Commands for Bulk Search - -Find all `var result = ` assignments: -```bash -git grep -n "var result = " tests/ExcelMcp.Core.Tests/ -``` - -Find all void method calls with assignment: -```bash -git grep -n "var .* = .*\..*Async(batch" -``` - -## Implementation Priority - -**Phase 1 (High Error Count):** -1. PowerQueryCommandsTests.cs - 20 errors -2. TableCommandsTests.cs - 16 errors -3. SheetCommandsTests.Move.cs - 10 errors -4. DataModelCommandsTests.cs - 8 errors + DataModelTestsFixture (8 errors) - -**Phase 2 (Helpers):** -5. PowerQueryTestsFixture.cs - 3 errors -6. PivotTableRealisticFixture.cs - 2 errors -7. TableTestsFixture.cs - 1 error - -**Phase 3 (Remaining):** -8. SheetCommandsTests.Lifecycle/TabColor/Visibility - 12 errors combined -9. PivotTableCommandsTests - 3 errors -10. FileCommandsTests.CreateEmptyThenList.cs - 1 error - -## Testing After Fixes - -Once all 84 errors are fixed: - -```bash -# Build to verify compilation -dotnet build - -# Run smoke test -dotnet test --filter "FullyQualifiedName~McpServerSmokeTests.SmokeTest_AllTools_LlmWorkflow" --verbosity quiet - -# Run feature-specific tests -dotnet test --filter "Feature=PowerQuery&RunType!=OnDemand" -dotnet test --filter "Feature=Tables&RunType!=OnDemand" -``` - -## Notes - -- **No API changes needed** - void Execute infrastructure is complete -- **Just test updates** - All test files need removal of `var result = ` patterns -- **Error-first semantics** - Commands throw on error, tests no longer check `Success` flag -- **Exception handling** - Try-catch in tests if specific error handling is needed (rare) - -## Rollback Procedure - -If needed: -```bash -git reset --hard HEAD~1 # Undo void Execute commit -git checkout HEAD -- src/ExcelMcp.ComInterop/Session/IExcelBatch.cs -git checkout HEAD -- src/ExcelMcp.ComInterop/Session/ExcelBatch.cs -``` diff --git a/infrastructure/azure/appinsights-test.bicep b/infrastructure/azure/appinsights-test.bicep new file mode 100644 index 00000000..52b7566f --- /dev/null +++ b/infrastructure/azure/appinsights-test.bicep @@ -0,0 +1,58 @@ +// Application Insights infrastructure for ExcelMcp INTEGRATION TESTS +// This is a separate instance from production for testing telemetry functionality +// +// Deploy with: az deployment sub create --location --template-file appinsights-test.bicep --parameters appinsights-test.parameters.json + +targetScope = 'subscription' + +@description('Name of the resource group to create') +param resourceGroupName string = 'excelmcp-test-observability' + +@description('Azure region for all resources') +param location string = 'westeurope' + +@description('Name of the Log Analytics workspace') +param logAnalyticsName string = 'excelmcp-test-logs' + +@description('Name of the Application Insights resource') +param appInsightsName string = 'excelmcp-test-appinsights' + +@description('Data retention in days - shorter for test instance to reduce costs') +@minValue(30) +@maxValue(730) +param retentionInDays int = 30 + +@description('Tags to apply to all resources') +param tags object = { + project: 'ExcelMcp' + purpose: 'TelemetryTesting' + environment: 'test' + managedBy: 'Bicep' +} + +// Resource Group +resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceGroupName + location: location + tags: tags +} + +// Deploy resources into the resource group +module observability 'appinsights-resources.bicep' = { + name: 'test-observability-deployment' + scope: rg + params: { + location: location + logAnalyticsName: logAnalyticsName + appInsightsName: appInsightsName + retentionInDays: retentionInDays + tags: tags + } +} + +// Outputs +output resourceGroupName string = rg.name +output logAnalyticsWorkspaceId string = observability.outputs.logAnalyticsWorkspaceId +output appInsightsName string = observability.outputs.appInsightsName +output appInsightsConnectionString string = observability.outputs.appInsightsConnectionString +output appInsightsInstrumentationKey string = observability.outputs.appInsightsInstrumentationKey diff --git a/infrastructure/azure/appinsights-test.parameters.json b/infrastructure/azure/appinsights-test.parameters.json new file mode 100644 index 00000000..1b260f28 --- /dev/null +++ b/infrastructure/azure/appinsights-test.parameters.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "value": "excelmcp-test-observability" + }, + "location": { + "value": "swedencentral" + }, + "logAnalyticsName": { + "value": "excelmcp-test-logs" + }, + "appInsightsName": { + "value": "excelmcp-test-appinsights" + }, + "retentionInDays": { + "value": 30 + }, + "tags": { + "value": { + "project": "ExcelMcp", + "purpose": "TelemetryTesting", + "environment": "test", + "managedBy": "Bicep" + } + } + } +} diff --git a/src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj b/src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj index cc4c383d..6e9d1253 100644 --- a/src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj +++ b/src/ExcelMcp.McpServer/ExcelMcp.McpServer.csproj @@ -14,6 +14,9 @@ $(NoWarn);CS1591 + + Sbroenne.ExcelMcp.McpServer.Tests + 1.0.0 1.0.0.0 @@ -40,8 +43,44 @@ true + + + + $(APPINSIGHTS_CONNECTION_STRING) + + + + + + + + + + System.IO.File.WriteAllText(FilePath, Content); + + + + + + + $(IntermediateOutputPath)TelemetryConfig.g.cs + // Auto-generated at build time - do not edit +namespace Sbroenne.ExcelMcp.McpServer.Telemetry%3B + +internal static class TelemetryConfig +{ + public const string ConnectionString = "$(AppInsightsConnectionString)"%3B +} + + + + + + + + @@ -79,8 +118,9 @@ - - + + + all runtime; build; native; contentfiles; analyzers diff --git a/src/ExcelMcp.McpServer/Program.cs b/src/ExcelMcp.McpServer/Program.cs index 11306b88..bcb5b324 100644 --- a/src/ExcelMcp.McpServer/Program.cs +++ b/src/ExcelMcp.McpServer/Program.cs @@ -1,5 +1,6 @@ +using System.IO.Pipelines; using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.ApplicationInsights.WorkerService; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -8,13 +9,39 @@ namespace Sbroenne.ExcelMcp.McpServer; /// -/// ExcelCLI Model Context Protocol (MCP) Server -/// Provides resource-based tools for AI assistants to automate Excel operations: -/// +/// ExcelMCP Model Context Protocol (MCP) Server. +/// Provides resource-based tools for AI assistants to automate Excel operations. /// public class Program { - public static async Task Main(string[] args) + // Test transport configuration - set by tests before calling Main() + // These are intentionally static for test injection. Thread-safety is not required + // because tests run sequentially and call ResetTestTransport() after each test. + private static Pipe? _testInputPipe; + private static Pipe? _testOutputPipe; + + /// + /// Configures the server to use in-memory pipe transport for testing. + /// Call this before RunAsync() to enable test mode. + /// + /// Pipe for reading client requests (client writes, server reads) + /// Pipe for writing server responses (server writes, client reads) + public static void ConfigureTestTransport(Pipe inputPipe, Pipe outputPipe) + { + _testInputPipe = inputPipe; + _testOutputPipe = outputPipe; + } + + /// + /// Resets test transport configuration (call after test completes). + /// + public static void ResetTestTransport() + { + _testInputPipe = null; + _testOutputPipe = null; + } + + public static async Task Main(string[] args) { // Register global exception handlers for unhandled exceptions (telemetry) RegisterGlobalExceptionHandlers(); @@ -27,77 +54,113 @@ public static async Task Main(string[] args) consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; }); - // Configure OpenTelemetry for Application Insights (if not opted out) + // Configure Application Insights ConfigureTelemetry(builder); - // MCP Server architecture: - // - Batch session management: LLM controls workbook lifecycle via begin/commit tools - - // Add MCP server with Excel tools (auto-discovers tools and prompts via attributes) - builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(); - - // Note: Completion support requires manual JSON-RPC method handling - // See ExcelCompletionHandler for completion logic implementation - // To enable: handle "completion/complete" method in custom transport layer + // Configure MCP Server - use test transport if configured, otherwise stdio + var mcpBuilder = builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new() + { + Name = "excel-mcp", + Version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0" + }; + + // Server-wide instructions for LLMs - helps with tool selection and workflow understanding + options.ServerInstructions = """ + ExcelMCP automates Microsoft Excel via COM interop. + + CRITICAL: File must be CLOSED in Excel desktop app (COM requires exclusive access). + + SESSION LIFECYCLE: + 1. excel_file(action:'open') → returns sessionId + 2. Use sessionId with ALL subsequent tools + 3. excel_file(action:'close', save:true/false) → ONLY when completely done + Keep session open across multiple operations - don't close prematurely! + """; + }) + .WithToolsFromAssembly() + .WithPromptsFromAssembly(); // Auto-discover prompts marked with [McpServerPromptType] + + if (_testInputPipe != null && _testOutputPipe != null) + { + // Test mode: use in-memory pipe transport + mcpBuilder.WithStreamServerTransport( + _testInputPipe.Reader.AsStream(), + _testOutputPipe.Writer.AsStream()); + } + else + { + // Production mode: use stdio transport + mcpBuilder.WithStdioServerTransport(); + } var host = builder.Build(); + // Initialize telemetry client for static access + InitializeTelemetryClient(host.Services); + await host.RunAsync(); + + return 0; } /// - /// Configures Application Insights SDK for telemetry. - /// Enables Users/Sessions/Funnels/User Flows analytics in Azure Portal. - /// Respects opt-out via EXCELMCP_TELEMETRY_OPTOUT environment variable. + /// Initializes the static TelemetryClient from DI container. /// - private static void ConfigureTelemetry(HostApplicationBuilder builder) + private static void InitializeTelemetryClient(IServiceProvider services) { - // Check if telemetry is enabled - if (ExcelMcpTelemetry.IsOptedOut()) + // Resolve TelemetryClient from DI and store for static access + // Worker Service SDK manages the TelemetryClient lifecycle including flush on shutdown + var telemetryClient = services.GetService(); + if (telemetryClient != null) { - return; // User opted out + ExcelMcpTelemetry.SetTelemetryClient(telemetryClient); } + } - // Debug mode: log telemetry to stderr instead of Azure (for local testing) - var isDebugMode = ExcelMcpTelemetry.IsDebugMode(); - + /// + /// Configures Application Insights Worker Service SDK for telemetry. + /// Uses AddApplicationInsightsTelemetryWorkerService() for proper host integration. + /// Enables Users/Sessions/Funnels/User Flows analytics in Azure Portal. + /// + private static void ConfigureTelemetry(HostApplicationBuilder builder) + { var connectionString = ExcelMcpTelemetry.GetConnectionString(); - if (string.IsNullOrEmpty(connectionString) && !isDebugMode) + if (string.IsNullOrEmpty(connectionString)) { - return; // No connection string available and not in debug mode + return; // No connection string available (local dev build) } - // Configure Application Insights SDK - var aiConfig = TelemetryConfiguration.CreateDefault(); - if (!string.IsNullOrEmpty(connectionString)) - { - aiConfig.ConnectionString = connectionString; - } - else if (isDebugMode) + // Configure Application Insights Worker Service SDK + // This provides: + // - Proper DI integration with IHostApplicationLifetime + // - Automatic dependency tracking + // - Automatic performance counter collection (where available) + // - Proper telemetry channel with ServerTelemetryChannel (retries, local storage) + // - Automatic flush on host shutdown + var aiOptions = new ApplicationInsightsServiceOptions { - // Debug mode without connection string - telemetry will be tracked but not sent - // This allows testing the tracking code without Azure resources - Console.Error.WriteLine("[Telemetry] Debug mode enabled - telemetry tracked locally (no Azure connection)"); - } - - // Add initializer to set User.Id and Session.Id on all telemetry - aiConfig.TelemetryInitializers.Add(new ExcelMcpTelemetryInitializer()); - - // Register TelemetryClient as singleton for dependency injection - var telemetryClient = new TelemetryClient(aiConfig); - builder.Services.AddSingleton(telemetryClient); - builder.Services.AddSingleton(aiConfig); + // Set connection string if available + ConnectionString = connectionString, + + // Disable features not needed for MCP server (reduces overhead) + EnableHeartbeat = true, // Useful for monitoring server health + EnableAdaptiveSampling = true, // Helps manage telemetry volume + EnableQuickPulseMetricStream = false, // Live Metrics not needed for CLI tool + EnablePerformanceCounterCollectionModule = false, // Perf counters not useful for short-lived CLI + EnableEventCounterCollectionModule = false, // Event counters not needed + + // Disable dependency tracking for HTTP calls + EnableDependencyTrackingTelemetryModule = false, + }; - // Store reference for static access in ExcelMcpTelemetry - ExcelMcpTelemetry.SetTelemetryClient(telemetryClient); + builder.Services.AddApplicationInsightsTelemetryWorkerService(aiOptions); - if (isDebugMode) - { - Console.Error.WriteLine($"[Telemetry] Application Insights configured - User.Id={ExcelMcpTelemetry.UserId}, Session.Id={ExcelMcpTelemetry.SessionId}"); - } + // Add custom telemetry initializer for User.Id and Session.Id + // This enables the Users and Sessions blades in Azure Portal + builder.Services.AddSingleton(); } /// diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_connection.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_connection.md deleted file mode 100644 index f72b59d1..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_connection.md +++ /dev/null @@ -1,46 +0,0 @@ -# excel_connection Tool - -**Related tools**: -- excel_powerquery – For Power Query M sources, CSV/TEXT/WEB imports, or when an OLE DB provider is unavailable - -**Actions**: list, view, create, test, refresh, delete, load-to, get-properties, set-properties - -**When to use excel_connection**: -- Create and manage Excel connections (OLEDB, ODBC) -- Refresh data from connection sources -- Delete connections you no longer need -- Load connection data to worksheets -- Configure connection properties (background refresh, auto-refresh, etc.) -- Use excel_powerquery for Power Query/M-driven sources, CSV/TEXT/WEB imports, or when the target provider is missing - -**Server-specific behavior**: -- OLEDB connections: Fully supported when the provider is installed (e.g., Microsoft.ACE.OLEDB.16.0, SQLOLEDB). Excel throws "Value does not fall within the expected range" if the provider is missing. -- ODBC connections: Supported; DSN or DSN-less connection strings work. -- TEXT/WEB connections: Creation routed to Power Query (use excel_powerquery create) -- DataFeed / Model: These types show up when workbook already has Power Query or Power Pivot connections. Manage/refresh them here; creation happens via excel_powerquery / Power Pivot UI. -- Connection types 3 and 4 (TEXT/WEB) may report inconsistently -- Delete removes connection and associated QueryTables -- Power Query connections automatically redirect to excel_powerquery tool -- Refresh/load-to actions time out after 5 minutes; if Excel is blocked (credentials, privacy dialog), you'll receive `SuggestedNextActions` instead of a hung session. - -**Action disambiguation**: -- list: Show all connections in workbook -- view: Display connection details and properties -- create: Create OLEDB/ODBC connection (provider must exist). Use excel_powerquery for TEXT/WEB/Power Query scenarios. -- test: Verify connection without refreshing data -- refresh: Update data from connection source -- delete: Remove connection and its QueryTables (use excel_powerquery delete for Power Query connections) -- load-to: Load connection data to specified worksheet -- get-properties: Retrieve connection properties (background query, refresh settings, etc.) -- set-properties: Update connection properties (background query, refresh-on-open, save password, refresh period) - -**Common mistakes**: -- Missing provider for OLEDB connection string → Install provider (e.g., ACE) or switch to ODBC/Power Query. -- Trying to create Power Query/TEXT/WEB connections → Use excel_powerquery instead. -- Not testing connection before refresh → Use test first to verify connectivity - -**Workflow optimization**: -- Provide concrete provider connection strings (e.g., `Provider=Microsoft.ACE.OLEDB.16.0;...`). -- Use test before refresh to surface provider/network errors earlier. -- Route CSV/TEXT/WEB scenarios to excel_powerquery (faster automation, no provider requirement). -- Check properties with get-properties before modifying via set-properties. diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_datamodel.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_datamodel.md deleted file mode 100644 index 2740b3b2..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_datamodel.md +++ /dev/null @@ -1,14 +0,0 @@ -# excel_datamodel - Server Quirks - -**Action disambiguation**: -- list-tables: Data model tables only (NOT worksheet tables) -- read-table: Detailed info (columns, measures, row count, refresh date) -- list-columns: Columns in specific data model table -- create-measure vs DAX formulas: Measures are data model calculations, not worksheet formulas - -**Server-specific quirks**: -- Requires data loaded with loadDestination='data-model' or 'both' first -- DAX syntax (Power Pivot) not Excel worksheet syntax -- Measures created in data model, NOT visible in worksheets until used in PivotTable -- formatString: Must set explicitly or numbers display as general format -- list-* actions have a 5 minute guard. If Power Pivot is waiting on a dialog, you'll get timeout guidance (SuggestedNextActions) instead of a silent hang—surface it before retrying. diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_file.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_file.md deleted file mode 100644 index 4c9a83e8..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_file.md +++ /dev/null @@ -1,57 +0,0 @@ -# excel_file Tool - -**Actions**: open, close, create-empty, close-workbook, test - -**IMPORTANT: NO 'save' ACTION** -- To persist changes, use: `action='close'` with `save=true` parameter -- Common mistake: `action='save'` (WRONG) → use `action='close', save=true` (CORRECT) - -**CRITICAL: DO NOT CLOSE SESSION PREMATURELY** -- **ONLY close when ALL operations are complete** - closing mid-workflow loses the session -- If user requests multiple operations, keep session open until explicitly told to close -- Ask user "Should I close the session now?" if workflow completion is unclear -- Premature close causes: lost session, file locks, failed subsequent operations - -**Session lifecycle** (REQUIRED for all operations): -1. `action='open'` → returns sessionId -2. Use sessionId with other excel_* tools -3. `action='close', save=true` → persists changes and ends session -4. `action='close', save=false` → discards changes and ends session - -**Related tools**: -- excel_worksheet - Add sheets after creating workbook -- excel_powerquery - Load data into workbook -- excel_range - Write data to workbook -- excel_vba - Use .xlsm extension for macro-enabled workbooks - -**When to use excel_file**: -- Start/end sessions for Excel operations -- Create new blank Excel workbooks -- Validate file exists and is accessible - -**Server-specific behavior**: -- open: Creates session, returns sessionId (required for all operations) -- close: Ends session, optional save parameter (default: false) -- create-empty: Creates .xlsx or .xlsm (specify extension for macro support) -- close-workbook: No-op (deprecated - automatic with single-instance architecture) -- test: Validates file without opening with Excel COM -- **File locking**: All Excel operations automatically check if file is locked and return clear error if open - -**Action disambiguation**: -- open: Start session for operations -- close (save=true): Persist changes and end session -- close (save=false): Discard changes and end session -- create-empty: New blank workbook -- test: Check if file exists and has valid extension -- close-workbook: Deprecated (automatic cleanup) - -**Common mistakes**: -- Using `action='save'` → WRONG! Use `action='close', save=true` -- Forgetting .xlsm for VBA → Specify extension in path -- Not starting session → All operations require sessionId from 'open' action - -**Workflow optimization**: -- After create-empty: Use 'open' to start session -- After operations: Use 'close' with save=true to persist - - diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_namedrange.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_namedrange.md deleted file mode 100644 index aff85cf3..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_namedrange.md +++ /dev/null @@ -1,35 +0,0 @@ -# excel_namedrange Tool - -**Related tools**: -- excel_range - For reading/writing data in named ranges (use sheetName="") -- excel_powerquery - Named ranges can be used as Power Query parameters - -**Actions**: list, get, set, create, update, delete, create-bulk - -**When to use excel_namedrange**: -- Named ranges as configuration parameters -- Reusable cell references -- Settings, thresholds, dates -- Use excel_range for data operations -- Use excel_namedrange interchangeably (same tool) - -**Server-specific behavior**: -- Parameters are named ranges pointing to single cells -- create-bulk: Efficient multi-parameter creation (one call vs many) -- Absolute references recommended: =Sheet1!$A$1 -- Parameters accessible across entire workbook - -**Action disambiguation**: -- create: Add single named range parameter -- create-bulk: Add multiple parameters in one call (90% faster) -- get: Retrieve parameter value -- set: Update parameter value -- update: Change parameter cell reference - -**Common mistakes**: -- Creating parameters one-by-one → Use create-bulk for 2+ parameters -- Missing = prefix in references → Must be =Sheet1!$A$1 not Sheet1!$A$1 -- Relative references → Use absolute ($A$1) for parameters - -**Workflow optimization**: -- Multiple parameters? Use create-bulk action diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_pivottable.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_pivottable.md deleted file mode 100644 index 0b4d31f2..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_pivottable.md +++ /dev/null @@ -1,62 +0,0 @@ -# excel_pivottable Tool - -**Related tools**: -- excel_range - For source data ranges (use create-from-range) -- excel_table - For structured source tables (use create-from-table) -- excel_datamodel - For Data Model tables with DAX (use create-from-datamodel) - -**Actions**: list, get-info, create-from-range, create-from-table, create-from-datamodel, delete, refresh, list-fields, add-row-field, add-column-field, add-value-field, add-filter-field, remove-field, set-field-function, set-field-name, set-field-format, set-field-filter, sort-field, get-data - -**When to use excel_pivottable**: -- Create PivotTables from ranges or tables -- Configure PivotTable fields and calculations -- Use excel_table for source data tables -- Use excel_datamodel for DAX-based analytics - -**Server-specific behavior**: -- Requires source data in range or table -- PivotTables auto-refresh when source data changes -- Fields can be rows, columns, values, or filters -- Value field functions: Sum, Count, Average, Max, Min, etc. -- create-from-datamodel enforces a 5 minute timeout; if the Data Model is stuck (privacy dialogs, refresh), you'll get `SuggestedNextActions` to resolve it instead of a hung session. - -**Action disambiguation**: -- create-from-range: Create PivotTable from range address (sheetName + range parameters) -- create-from-table: Create PivotTable from Excel Table/ListObject (excelTableName = worksheet table name) -- create-from-datamodel: Create PivotTable from Power Pivot Data Model table (dataModelTableName = Data Model table name) -- add-row-field: Field goes to row area -- add-column-field: Field goes to column area -- add-value-field: Field goes to values area (calculations) - - **OLAP Mode 1**: Add pre-existing DAX measure (fieldName = measure name or "[Measures].[Name]") - - **OLAP Mode 2**: Auto-create DAX measure from column (fieldName = column name, specify aggregation function) - - **Regular PivotTables**: Add column to values area with aggregation function -- add-filter-field: Field goes to filter area -- set-field-function: Change aggregation (Sum, Count, etc.) - -**add-value-field for OLAP PivotTables**: -- **Pre-existing measure**: Use fieldName = "Total Sales" or "[Measures].[Total Sales]" - - Adds existing measure without creating duplicate - - aggregationFunction parameter ignored (measure formula defines aggregation) - - Best for complex DAX measures with relationships, time intelligence, etc. -- **Auto-create from column**: Use fieldName = "Sales" (column name) - - Creates new DAX measure: SUM('Table'[Sales]), COUNT('Table'[Column]), etc. - - aggregationFunction parameter determines DAX function (Sum, Count, Average, etc.) - - customName parameter sets measure name - - Best for simple aggregations - -**Parameter guidance for create actions**: -- create-from-range: sheetName, range, destinationSheet, destinationCell, pivotTableName -- create-from-table: excelTableName (Excel Table name), destinationSheet, destinationCell, pivotTableName -- create-from-datamodel: dataModelTableName (Data Model table name), destinationSheet, destinationCell, pivotTableName - -**Common mistakes**: -- Creating PivotTable without source data → Prepare data first -- Not refreshing after source data changes → Use refresh action -- Wrong field type → Rows vs Columns vs Values have different purposes -- **OLAP**: Using column name when measure exists → Use measure name directly for existing measures -- **OLAP**: Creating duplicate measures → Check if measure exists first with excel_datamodel list-measures - -**Workflow optimization**: -- Pattern: Create → Add fields → Set functions → Format → Sort -- **OLAP with existing measures**: list-fields → add-value-field (use measure name) → format -- **OLAP auto-create**: add-value-field (use column name + aggregation) → creates measure automatically diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_powerquery.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_powerquery.md index 2cd9e6f6..4e142f48 100644 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_powerquery.md +++ b/src/ExcelMcp.McpServer/Prompts/Content/excel_powerquery.md @@ -1,21 +1,25 @@ # excel_powerquery - Server Quirks **Action disambiguation**: + - create: Import NEW query using inline `mCode` (FAILS if query already exists - use update instead) - update: Update EXISTING query M code + refresh data (use this if query exists) - load-to: Loads to worksheet or data model or both (not just config change) - CHECKS for sheet conflicts - unload: Removes data from worksheet but keeps query definition (inverse of load-to) **When to use create vs update**: + - Query doesn't exist? → Use create - Query already exists? → Use update (create will error "already exists") - Not sure? → Check with list action first, then use update if exists or create if new **Inline M code**: + - Provide raw M code directly via `mCode` - Keep `.pq` files only for GIT workflows **Create/LoadTo with existing sheets**: + - Use `targetCellAddress` to place the table on an existing worksheet without deleting other content - Applies to BOTH create and load-to - If the worksheet already has data and you omit `targetCellAddress`, the tool returns guidance telling you to provide one @@ -23,11 +27,13 @@ - Worksheets that exist but are empty behave like new sheets (default destination = A1) **Common mistakes**: + - Using create on existing query → ERROR "Query 'X' already exists" (should use update) - Using update on new query → ERROR "Query 'X' not found" (should use create) - Calling LoadTo without checking if sheet exists (will error if sheet exists) **Server-specific quirks**: + - Validation = execution: M code only validated when data loads/refreshes - connection-only queries: NOT validated until first execution - refresh with loadDestination: Applies load config + refreshes (2-in-1) diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_range.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_range.md deleted file mode 100644 index 83685b02..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_range.md +++ /dev/null @@ -1,47 +0,0 @@ -# excel_range Tool - -**Related tools**: -- excel_table - For structured tables with AutoFilter and structured references -- excel_namedrange - For defining reusable range names -- excel_worksheet - For sheet lifecycle (create, delete, rename) - -**Actions**: get-values, set-values, get-formulas, set-formulas, get-number-formats, set-number-format, set-number-formats, clear-all, clear-contents, clear-formats, copy, copy-values, copy-formulas, insert-cells, delete-cells, insert-rows, delete-rows, insert-columns, delete-columns, find, replace, sort, get-used-range, get-current-region, get-range-info, add-hyperlink, remove-hyperlink, list-hyperlinks, get-hyperlink, get-style, set-style, format-range, validate-range, get-validation, remove-validation, autofit-columns, autofit-rows, merge-cells, unmerge-cells, get-merge-info, add-conditional-formatting, clear-conditional-formatting, set-cell-lock, get-cell-lock - -**When to use excel_range**: -- Cell values, formulas, formatting in existing worksheets -- Use excel_table for structured tables (AutoFilter, structured refs) -- Use excel_powerquery for external data sources -- Use excel_worksheet for sheet lifecycle (create, delete, rename) - -**Server-specific behavior**: -- Single cell returns [[value]] as 2D array, not scalar -- Named ranges: use sheetName="" (empty string) -- For performance: Use set-number-formats (plural) to format multiple cells at once - -**Action disambiguation**: -- clear-all: Removes content + formatting -- clear-contents: Removes content only, preserves formatting -- clear-formats: Removes formatting only, preserves content -- copy: Copies everything (values + formulas + formatting) -- copy-values: Copies only values (no formulas, no formatting) -- copy-formulas: Copies only formulas (no values, no formatting) -- set-number-format: Apply one format code to entire range -- set-number-formats: Apply different format codes to each cell (2D array) -- get-style: Inspect built-in Excel style currently applied to a range (returns style name and type) -- set-style: Apply built-in Excel style (Heading 1, Total, Input, etc.) - RECOMMENDED for formatting -- format-range: Apply custom formatting (font, color, borders) - Use only when built-in styles don't fit -- get-used-range: Returns actual data bounds (ignores empty cells) -- get-current-region: Returns contiguous region around a cell - -**Common mistakes**: -- Expecting single cell to return scalar → Always returns 2D array [[value]] -- Using sheetName for named ranges → Use sheetName="" for named ranges -- Setting number format per cell → Use set-number-format for entire range instead -- **List validation with comma-separated values → Only range references create dropdowns! Write values to range first, then reference it.** - -**Workflow optimization**: -- Combine operations: set values + format + validate in one session -- Use get-used-range to discover data bounds before operations -- **Inspecting styles?** Use get-style to check current formatting before making changes -- **Formatting?** Try set-style with built-in styles first (faster, theme-aware, consistent) -- Only use format-range for brand-specific colors or one-off custom designs diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_table.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_table.md deleted file mode 100644 index b7e1692a..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_table.md +++ /dev/null @@ -1,32 +0,0 @@ -# excel_table - Server Quirks - -**Action disambiguation**: -- add-to-datamodel vs loadDestination: add-to-datamodel for existing Excel tables, loadDestination for Power Query imports -- resize: Changes table boundaries (not the same as append) -- get-structured-reference: Returns formula + range address for use with excel_range -- set-style: Apply built-in Excel table styles (60+ presets available) -- get-data: Returns row data (set `visibleOnly: true` to honor active filters) - -**Server-specific quirks**: -- Column names: Any string including purely numeric (e.g., "60") -- Table names: Must start with letter/underscore, alphanumeric only -- AutoFilter: Enabled by default on table creation -- Structured references: =Table1[@ColumnName] (auto-adjusts when table resizes) -- Table styles: Can be applied during create or changed later with set-style -- get-data with `visibleOnly=true` returns only rows still visible after filters (same order as Excel) - -**Table Style Categories** (60+ built-in styles): -- **Light** (TableStyleLight1-21): Subtle colors, minimal formatting -- **Medium** (TableStyleMedium1-28): Balanced colors, most popular -- **Dark** (TableStyleDark1-11): High contrast, bold colors - -**Popular table styles**: -- TableStyleMedium2 - orange accents (most common) -- TableStyleMedium9 - gray/blue professional -- TableStyleLight9 - blue banding (subtle) -- TableStyleDark1 - dark blue header (high contrast) - -**Style behavior**: -- Styles include header formatting, row banding, total row formatting -- Changing style preserves data and structure -- Empty string "" removes table styling diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_vba.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_vba.md deleted file mode 100644 index 25223edf..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_vba.md +++ /dev/null @@ -1,36 +0,0 @@ -# excel_vba Tool - -**Related tools**: -- excel_file - Create .xlsm files (macro-enabled workbooks) -- excel_range - For reading/writing data from VBA procedures - -**Actions**: list, view, import, export, update, delete, run - -**When to use excel_vba**: -- VBA macro management -- Import/export VBA modules for version control -- Run existing macros -- Use excel_range for data operations -- Requires .xlsm files (macro-enabled) - -**Server-specific behavior**: -- Requires macro-enabled workbook (.xlsm) -- VBA trust settings must allow programmatic access -- Modules stored as .bas or .cls files -- run action executes VBA Sub procedures - -**Action disambiguation**: -- list: Show all VBA modules in workbook -- view: Get VBA code for specific module -- import: Load VBA code from .bas/.cls file -- export: Save VBA module to file (version control) -- run: Execute VBA Sub procedure -- update: Replace module code - -**Common mistakes**: -- Using .xlsx instead of .xlsm → VBA requires macro-enabled files -- VBA trust not enabled → Check security settings -- Running Function instead of Sub → Use run for Sub only - -**Workflow optimization**: -- Version control: export → Git → import on other machine diff --git a/src/ExcelMcp.McpServer/Prompts/Content/excel_worksheet.md b/src/ExcelMcp.McpServer/Prompts/Content/excel_worksheet.md deleted file mode 100644 index 3769f86b..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/excel_worksheet.md +++ /dev/null @@ -1,72 +0,0 @@ -# excel_worksheet Tool - -**Related tools**: -- excel_range - For data operations on worksheet cells -- excel_table - For structured tables on worksheets -- excel_powerquery - For loading external data to worksheets - -**Actions**: list, create, rename, copy, delete, move, copy-to-workbook, move-to-workbook, set-tab-color, get-tab-color, clear-tab-color, hide, very-hide, show, get-visibility, set-visibility - -**When to use excel_worksheet**: -- Sheet lifecycle (create, delete, rename, copy, move) -- Copy/move sheets within same workbook -- Copy/move sheets between different workbooks -- Sheet visibility (hide, show) -- Sheet tab colors -- Use excel_range for data operations -- Use excel_powerquery for external data loading - -## Cross-Workbook Operations - -**When to use copy-to-workbook vs copy**: -- `copy`: Duplicate sheet WITHIN same workbook (single sessionId) -- `copy-to-workbook`: Copy sheet TO DIFFERENT workbook (requires TWO sessionIds) -- `move-to-workbook`: Move sheet TO DIFFERENT workbook (requires TWO sessionIds) - -**Cross-workbook workflow**: -1. Open source file: `excel_file(action: 'open', excelPath: 'source.xlsx')` → get sessionId1 -2. Open target file: `excel_file(action: 'open', excelPath: 'target.xlsx')` → get sessionId2 -3. Copy/move sheet: `excel_worksheet(action: 'copy-to-workbook', sessionId: sessionId1, targetSessionId: sessionId2, sheetName: 'Sales', targetName: 'Q1_Sales')` - -**Parameters for cross-workbook**: -- `sessionId`: Source workbook session -- `targetSessionId`: Target workbook session -- `sheetName`: Sheet to copy/move from source -- `targetName` (optional): Rename during copy/move -- `beforeSheet` OR `afterSheet` (optional): Position in target workbook - -**Example - Copy sheet to consolidation workbook**: -``` -User: "Copy the Sales sheet from Q1.xlsx to Annual_Report.xlsx" - -1. Open Q1.xlsx → sessionId: "abc123" -2. Open Annual_Report.xlsx → sessionId: "def456" -3. excel_worksheet( - action: 'copy-to-workbook', - sessionId: 'abc123', - targetSessionId: 'def456', - sheetName: 'Sales', - targetName: 'Q1_Sales' - ) -``` - -**Server-specific behavior**: -- Cannot delete last remaining worksheet (Excel limitation) -- Cannot delete active worksheet while viewing in Excel UI -- very-hide: Hidden from UI and VBA (stronger than hide) - -**Action disambiguation**: -- create: Add new blank worksheet -- copy: Duplicate existing worksheet WITHIN same workbook -- move: Reposition existing worksheet WITHIN same workbook -- copy-to-workbook: Copy sheet TO DIFFERENT workbook (requires targetSessionId) -- move-to-workbook: Move sheet TO DIFFERENT workbook (requires targetSessionId) -- hide: Hide from UI tabs (visible in VBA) -- very-hide: Hide from UI and VBA (stronger protection) -- show: Make visible in UI tabs - -**Common mistakes**: -- Trying to delete last worksheet → Excel requires at least one sheet -- Not checking if sheet exists before operations → Use list first -- Using copy when files are different → Use copy-to-workbook instead -- Forgetting to open both files before cross-workbook operation → Both need sessionIds diff --git a/src/ExcelMcp.McpServer/Prompts/Content/server_quirks.md b/src/ExcelMcp.McpServer/Prompts/Content/server_quirks.md deleted file mode 100644 index bc0dc51a..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/server_quirks.md +++ /dev/null @@ -1,83 +0,0 @@ -# Server Quirks & Gotchas - Things I Need to Remember - -## CRITICAL: File Access Requirements - -**NEVER work on files that are already open in Excel** -- ExcelMcp requires EXCLUSIVE access to workbooks -- If file is open in Excel UI or another process → operations WILL FAIL -- Error: "The file is already open in Excel or another process is using it" -- **USER ACTION REQUIRED**: Tell user to close the file first! -- This is NOT optional - Excel COM automation requires exclusive access - -**Why this matters:** -- Excel uses file locking to prevent corruption -- Multiple processes accessing same file = data loss risk -- Automation needs predictable state (no user edits during operation) - -**How to detect:** -- User says "the file is open" or "I have Excel running" -- Error message mentions "already open" or "locked by another process" -- ALWAYS tell user: "Please close the file in Excel before running automation" - -## Data Type Surprises - -**Single cells return 2D arrays, not scalars** -- excel_range(get-values, rangeAddress='A1') → [[42]] not 42 -- ALWAYS expect [[value]] even for single cell -- I must extract value[0][0] if I need the scalar - -**Named ranges use empty sheetName** -- excel_range(rangeAddress='SalesData', sheetName='') ← empty string! -- NOT sheetName='Sheet1' for named ranges - -## Excel COM Limitations - -**Cannot create OLEDB/ODBC connections programmatically** -- excel_connection can only MANAGE existing connections -- User must create OLEDB/ODBC in Excel UI first -- TEXT connections work fine for automation - -**Cannot delete last worksheet** -- Excel always requires at least one sheet -- excel_worksheet(delete) will fail if it's the last one - -**VBA requires .xlsm files** -- excel_vba won't work on .xlsx -- File must be macro-enabled - -## Load Destination Confusion - -**'worksheet' vs 'data-model' vs 'both'** -- worksheet: Users see data, NO DAX capability -- data-model: Ready for DAX, users DON'T see data -- both: Users see data AND DAX works -- DEFAULT is 'worksheet' if not specified - -**Cannot directly add worksheet query to Data Model** -- If loaded to worksheet only, can't use excel_table add-to-datamodel -- Must use excel_powerquery set-load-to-data-model to fix - -## Number Format Edge Cases - -**Format codes are strings, not patterns** -- '$#,##0.00' is exact string, not regex -- Common codes: see format_codes.md completion - -**set-number-format vs set-number-formats (plural)** -- set-number-format: ONE format for entire range -- set-number-formats: DIFFERENT format per cell (2D array) - -## Common Error Patterns - -**"Value does not fall within expected range"** -- Usually: Trying to create OLEDB connection (can't do it) -- Or: Invalid range address -- Or: Operation not supported by Excel COM - -**"Refresh failed or data not updated"** -- RefreshAll() is async and unreliable -- Solution: Use individual query/connection refresh (synchronous) - -**"Parameter name missing ="** -- Named ranges must be: =Sheet1!$A$1 -- NOT: Sheet1!$A$1 (missing = prefix) diff --git a/src/ExcelMcp.McpServer/Prompts/Content/tool_selection_guide.md b/src/ExcelMcp.McpServer/Prompts/Content/tool_selection_guide.md deleted file mode 100644 index bc260d9f..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/tool_selection_guide.md +++ /dev/null @@ -1,48 +0,0 @@ -# Tool Selection Guide - -## Pre-Requisite -- File must be CLOSED in Excel (exclusive access required by COM) -- Use `excel_file(open)` to start a session, `excel_file(close)` to end - -**CRITICAL - Session Lifetime Rules:** -- **KEEP session open** across multiple operations -- **ONLY close** when user explicitly requests it OR all operations are complete -- **ASK user** "Should I close the session now?" if unclear whether more operations are needed -- Closing mid-workflow = lost session, cannot resume - - -## Quick Reference - -| Need | Use | NOT | -|------|-----|-----| -| External data (databases, APIs, CSV) | `excel_powerquery` + `loadDestination` | `excel_table` (data already in Excel) | -| Connection management | `excel_connection` | - | -| DAX measures / Data Model | `excel_datamodel` | `excel_range` (worksheet formulas) | -| Data in worksheets (values, formulas, format) | `excel_range` | - | -| Convert range to table | `excel_table` | - | -| Sheet lifecycle (create, delete, hide, rename) | `excel_worksheet` | - | -| Named ranges (parameters) | `excel_namedrange` (use `create-bulk` for 2+) | - | -| VBA macros (.xlsm only) | `excel_vba` | - | -| PivotTables | `excel_pivottable` | - | - -## Common Mistakes - -**Don't use `excel_table` for external data** -- WRONG: `excel_table(create)` for CSV import -- CORRECT: `excel_powerquery(create, loadDestination='worksheet')` - -**loadDestination matters** -- WRONG: `excel_powerquery` without `loadDestination` for DAX -- CORRECT: `excel_powerquery(create, loadDestination='data-model')` - -**Use bulk operations for multiple items** -- WRONG: `excel_namedrange(create)` called 5 times -- CORRECT: `excel_namedrange(create-bulk)` with JSON array - -**DAX is not worksheet formulas** -- WRONG: Using `excel_range` for DAX syntax -- CORRECT: `excel_datamodel(create-measure)` with DAX - -**WorksheetAction vs DAX** -- Worksheet formulas: `excel_range` with `=SUM(A1:A10)` -- DAX measures: `excel_datamodel` with `SUM(Sales[Amount])` diff --git a/src/ExcelMcp.McpServer/Prompts/Content/user_request_patterns.md b/src/ExcelMcp.McpServer/Prompts/Content/user_request_patterns.md deleted file mode 100644 index 67441e0c..00000000 --- a/src/ExcelMcp.McpServer/Prompts/Content/user_request_patterns.md +++ /dev/null @@ -1,112 +0,0 @@ -# Common User Request Patterns - How to Interpret - -## PRE-FLIGHT: File Access Check - -**BEFORE processing ANY request, check if file is accessible:** - -**User signals file might be open:** -- "I have the file open" → STOP, tell them to close it -- "The file is currently open in Excel" → STOP -- "Can I run this while viewing the file?" → NO, tell them to close first -- "Error says file is locked" → File is open, tell them to close it - -**Always ask if unsure:** -- "Is the Excel file currently open?" -- "Please close the file before we proceed with automation" - -**This is mandatory - Excel COM automation requires exclusive file access!** - -## Data Import Requests - -**"Load this CSV file"** -→ excel_powerquery(create, sourcePath='file.csv', loadDestination='worksheet') -→ NOT excel_table (that's for existing data) - -**"Import data from SQL Server"** -→ excel_connection(create) with OLEDB connection string -→ Then excel_powerquery for transformations - -**"Load Power Query results to worksheet"** -→ excel_powerquery(create, loadDestination='worksheet') - -**"Put data in Data Model for DAX"** -→ excel_powerquery(create, loadDestination='data-model') -→ NOT 'worksheet' (that won't work for DAX) - -**"Refresh data from external source"** -→ excel_powerquery(refresh) - synchronous, guaranteed persistence -→ excel_connection(refresh) - for connection-based data - -## Formatting Requests - -**"Make headers bold with blue background"** -→ excel_range(format-range, bold=true, fillColor='#4472C4', fontColor='#FFFFFF') -→ Single call with multiple properties - -**"Format column D as currency"** -→ excel_range(set-number-format, rangeAddress='D:D', formatCode='$#,##0.00') - -**"Add dropdown for Status column"** -→ excel_range(validate-range, validationType='list', validationFormula1='Active,Inactive,Pending') - -## Analytics Requests - -**"Create Total Sales measure"** -→ First: Check data in Data Model with excel_datamodel(list-tables) -→ Then: excel_datamodel(create-measure, daxFormula='SUM(Sales[Amount])') - -**"Link Sales to Products table"** -→ excel_datamodel(create-relationship, fromTable='Sales', fromColumn='ProductID', toTable='Products', toColumn='ProductID') - -## Configuration Requests - -**"Set up parameters for date range"** -→ excel_namedrange(create-bulk) with StartDate and EndDate -→ NOT excel_range (parameters are named ranges) - -## Structure Requests - -**"Create new sheet called Reports"** -→ excel_worksheet(create, sheetName='Reports') - -**"Convert this data to a table"** -→ excel_table(create, sourceRange='A1:E100', tableName='SalesData') - -## VBA Requests - -**"Export VBA code for version control"** -→ excel_vba(export, moduleName='Module1', targetPath='Module1.bas') - -**"Import macro from file"** -→ excel_vba(import, sourcePath='Module1.bas') -→ File must be .xlsm - -## Discovery Requests - -**"What Power Queries are in this file?"** -→ excel_powerquery(list) - -**"Show me all DAX measures"** -→ excel_datamodel(list-measures) - -**"What sheets exist?"** -→ excel_worksheet(list) - -**"What connections are available?"** -→ excel_connection(list) - -**"Are there any active batch sessions?"** -→ list_excel_batches - -## Edge Case Interpretations - -**"Delete all data"** -→ excel_range(clear-contents) NOT clear-all (preserve formatting) - -**"Get data from A1"** -→ Remember: Returns [[value]] not value -→ Extract with result.values[0][0] if needed - -**"Hide this sheet from users"** -→ excel_worksheet(very-hide) for strong protection -→ excel_worksheet(hide) for normal hiding diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelConnectionPrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelConnectionPrompts.cs deleted file mode 100644 index 340bfaa2..00000000 --- a/src/ExcelMcp.McpServer/Prompts/ExcelConnectionPrompts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Prompts; - -/// -/// Quick reference prompt for Excel connection types and COM API limitations. -/// -[McpServerPromptType] -public static class ExcelConnectionPrompts -{ - /// - /// Quick reference for Excel connection types and critical COM API limitations. - /// - [McpServerPrompt(Name = "excel_connection_reference")] - [Description("Quick reference: Excel connection types, which ones work via COM API, and critical limitations")] - public static ChatMessage ConnectionReference() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("excel_connection.md")); - } -} diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelElicitationPrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelElicitationPrompts.cs index f666a37a..19493d84 100644 --- a/src/ExcelMcp.McpServer/Prompts/ExcelElicitationPrompts.cs +++ b/src/ExcelMcp.McpServer/Prompts/ExcelElicitationPrompts.cs @@ -17,11 +17,4 @@ public static ChatMessage DataValidationChecklist() { return new ChatMessage(ChatRole.User, MarkdownLoader.LoadElicitation("data_validation.md")); } - - [McpServerPrompt(Name = "excel_troubleshooting_guide")] - [Description("Common Excel MCP server issues and solutions")] - public static ChatMessage TroubleshootingGuide() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("server_quirks.md")); - } } diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelNamedRangePrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelNamedRangePrompts.cs deleted file mode 100644 index 68113942..00000000 --- a/src/ExcelMcp.McpServer/Prompts/ExcelNamedRangePrompts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Prompts; - -/// -/// MCP prompts for Excel parameter (named range) management. -/// -[McpServerPromptType] -public static class ExcelNamedRangePrompts -{ - /// - /// Guide for efficient parameter creation using bulk operations. - /// - [McpServerPrompt(Name = "excel_namedrange_bulk_guide")] - [Description("Guide for creating multiple Excel parameters efficiently using bulk operations")] - public static ChatMessage NamedRangeBulkGuide() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("excel_namedrange.md")); - } -} diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelRangePrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelRangePrompts.cs deleted file mode 100644 index 05365c62..00000000 --- a/src/ExcelMcp.McpServer/Prompts/ExcelRangePrompts.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Prompts; - -/// -/// MCP prompts for Excel range operations - values, formulas, formatting, validation. -/// -[McpServerPromptType] -public static class ExcelRangePrompts -{ - /// - /// Guide for formatting and styling Excel ranges. - /// - [McpServerPrompt(Name = "excel_range_formatting_guide")] - [Description("Best practices for formatting and styling Excel ranges")] - public static ChatMessage FormattingGuide() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("excel_range.md")); - } - - /// - /// Guide for data validation rules in Excel ranges. - /// - [McpServerPrompt(Name = "excel_range_validation_guide")] - [Description("Guide for adding data validation rules to Excel ranges")] - public static ChatMessage ValidationGuide() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadElicitation("data_validation.md")); - } - - /// - /// Complete workflow combining values, formulas, formatting, and validation. - /// - [McpServerPrompt(Name = "excel_range_complete_workflow")] - [Description("Complete workflow: values → formulas → formatting → validation")] - public static ChatMessage CompleteWorkflow() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("excel_range.md")); - } -} diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelScenarioPrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelScenarioPrompts.cs deleted file mode 100644 index 722bf806..00000000 --- a/src/ExcelMcp.McpServer/Prompts/ExcelScenarioPrompts.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Prompts; - -/// -/// MCP prompts for common Excel workflow scenarios. -/// Provides step-by-step templates for frequent tasks. -/// -[McpServerPromptType] -public static class ExcelScenarioPrompts -{ - [McpServerPrompt(Name = "excel_build_financial_report")] - [Description("Step-by-step guide to build a formatted financial report with formulas")] - public static ChatMessage BuildFinancialReport( - [Description("Report title (optional)")] string? reportTitle = null, - [Description("Number of months (default: 12)")] int? months = null) - { - var monthCount = months ?? 12; - var title = reportTitle ?? "Monthly Revenue Report"; - - return new ChatMessage(ChatRole.User, $@" -# BUILD FINANCIAL REPORT: {title} - -Complete workflow for creating a professional financial report with formulas and formatting. - -**CRITICAL: Keep session open until ALL steps complete - do NOT close prematurely** - -## RECOMMENDED WORKFLOW: - -1. excel_file(action: 'open', excelPath: 'FinancialReport.xlsx') - \u2192 Returns sessionId (use for ALL remaining operations) -2. excel_worksheet(action: 'create', sheetName: 'Report', sessionId: '') -3. excel_range(action: 'set-values', rangeAddress: 'A1:D1', values: [['Month', 'Revenue', 'Expenses', 'Profit']], sessionId: '') -4. excel_range(action: 'format-range', rangeAddress: 'A1:D1', bold: true, fillColor: '#4472C4', sessionId: '') -5. excel_range(action: 'set-formulas', rangeAddress: 'D2:D{monthCount + 1}', formulas: [['=B2-C2'], ...], sessionId: '') -6. excel_range(action: 'set-number-format', rangeAddress: 'B2:D{monthCount + 1}', formatCode: '$#,##0', sessionId: '') -7. excel_file(action: 'close', save: true, sessionId: '') - \u2192 ONLY close when report is complete - -RESULT: Professional formatted report with {monthCount} months of data -"); - } - - [McpServerPrompt(Name = "excel_multi_query_import")] - [Description("Efficiently import multiple Power Queries to Data Model for DAX")] - public static ChatMessage MultiQueryImport( - [Description("Number of queries to import")] int? queryCount = null) - { - var count = queryCount ?? 4; - - return new ChatMessage(ChatRole.User, $@" -# IMPORT {count} POWER QUERIES TO DATA MODEL - -Complete workflow for importing multiple queries and preparing for DAX analysis. - -**CRITICAL: Keep session open across ALL query imports - do NOT close between operations** - -## RECOMMENDED WORKFLOW: - -1. excel_file(action: 'open', excelPath: 'Analytics.xlsx') - \u2192 Returns sessionId (use for ALL remaining operations) -2. For each query: - - excel_powerquery(action: 'create', queryName: '', mCode: '', loadDestination: 'data-model', sessionId: '') -3. excel_file(action: 'close', save: true, sessionId: '') - \u2192 ONLY close after ALL queries imported - -KEY: Use loadDestination: 'data-model' for direct Power Pivot loading -RESULT: {count} queries loaded and ready for DAX measures -"); - } - - [McpServerPrompt(Name = "excel_build_data_entry_form")] - [Description("Build a data entry form with dropdown validation and formatting")] - public static ChatMessage BuildDataEntryForm() - { - return new ChatMessage(ChatRole.User, @" -# BUILD DATA ENTRY FORM WITH VALIDATION - -Create professional data entry form with dropdowns, date validation, and formatted layout. - -**CRITICAL: Keep session open until form is complete - do NOT close between operations** - -WORKFLOW: -1. excel_file(action: 'open', excelPath: 'DataEntryForm.xlsx') - \u2192 Returns sessionId (use for ALL remaining operations) -2. excel_worksheet(action: 'create', sheetName: 'Employee Form', sessionId: '') -3. excel_range(action: 'set-values', values: [['Employee ID', 'Name', 'Department', 'Status', 'Hire Date']], sessionId: '') -4. excel_range(action: 'format-range', rangeAddress: 'A1:E1', bold: true, fillColor: '#D9E1F2', sessionId: '') -5. excel_range(action: 'validate-range', rangeAddress: 'C2:C100', validationType: 'list', validationFormula1: 'IT,HR,Finance,Operations', sessionId: '') -6. excel_range(action: 'validate-range', rangeAddress: 'D2:D100', validationType: 'list', validationFormula1: 'Active,Inactive,Leave', sessionId: '') -7. excel_range(action: 'validate-range', rangeAddress: 'E2:E100', validationType: 'date', sessionId: '') -8. excel_file(action: 'close', save: true, sessionId: '') - \u2192 ONLY close when form is complete - -RESULT: Professional form with validation, dropdowns, and formatting -"); - } - - - [McpServerPrompt(Name = "excel_build_analytics_workbook")] - [Description("Complete workflow: Build analytics workbook with Power Query, Data Model, DAX measures")] - public static ChatMessage BuildAnalyticsWorkbook() - { - return new ChatMessage(ChatRole.User, @" -# BUILD COMPLETE ANALYTICS WORKBOOK - -End-to-end: Import data → Build Data Model → Create DAX measures → Add PivotTable - -**CRITICAL: Keep session open across ALL steps - do NOT close between operations** - -WORKFLOW: -1. excel_file(action: 'open', excelPath: 'Analytics.xlsx') - → Returns sessionId (use for ALL remaining operations) -2. Import 4 queries with loadDestination: 'data-model' (Sales, Products, Customers, Calendar) - - excel_powerquery(action: 'create', queryName: 'Sales', mCode: '', loadDestination: 'data-model', sessionId: '') - - ... (repeat for Products, Customers, Calendar) -3. Create 3 relationships - - excel_datamodel(action: 'create-relationship', fromTable: 'Sales', fromColumn: 'ProductID', toTable: 'Products', toColumn: 'ProductID', sessionId: '') - - excel_datamodel(action: 'create-relationship', fromTable: 'Sales', fromColumn: 'CustomerID', toTable: 'Customers', toColumn: 'CustomerID', sessionId: '') - - excel_datamodel(action: 'create-relationship', fromTable: 'Sales', fromColumn: 'DateID', toTable: 'Calendar', toColumn: 'DateID', sessionId: '') -4. Create 4 DAX measures - - excel_datamodel(action: 'create-measure', tableName: 'Measures', measureName: 'Total Revenue', daxFormula: 'SUM(Sales[Amount])', sessionId: '') - - ... (repeat for other measures) -5. excel_pivottable(action: 'create-from-datamodel', dataModelTableName: 'Sales', destinationSheet: 'PivotTable', destinationCell: 'A1', sessionId: '') -6. excel_file(action: 'close', save: true, sessionId: '') - → ONLY close when analytics workbook is complete - -RESULT: 4 data sources, 3 relationships, 4 DAX measures, 1 PivotTable -"); - } -} diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelToolSelectionPrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelToolSelectionPrompts.cs deleted file mode 100644 index 9ebf6558..00000000 --- a/src/ExcelMcp.McpServer/Prompts/ExcelToolSelectionPrompts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Prompts; - -/// -/// MCP prompt for helping LLMs choose the right Excel tool for the task. -/// -[McpServerPromptType] -public static class ExcelToolSelectionPrompts -{ - /// - /// Comprehensive guide for selecting the appropriate Excel tool. - /// - [McpServerPrompt(Name = "excel_tool_selection_guide")] - [Description("Guide for choosing the right Excel tool based on the user's request")] - public static ChatMessage ToolSelectionGuide() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("tool_selection_guide.md")); - } -} diff --git a/src/ExcelMcp.McpServer/Prompts/ExcelVbaPrompts.cs b/src/ExcelMcp.McpServer/Prompts/ExcelVbaPrompts.cs deleted file mode 100644 index 3db3ad2a..00000000 --- a/src/ExcelMcp.McpServer/Prompts/ExcelVbaPrompts.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Server; - -namespace Sbroenne.ExcelMcp.McpServer.Prompts; - -/// -/// MCP prompts for Excel VBA macro management. -/// -[McpServerPromptType] -public static class ExcelVbaPrompts -{ - /// - /// Guide for VBA macro version control and automation workflows. - /// - [McpServerPrompt(Name = "excel_vba_version_control_guide")] - [Description("Guide for managing VBA macros with version control and automation")] - public static ChatMessage VbaVersionControlGuide() - { - return new ChatMessage(ChatRole.User, MarkdownLoader.LoadPrompt("excel_vba.md")); - } -} diff --git a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs b/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs index 01db9a3f..bca25d1e 100644 --- a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs +++ b/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetry.cs @@ -12,27 +12,6 @@ namespace Sbroenne.ExcelMcp.McpServer.Telemetry; /// public static class ExcelMcpTelemetry { - /// - /// Environment variable to opt-out of telemetry. - /// Set to "true" or "1" to disable telemetry. - /// - public const string OptOutEnvironmentVariable = "EXCELMCP_TELEMETRY_OPTOUT"; - - /// - /// Environment variable to enable debug mode (console output instead of Azure). - /// Set to "true" or "1" for local testing without Azure resources. - /// - public const string DebugTelemetryEnvironmentVariable = "EXCELMCP_DEBUG_TELEMETRY"; - - /// - /// Application Insights connection string (embedded at build time). - /// - /// - /// This value is replaced during CI/CD build from the APPINSIGHTS_CONNECTION_STRING secret. - /// Format: InstrumentationKey=xxx;IngestionEndpoint=https://xxx.in.applicationinsights.azure.com/;... - /// - private const string ConnectionString = "__APPINSIGHTS_CONNECTION_STRING__"; - /// /// Unique session ID for correlating telemetry within a single MCP server process. /// Changes each time the MCP server starts. @@ -58,31 +37,33 @@ public static class ExcelMcpTelemetry /// internal static void SetTelemetryClient(TelemetryClient client) { + Console.Error.WriteLine($"[TELEMETRY DIAG] SetTelemetryClient called with client={(client == null ? "NULL" : "instance")}"); _telemetryClient = client; } - private static bool? _isEnabled; - /// - /// Gets whether telemetry is enabled (not opted out and either debug mode or connection string available). + /// Flushes any buffered telemetry to Application Insights. + /// CRITICAL: Must be called before application exits to ensure telemetry is not lost. + /// Application Insights SDK buffers telemetry and sends in batches - without explicit flush, + /// short-lived processes like MCP servers may terminate before telemetry is transmitted. /// - public static bool IsEnabled + public static void Flush() { - get + Console.Error.WriteLine($"[TELEMETRY DIAG] Flush called, client={((_telemetryClient == null) ? "NULL" : "SET")}"); + if (_telemetryClient == null) return; + + try { - _isEnabled ??= !IsOptedOut() && (IsDebugMode() || !string.IsNullOrEmpty(GetConnectionString())); - return _isEnabled.Value; + // Flush with timeout to avoid hanging on shutdown + // 5 seconds is typically sufficient for small batches + _telemetryClient.FlushAsync(CancellationToken.None).Wait(TimeSpan.FromSeconds(5)); + Console.Error.WriteLine($"[TELEMETRY DIAG] Flush completed successfully"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[TELEMETRY DIAG] Flush failed: {ex.Message}"); + // Don't let telemetry flush failure crash the application } - } - - /// - /// Checks if debug telemetry mode is enabled (console output for local testing). - /// - public static bool IsDebugMode() - { - var debug = Environment.GetEnvironmentVariable(DebugTelemetryEnvironmentVariable); - return string.Equals(debug, "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(debug, "1", StringComparison.Ordinal); } /// @@ -90,24 +71,23 @@ public static bool IsDebugMode() /// public static string? GetConnectionString() { - // Connection string is embedded at build time via CI/CD - // Returns null if placeholder wasn't replaced (local dev builds) - return ConnectionString.StartsWith("__", StringComparison.Ordinal) ? null : ConnectionString; - } - - /// - /// Checks if user has opted out of telemetry via environment variable. - /// - public static bool IsOptedOut() - { - var optOut = Environment.GetEnvironmentVariable(OptOutEnvironmentVariable); - return string.Equals(optOut, "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(optOut, "1", StringComparison.OrdinalIgnoreCase); + // Connection string is embedded at build time from Directory.Build.props.user + // Returns null if not set (placeholder value starts with __) + if (string.IsNullOrEmpty(TelemetryConfig.ConnectionString) || + TelemetryConfig.ConnectionString.StartsWith("__", StringComparison.Ordinal)) + { + Console.Error.WriteLine($"[TELEMETRY DIAG] GetConnectionString returning null. Raw value: '{TelemetryConfig.ConnectionString ?? "(null)"}'"); + return null; + } + Console.Error.WriteLine($"[TELEMETRY DIAG] GetConnectionString returning valid connection string (length={TelemetryConfig.ConnectionString.Length})"); + return TelemetryConfig.ConnectionString; } /// /// Tracks a tool invocation with usage metrics. - /// Sends Application Insights Custom Event for Users/Sessions analytics. + /// Sends Application Insights Request and PageView telemetry. + /// - Request: Populates Performance, Failures, Users, Sessions blades + /// - PageView: Enables User Flows blade (shows tool usage patterns) /// /// The MCP tool name (e.g., "excel_range") /// The action performed (e.g., "get-values") @@ -115,74 +95,47 @@ public static bool IsOptedOut() /// Whether the operation succeeded public static void TrackToolInvocation(string toolName, string action, long durationMs, bool success) { - if (!IsEnabled) return; + Console.Error.WriteLine($"[TELEMETRY DIAG] TrackToolInvocation called: {toolName}/{action}, client={((_telemetryClient == null) ? "NULL" : "SET")}"); + if (_telemetryClient == null) return; - // Debug mode: write to stderr - if (IsDebugMode()) - { - Console.Error.WriteLine($"[Telemetry] ToolInvocation: {toolName}.{action} - {(success ? "Success" : "Failed")} ({durationMs}ms)"); - } + var operationName = $"{toolName}/{action}"; + var startTime = DateTimeOffset.UtcNow.AddMilliseconds(-durationMs); + var duration = TimeSpan.FromMilliseconds(durationMs); - // Send Application Insights Custom Event (populates customEvents table) - if (_telemetryClient != null) + // Request telemetry: Performance, Failures, Users, Sessions + _telemetryClient.TrackRequest(operationName, startTime, duration, success ? "200" : "500", success); + + // PageView telemetry: Enables User Flows blade + // Must include duration for proper User Flows visualization + var pageView = new Microsoft.ApplicationInsights.DataContracts.PageViewTelemetry(operationName) { - var properties = new Dictionary - { - { "ToolName", toolName }, - { "Action", action }, - { "Success", success.ToString() }, - { "AppVersion", GetVersion() } - }; - - var metrics = new Dictionary - { - { "DurationMs", durationMs } - }; - - _telemetryClient.TrackEvent("ToolInvocation", properties, metrics); - } + Timestamp = startTime, + Duration = duration + }; + _telemetryClient.TrackPageView(pageView); + Console.Error.WriteLine($"[TELEMETRY DIAG] TrackRequest + TrackPageView completed for {operationName}"); } /// /// Tracks an unhandled exception. /// Only call this for exceptions that escape all catch blocks (true bugs/crashes). - /// Sends Application Insights exception and Custom Event. /// /// The unhandled exception /// Source of the exception (e.g., "AppDomain.UnhandledException") public static void TrackUnhandledException(Exception exception, string source) { - if (!IsEnabled || exception == null) return; + if (_telemetryClient == null || exception == null) return; // Redact sensitive data from exception - var (type, message, _) = SensitiveDataRedactor.RedactException(exception); + var (type, _, _) = SensitiveDataRedactor.RedactException(exception); - // Debug mode: write to stderr - if (IsDebugMode()) + // Track as exception in Application Insights (for Failures blade) + _telemetryClient.TrackException(exception, new Dictionary { - Console.Error.WriteLine($"[Telemetry] UnhandledException: {type} - {message} (Source: {source})"); - } - - // Send Application Insights telemetry - if (_telemetryClient != null) - { - // Track as exception in Application Insights (for Failures blade) - _telemetryClient.TrackException(exception, new Dictionary - { - { "Source", source }, - { "ExceptionType", type }, - { "AppVersion", GetVersion() } - }); - - // Also track as Custom Event (for Users/Sessions analytics) - _telemetryClient.TrackEvent("UnhandledException", new Dictionary - { - { "Source", source }, - { "ExceptionType", type }, - { "Message", message ?? "Unknown" }, - { "AppVersion", GetVersion() } - }); - } + { "Source", source }, + { "ExceptionType", type }, + { "AppVersion", GetVersion() } + }); } /// diff --git a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs b/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs index efe64aff..5bbc3ac7 100644 --- a/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs +++ b/src/ExcelMcp.McpServer/Telemetry/ExcelMcpTelemetryInitializer.cs @@ -1,19 +1,23 @@ // Copyright (c) Sbroenne. All rights reserved. // Licensed under the MIT License. +using System.Reflection; + using Microsoft.ApplicationInsights.Channel; using Microsoft.ApplicationInsights.Extensibility; namespace Sbroenne.ExcelMcp.McpServer.Telemetry; /// -/// Telemetry initializer that sets User.Id and Session.Id for Application Insights. -/// This enables the Users and Sessions blades in the Azure Portal. +/// Telemetry initializer that sets User.Id, Session.Id, and Component.Version for Application Insights. +/// This enables the Users and Sessions blades in the Azure Portal and ensures correct version reporting. /// public sealed class ExcelMcpTelemetryInitializer : ITelemetryInitializer { private readonly string _userId; private readonly string _sessionId; + private readonly string _version; + private readonly string _roleInstance; /// /// Initializes a new instance of the class. @@ -22,10 +26,12 @@ public ExcelMcpTelemetryInitializer() { _userId = ExcelMcpTelemetry.UserId; _sessionId = ExcelMcpTelemetry.SessionId; + _version = GetVersion(); + _roleInstance = GenerateAnonymousRoleInstance(); } /// - /// Initializes the telemetry item with user and session context. + /// Initializes the telemetry item with user, session, and version context. /// /// The telemetry item to initialize. public void Initialize(ITelemetry telemetry) @@ -47,5 +53,33 @@ public void Initialize(ITelemetry telemetry) { telemetry.Context.Cloud.RoleName = "ExcelMcp.McpServer"; } + + // Set role instance to anonymized value (instead of machine name) + telemetry.Context.Cloud.RoleInstance = _roleInstance; + + // Set version explicitly - ALWAYS override SDK auto-detection + // SDK picks up Excel COM version (15.0.0.0) instead of our assembly version + telemetry.Context.Component.Version = _version; + } + + /// + /// Generates an anonymous role instance identifier based on machine identity. + /// Uses the same hash as UserId but with a different prefix for clarity. + /// + private static string GenerateAnonymousRoleInstance() + { + // Reuse the anonymous user ID (already a hash of machine identity) + return $"instance-{ExcelMcpTelemetry.UserId[..8]}"; + } + + /// + /// Gets the application version from assembly metadata. + /// + private static string GetVersion() + { + return Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() + ?? "1.0.0"; } } diff --git a/src/ExcelMcp.McpServer/Tools/ExcelNamedRangeTool.cs b/src/ExcelMcp.McpServer/Tools/ExcelNamedRangeTool.cs index a5b70ccc..c0958c6a 100644 --- a/src/ExcelMcp.McpServer/Tools/ExcelNamedRangeTool.cs +++ b/src/ExcelMcp.McpServer/Tools/ExcelNamedRangeTool.cs @@ -17,7 +17,16 @@ public static class ExcelNamedRangeTool /// Manage Excel parameters (named ranges) - configuration values and reusable references /// [McpServerTool(Name = "excel_namedrange")] - [Description(@"Manage Excel named ranges")] + [Description(@"Manage Excel named ranges - named cell references for reusable formulas and parameters. + +CREATE/UPDATE: +- value is a cell reference (e.g., 'Sheet1!A1' or 'Sheet1!$A$1:$B$10') +- Use $ for absolute references that won't shift when copied + +WRITE: +- value is the actual data to store in the named range's cell(s) + +TIP: Use excel_range with rangeAddress=namedRangeName for bulk data operations on named ranges.")] public static string ExcelParameter( [Required] [Description("Action to perform (enum displayed as dropdown in MCP clients)")] diff --git a/src/ExcelMcp.McpServer/Tools/ExcelRangeTool.cs b/src/ExcelMcp.McpServer/Tools/ExcelRangeTool.cs index 271f0b07..d8978f1f 100644 --- a/src/ExcelMcp.McpServer/Tools/ExcelRangeTool.cs +++ b/src/ExcelMcp.McpServer/Tools/ExcelRangeTool.cs @@ -24,8 +24,11 @@ public static class ExcelRangeTool DATA FORMAT: - Values/formulas: JSON 2D arrays [[row1col1, row1col2], [row2col1, row2col2]] -- Example single cell: [[100]] or [['=SUM(A:A)']] -- Example range: [[1,2,3], [4,5,6], [7,8,9]] +- Single cell returns [[value]] (always 2D, never scalar) + +NAMED RANGES: +- Can use named range name instead of rangeAddress (e.g., 'SalesData' instead of 'A1:D10') +- When using named range, leave sheetName empty ")] public static string ExcelRange( [Required] diff --git a/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs b/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs index be107f8c..5556f5e0 100644 --- a/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs +++ b/src/ExcelMcp.McpServer/Tools/ExcelWorksheetTool.cs @@ -25,17 +25,10 @@ public static class ExcelWorksheetTool [McpServerTool(Name = "excel_worksheet")] [Description(@"Manage Excel worksheets: lifecycle, tab colors, visibility. -REQUIRED WORKFLOW: -- Use excel_file(action: 'open') first to get a sessionId -- Pass sessionId to all worksheet actions -- Use excel_file(action: 'save') to persist changes -- Use excel_file(action: 'close') to end the session (does NOT save) - CROSS-WORKBOOK OPERATIONS (copy-to-workbook, move-to-workbook): - Copy or move sheets BETWEEN different Excel files - Requires TWO sessionIds: sourceSessionId + targetSessionId - Opens both workbooks in same Excel instance automatically -- Example: Copy 'Sales' from Q1.xlsx to Q2.xlsx using both session IDs TAB COLORS (set-tab-color): - RGB values: 0-255 for red, green, blue components diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 42fd10fd..f544501a 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -2,7 +2,5 @@ true - - $(MSBuildThisFileDirectory)test.runsettings diff --git a/tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj b/tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj index b101a61b..c6201af0 100644 --- a/tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj +++ b/tests/ExcelMcp.McpServer.Tests/ExcelMcp.McpServer.Tests.csproj @@ -16,6 +16,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs new file mode 100644 index 00000000..1fa543b4 --- /dev/null +++ b/tests/ExcelMcp.McpServer.Tests/Integration/McpServerIntegrationTests.cs @@ -0,0 +1,371 @@ +// Copyright (c) Sbroenne. All rights reserved. +// Licensed under the MIT License. + +using System.IO.Pipelines; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Sbroenne.ExcelMcp.McpServer.Telemetry; +using Sbroenne.ExcelMcp.McpServer.Tools; +using Xunit; +using Xunit.Abstractions; + +// Avoid namespace conflict: McpServer is both a type and namespace +using Server = ModelContextProtocol.Server; + +namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration; + +/// +/// Integration tests that exercise the full MCP protocol using in-memory transport. +/// These tests use the official MCP SDK client to connect to our server, ensuring: +/// - DI pipeline is correctly configured +/// - Tool discovery via WithToolsFromAssembly() works +/// - Tool schemas are correctly generated +/// - Tools execute properly through the MCP protocol +/// +/// This is the CORRECT way to test MCP servers - using the SDK's client to verify +/// the actual protocol behavior, not reflection or direct method calls. +/// +[Trait("Category", "Integration")] +[Trait("Speed", "Fast")] +[Trait("Layer", "McpServer")] +[Trait("Feature", "McpProtocol")] +public class McpServerIntegrationTests(ITestOutputHelper output) : IAsyncLifetime, IAsyncDisposable +{ + private readonly Pipe _clientToServerPipe = new(); + private readonly Pipe _serverToClientPipe = new(); + private readonly CancellationTokenSource _cts = new(); + private Server.McpServer? _server; + private McpClient? _client; + private IServiceProvider? _serviceProvider; + private Task? _serverTask; + + /// + /// Expected tool names from our assembly - the source of truth. + /// + private static readonly HashSet ExpectedToolNames = + [ + "excel_chart", + "excel_conditionalformat", + "excel_connection", + "excel_datamodel", + "excel_file", + "excel_namedrange", + "excel_pivottable", + "excel_powerquery", + "excel_range", + "excel_table", + "excel_vba", + "excel_worksheet" + ]; + + /// + /// Setup: Create MCP server with DI and connect client via in-memory pipes. + /// This exercises the exact same code path as Program.cs. + /// + public async Task InitializeAsync() + { + // Build the server with DI - same pattern as Program.cs + var services = new ServiceCollection(); + services.AddLogging(builder => builder.AddDebug().SetMinimumLevel(LogLevel.Debug)); + + // Configure telemetry (disabled for tests) + services.AddApplicationInsightsTelemetryWorkerService(options => + { + options.ConnectionString = null; + options.EnableHeartbeat = false; + options.EnableAdaptiveSampling = false; + options.EnableQuickPulseMetricStream = false; + options.EnablePerformanceCounterCollectionModule = false; + options.EnableEventCounterCollectionModule = false; + options.EnableDependencyTrackingTelemetryModule = false; + }); + services.AddSingleton(); + + // Add MCP server with tools (same as Program.cs) using stream transport for testing + services + .AddMcpServer(options => + { + options.ServerInfo = new() { Name = "ExcelMcp-Test", Version = "1.0.0" }; + options.ServerInstructions = "Test server for integration tests"; + }) + .WithStreamServerTransport( + _clientToServerPipe.Reader.AsStream(), + _serverToClientPipe.Writer.AsStream()) + .WithToolsFromAssembly(typeof(ExcelFileTool).Assembly); + + _serviceProvider = services.BuildServiceProvider(validateScopes: true); + + // Get the server and start it + _server = _serviceProvider.GetRequiredService(); + _serverTask = _server.RunAsync(_cts.Token); + + // Create client connected to the server via pipes + _client = await McpClient.CreateAsync( + new StreamClientTransport( + serverInput: _clientToServerPipe.Writer.AsStream(), + serverOutput: _serverToClientPipe.Reader.AsStream()), + clientOptions: new McpClientOptions + { + ClientInfo = new() { Name = "TestClient", Version = "1.0.0" } + }, + cancellationToken: _cts.Token); + + output.WriteLine($"✓ Connected to server: {_client.ServerInfo?.Name} v{_client.ServerInfo?.Version}"); + } + + public async Task DisposeAsync() + { + await DisposeAsyncCore(); + } + + // Explicit IAsyncDisposable implementation to satisfy CA1001 analyzer + async ValueTask IAsyncDisposable.DisposeAsync() + { + await DisposeAsyncCore(); + GC.SuppressFinalize(this); + } + + private async Task DisposeAsyncCore() + { + await _cts.CancelAsync(); + + _clientToServerPipe.Writer.Complete(); + _serverToClientPipe.Writer.Complete(); + + if (_client != null) + { + await _client.DisposeAsync(); + } + + if (_serverTask != null) + { + try + { + await _serverTask; + } + catch (OperationCanceledException) + { + // Expected during shutdown + } + } + + if (_serviceProvider is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (_serviceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + + _cts.Dispose(); + } + + /// + /// Tests that all 12 expected tools are discoverable via the MCP protocol. + /// This is THE definitive test - it uses client.ListToolsAsync() which exercises: + /// - DI pipeline + /// - WithToolsFromAssembly() discovery + /// - MCP protocol serialization + /// - Tool schema generation + /// + [Fact] + public async Task ListTools_ReturnsAll12ExpectedTools() + { + output.WriteLine("=== TOOL DISCOVERY VIA MCP PROTOCOL ===\n"); + + // Act - Use the REAL MCP protocol to list tools + var tools = await _client!.ListToolsAsync(cancellationToken: _cts.Token); + + // Assert - Verify count + output.WriteLine($"Discovered {tools.Count} tools via MCP protocol:\n"); + + foreach (var tool in tools.OrderBy(t => t.Name)) + { + var descPreview = tool.Description?.Length > 60 ? tool.Description[..60] + "..." : tool.Description; + output.WriteLine($" • {tool.Name}: {descPreview}"); + } + + Assert.Equal(ExpectedToolNames.Count, tools.Count); + + // Verify all expected tools are present + var actualToolNames = tools.Select(t => t.Name).ToHashSet(); + + var missingTools = ExpectedToolNames.Except(actualToolNames).ToList(); + if (missingTools.Count > 0) + { + output.WriteLine($"\n❌ Missing tools: {string.Join(", ", missingTools)}"); + } + Assert.Empty(missingTools); + + var unexpectedTools = actualToolNames.Except(ExpectedToolNames).ToList(); + if (unexpectedTools.Count > 0) + { + output.WriteLine($"\n❌ Unexpected tools: {string.Join(", ", unexpectedTools)}"); + } + Assert.Empty(unexpectedTools); + + output.WriteLine($"\n✓ All {ExpectedToolNames.Count} tools discovered successfully via MCP protocol"); + } + + /// + /// Tests that each tool has proper schema (parameters, descriptions). + /// + [Fact] + public async Task ListTools_AllToolsHaveValidSchema() + { + output.WriteLine("=== TOOL SCHEMA VALIDATION ===\n"); + + var tools = await _client!.ListToolsAsync(cancellationToken: _cts.Token); + + foreach (var tool in tools) + { + // Every tool must have a name + Assert.False(string.IsNullOrEmpty(tool.Name), "Tool has empty name"); + + // Every tool should have a description + Assert.False(string.IsNullOrEmpty(tool.Description), $"Tool {tool.Name} has no description"); + + // McpClientTool implements AIFunction which has Parameters property + // The SDK generates schema from tool methods + + output.WriteLine($"✓ {tool.Name}: Has description ({tool.Description?.Length} chars)"); + } + + output.WriteLine($"\n✓ All {tools.Count} tools have valid schemas"); + } + + /// + /// Tests that excel_file tool's Test action works via MCP protocol. + /// This exercises the complete tool invocation path. + /// + [Fact] + public async Task CallTool_ExcelFileTest_ReturnsSuccess() + { + output.WriteLine("=== TOOL INVOCATION VIA MCP PROTOCOL ===\n"); + + // Arrange - Test action doesn't require an actual file + var arguments = new Dictionary + { + ["action"] = "Test", + ["excelPath"] = "C:\\fake\\test.xlsx" + }; + + // Act - Call tool via MCP protocol + var result = await _client!.CallToolAsync( + "excel_file", + arguments, + cancellationToken: _cts.Token); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Get text content - need to cast from ContentBlock base class + var textBlock = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textBlock); + + var textPreview = textBlock.Text.Length > 200 ? textBlock.Text[..200] + "..." : textBlock.Text; + output.WriteLine($"Tool response: {textPreview}"); + + // The test action should return success + Assert.Contains("success", textBlock.Text.ToLowerInvariant()); + + output.WriteLine("\n✓ excel_file Test action executed successfully via MCP protocol"); + } + + /// + /// Tests that server information is correctly exposed via MCP protocol. + /// + [Fact] + public async Task ServerInfo_ReturnsCorrectInformation() + { + output.WriteLine("=== SERVER INFO VIA MCP PROTOCOL ===\n"); + + // Act - Server info is available after connection + var serverInfo = _client!.ServerInfo; + var serverInstructions = _client.ServerInstructions; + + // Assert + Assert.NotNull(serverInfo); + Assert.Equal("ExcelMcp-Test", serverInfo.Name); + Assert.Equal("1.0.0", serverInfo.Version); + Assert.Equal("Test server for integration tests", serverInstructions); + + output.WriteLine($"Server Name: {serverInfo.Name}"); + output.WriteLine($"Server Version: {serverInfo.Version}"); + output.WriteLine($"Server Instructions: {serverInstructions}"); + + output.WriteLine("\n✓ Server info correctly exposed via MCP protocol"); + await Task.CompletedTask; // Satisfy async requirement + } + + /// + /// Tests that telemetry services are properly registered in DI. + /// + [Fact] + public void DI_TelemetryServicesRegistered() + { + output.WriteLine("=== TELEMETRY DI REGISTRATION ===\n"); + + Assert.NotNull(_serviceProvider); + + // Act - Verify telemetry services are available + var telemetryClient = _serviceProvider.GetService(); + var telemetryInitializers = _serviceProvider.GetServices().ToList(); + + // Assert + Assert.NotNull(telemetryClient); + Assert.Contains(telemetryInitializers, i => i is ExcelMcpTelemetryInitializer); + + output.WriteLine("✓ TelemetryClient registered"); + output.WriteLine($"✓ Found {telemetryInitializers.Count} telemetry initializers"); + output.WriteLine("✓ ExcelMcpTelemetryInitializer present"); + + output.WriteLine("\n✓ Telemetry services correctly registered in DI"); + } + + /// + /// Tests that tools can be enumerated lazily (important for large tool sets). + /// + [Fact] + public async Task EnumerateTools_SupportsLazyEnumeration() + { + output.WriteLine("=== LAZY TOOL ENUMERATION ===\n"); + + var toolCount = 0; + await foreach (var tool in _client!.EnumerateToolsAsync(cancellationToken: _cts.Token)) + { + toolCount++; + output.WriteLine($" Discovered: {tool.Name}"); + } + + Assert.Equal(ExpectedToolNames.Count, toolCount); + + output.WriteLine($"\n✓ Enumerated {toolCount} tools lazily"); + } + + /// + /// Tests that server capabilities include tools. + /// + [Fact] + public void ServerCapabilities_IncludesTools() + { + output.WriteLine("=== SERVER CAPABILITIES ===\n"); + + var capabilities = _client!.ServerCapabilities; + + Assert.NotNull(capabilities); + Assert.NotNull(capabilities.Tools); + + output.WriteLine($"✓ Tools capability: {capabilities.Tools != null}"); + output.WriteLine($"✓ ListChanged: {capabilities.Tools?.ListChanged}"); + + output.WriteLine("\n✓ Server capabilities correctly exposed"); + } +} diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs index f16b079d..05266349 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs +++ b/tests/ExcelMcp.McpServer.Tests/Integration/Models/ActionEnumCompletenessTests.cs @@ -14,20 +14,14 @@ namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Models; /// /// Uses reflection to automatically discover ALL action enums - no manual maintenance required. /// +/// [Trait("Category", "Integration")] [Trait("Speed", "Fast")] [Trait("Layer", "McpServer")] [Trait("Feature", "ActionEnums")] [Trait("RequiresExcel", "false")] -public class ActionEnumCompletenessTests +public class ActionEnumCompletenessTests(ITestOutputHelper output) { - private readonly ITestOutputHelper _output; - /// - - public ActionEnumCompletenessTests(ITestOutputHelper output) - { - _output = output; - } /// /// CRITICAL: Discovers ALL *Action enums and verifies every value has a ToActionString() mapping. @@ -45,10 +39,10 @@ public void AllActionEnums_HaveCompleteToActionStringMappings() .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "Sbroenne.ExcelMcp.McpServer.Models") .ToList(); - _output.WriteLine($"Found {actionEnums.Count} action enums:"); + output.WriteLine($"Found {actionEnums.Count} action enums:"); foreach (var enumType in actionEnums) { - _output.WriteLine($" - {enumType.Name}"); + output.WriteLine($" - {enumType.Name}"); } Assert.NotEmpty(actionEnums); // Sanity check @@ -87,7 +81,7 @@ public void AllActionEnums_HaveCompleteToActionStringMappings() } else { - _output.WriteLine($" ✅ {enumType.Name}.{enumValue} → '{result}'"); + output.WriteLine($" ✅ {enumType.Name}.{enumValue} → '{result}'"); } } catch (TargetInvocationException ex) when (ex.InnerException is ArgumentException argEx) @@ -104,7 +98,7 @@ public void AllActionEnums_HaveCompleteToActionStringMappings() if (failures.Count > 0) { var message = $"Enum mapping failures:\n{string.Join("\n", failures)}"; - _output.WriteLine($"\n{message}"); + output.WriteLine($"\n{message}"); Assert.Fail(message); } } @@ -171,7 +165,7 @@ public void AllActionEnums_NoDuplicateActionStrings() if (failures.Count > 0) { var message = $"Duplicate action string failures:\n{string.Join("\n", failures)}"; - _output.WriteLine($"\n{message}"); + output.WriteLine($"\n{message}"); Assert.Fail(message); } } @@ -192,17 +186,17 @@ public void AllActionEnums_DocumentedInToolFiles() .Where(t => t.IsEnum && t.Name.EndsWith("Action", StringComparison.Ordinal) && t.Namespace == "Sbroenne.ExcelMcp.McpServer.Models") .ToList(); - _output.WriteLine($"\nExpected tool files with switch statements:"); - _output.WriteLine($"Each *Action enum should have corresponding *Tool.cs with exhaustive switch.\n"); + output.WriteLine($"\nExpected tool files with switch statements:"); + output.WriteLine($"Each *Action enum should have corresponding *Tool.cs with exhaustive switch.\n"); foreach (var enumType in actionEnums) { var toolName = enumType.Name.Replace("Action", "Tool"); - _output.WriteLine($" - {enumType.Name} → Tools/{toolName}.cs"); - _output.WriteLine($" Expected: switch (action.ToActionString()) with all {Enum.GetValues(enumType).Length} cases"); + output.WriteLine($" - {enumType.Name} → Tools/{toolName}.cs"); + output.WriteLine($" Expected: switch (action.ToActionString()) with all {Enum.GetValues(enumType).Length} cases"); } - _output.WriteLine($"\n✅ Compiler enforces exhaustive switches via warning CS8524."); - _output.WriteLine($"✅ Build with TreatWarningsAsErrors=true ensures no missing cases."); + output.WriteLine($"\n✅ Compiler enforces exhaustive switches via warning CS8524."); + output.WriteLine($"✅ Build with TreatWarningsAsErrors=true ensures no missing cases."); } } diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs index acbc6950..5ed329eb 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs +++ b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/McpServerSmokeTests.cs @@ -1,34 +1,55 @@ +// Copyright (c) Sbroenne. All rights reserved. +// Licensed under the MIT License. + +using System.IO.Pipelines; using System.Text.Json; -using Sbroenne.ExcelMcp.Core.Commands.Chart; -using Sbroenne.ExcelMcp.McpServer.Models; -using Sbroenne.ExcelMcp.McpServer.Tools; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Sbroenne.ExcelMcp.McpServer.Telemetry; using Xunit; using Xunit.Abstractions; namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; /// -/// Smoke test for MCP Server - Quick validation of core functionality from an LLM perspective. +/// End-to-end smoke tests for the MCP Server using the official MCP SDK client. +/// +/// PURPOSE: Validates the complete MCP protocol stack works correctly with real Excel operations. +/// PATTERN: Uses Program.ConfigureTestTransport() to inject in-memory pipes, then runs the real server. +/// RUNTIME: ~30-60 seconds (requires Excel COM automation). +/// +/// These tests exercise: +/// - Full DI pipeline (exact same as production) +/// - MCP protocol serialization/deserialization +/// - Tool discovery and invocation via MCP protocol +/// - Real Excel operations through COM interop +/// - Session management across multiple tool calls +/// - Application Insights telemetry (same configuration as production) /// -/// PURPOSE: Fast, on-demand test to verify major functionality isn't broken. -/// SCOPE: Exercises the 12 main MCP tools with typical LLM workflows. -/// RUNTIME: ~30-60 seconds (fast enough for pre-commit checks). +/// The server is a BLACK BOX - tests only interact via MCP protocol. +/// Only the transport differs: pipes instead of stdio. /// -/// Run this test before commits to catch breaking changes: -/// dotnet test --filter "FullyQualifiedName~McpServerSmokeTests.SmokeTest_AllTools_LlmWorkflow" +/// Run before commits to catch breaking changes: +/// dotnet test --filter "FullyQualifiedName~McpServerSmokeTests" /// [Trait("Category", "Integration")] [Trait("Speed", "Medium")] [Trait("Layer", "McpServer")] [Trait("Feature", "SmokeTest")] [Trait("RequiresExcel", "true")] -public class McpServerSmokeTests : IDisposable +public class McpServerSmokeTests : IAsyncLifetime, IAsyncDisposable { private readonly ITestOutputHelper _output; private readonly string _tempDir; private readonly string _testExcelFile; private readonly string _testCsvFile; - private readonly string _testQueryFile; + + // MCP transport pipes + private readonly Pipe _clientToServerPipe = new(); + private readonly Pipe _serverToClientPipe = new(); + private readonly CancellationTokenSource _cts = new(); + private McpClient? _client; + private Task? _serverTask; public McpServerSmokeTests(ITestOutputHelper output) { @@ -40,14 +61,94 @@ public McpServerSmokeTests(ITestOutputHelper output) _testExcelFile = Path.Join(_tempDir, "SmokeTest.xlsx"); _testCsvFile = Path.Join(_tempDir, "SampleData.csv"); - _testQueryFile = Path.Join(_tempDir, "TestQuery.pq"); _output.WriteLine($"Test directory: {_tempDir}"); } - /// - public void Dispose() + /// + /// Setup: Configure test transport and run the real MCP server. + /// The server is a BLACK BOX - we only configure transport, everything else is production code. + /// + public async Task InitializeAsync() + { + // Configure the server to use our test pipes instead of stdio + // This is the ONLY difference from production - transport layer only + Program.ConfigureTestTransport(_clientToServerPipe, _serverToClientPipe); + + // Run the REAL server (Program.Main) - exact same code path as production + // The server will use our configured pipes for transport + _serverTask = Program.Main([]); + + // Create client connected to the server via pipes + _client = await McpClient.CreateAsync( + new StreamClientTransport( + serverInput: _clientToServerPipe.Writer.AsStream(), + serverOutput: _serverToClientPipe.Reader.AsStream()), + clientOptions: new McpClientOptions + { + ClientInfo = new() { Name = "SmokeTestClient", Version = "1.0.0" } + }, + cancellationToken: _cts.Token); + + _output.WriteLine($"✓ Connected to server: {_client.ServerInfo?.Name} v{_client.ServerInfo?.Version}"); + } + + public async Task DisposeAsync() + { + await DisposeAsyncCore(); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + await DisposeAsyncCore(); + GC.SuppressFinalize(this); + } + + private async Task DisposeAsyncCore() { + // Flush telemetry before shutdown to ensure test telemetry is sent + ExcelMcpTelemetry.Flush(); + + // Dispose client first - this signals we're done sending requests + if (_client != null) + { + await _client.DisposeAsync(); + } + + // Complete the pipes to signal EOF - this triggers GRACEFUL server shutdown + // The MCP SDK will see EOF and stop the host naturally, allowing + // Application Insights and other services to flush during shutdown + _clientToServerPipe.Writer.Complete(); + _serverToClientPipe.Writer.Complete(); + + // Wait for server to shut down gracefully (with timeout) + if (_serverTask != null) + { + // Give the server time to flush telemetry and clean up + var shutdownTimeout = Task.Delay(TimeSpan.FromSeconds(10)); + var completed = await Task.WhenAny(_serverTask, shutdownTimeout); + + if (completed == shutdownTimeout) + { + // Server didn't shut down in time - cancel as fallback + await _cts.CancelAsync(); + try + { + await _serverTask; + } + catch (OperationCanceledException) + { + // Expected when we had to force cancel + } + } + } + + // Reset test transport for next test + Program.ResetTestTransport(); + + _cts.Dispose(); + + // Clean up temp files if (Directory.Exists(_tempDir)) { try @@ -59,308 +160,416 @@ public void Dispose() // Ignore cleanup errors } } - GC.SuppressFinalize(this); } /// - /// Comprehensive smoke test that exercises all 13 MCP tools in a realistic LLM workflow using the session API. - /// This test validates the complete tool chain and demonstrates proper session usage for multiple operations. + /// Comprehensive smoke test that exercises all 12 MCP tools via the SDK client. + /// This validates the complete E2E flow: MCP protocol → DI → Tool → Core → Excel COM. /// [Fact] - public void SmokeTest_AllTools_LlmWorkflow() + public async Task SmokeTest_AllTools_E2EWorkflow() { - _output.WriteLine("=== MCP SERVER SMOKE TEST (SESSION API) ==="); - _output.WriteLine("Testing all 13 tools in optimized session workflow...\n"); + _output.WriteLine("=== MCP SERVER E2E SMOKE TEST (SDK CLIENT) ==="); + _output.WriteLine("Testing all 12 tools via MCP protocol with real Excel...\n"); // ===================================================================== - // STEP 1: FILE CREATION (before session) + // STEP 1: FILE CREATION // ===================================================================== - _output.WriteLine("✓ Step 1: Creating workbook..."); + _output.WriteLine("✓ Step 1: Creating workbook via MCP protocol..."); - // Create empty workbook - var createResult = ExcelFileTool.ExcelFile(FileAction.CreateEmpty, _testExcelFile); + var createResult = await CallToolAsync("excel_file", new Dictionary + { + ["action"] = "CreateEmpty", + ["excelPath"] = _testExcelFile + }); AssertSuccess(createResult, "File creation"); Assert.True(File.Exists(_testExcelFile), "Excel file should exist"); - - _output.WriteLine(" ✓ excel_file: CREATE passed"); + _output.WriteLine(" ✓ excel_file: CreateEmpty passed"); // ===================================================================== - // STEP 2: OPEN SESSION (75-90% faster for multiple operations) + // STEP 2: OPEN SESSION // ===================================================================== - _output.WriteLine("\n✓ Step 2: Opening session..."); + _output.WriteLine("\n✓ Step 2: Opening session via MCP protocol..."); - var openResult = ExcelFileTool.ExcelFile(FileAction.Open, _testExcelFile); + var openResult = await CallToolAsync("excel_file", new Dictionary + { + ["action"] = "Open", + ["excelPath"] = _testExcelFile + }); AssertSuccess(openResult, "Open session"); - var openJson = JsonDocument.Parse(openResult); - var sessionId = openJson.RootElement.GetProperty("sessionId").GetString(); + var sessionId = GetJsonProperty(openResult, "sessionId"); Assert.NotNull(sessionId); - _output.WriteLine($" ✓ Session opened: {sessionId}"); // ===================================================================== - // STEP 3: ALL OPERATIONS IN SESSION (using sessionId) + // STEP 3: WORKSHEET OPERATIONS // ===================================================================== - _output.WriteLine("\n✓ Step 3: Running all operations in active session..."); - - // Test file (no session required for test) - var testResult = ExcelFileTool.ExcelFile(FileAction.Test, _testExcelFile); - AssertSuccess(testResult, "File test"); + _output.WriteLine("\n✓ Step 3: Worksheet operations..."); - // Worksheet operations (with session) - var listSheetsResult = ExcelWorksheetTool.ExcelWorksheet(WorksheetAction.List, sessionId); - AssertSuccess(listSheetsResult, "List worksheets in batch"); + var listSheetsResult = await CallToolAsync("excel_worksheet", new Dictionary + { + ["action"] = "List", + ["sessionId"] = sessionId + }); + AssertSuccess(listSheetsResult, "List worksheets"); - var createSheetResult = ExcelWorksheetTool.ExcelWorksheet( - WorksheetAction.Create, - sessionId, - sheetName: "Data"); - AssertSuccess(createSheetResult, "Create worksheet in batch"); + var createSheetResult = await CallToolAsync("excel_worksheet", new Dictionary + { + ["action"] = "Create", + ["sessionId"] = sessionId, + ["sheetName"] = "Data" + }); + AssertSuccess(createSheetResult, "Create worksheet"); + _output.WriteLine(" ✓ excel_worksheet: List and Create passed"); - _output.WriteLine(" ✓ excel_worksheet: LIST and CREATE in batch"); + // ===================================================================== + // STEP 4: RANGE OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 4: Range operations..."); - // Range operations using session-aware tool API var values = new List> { - new List { "Name", "Value", "Date" }, - new List { "Item1", 100, "2024-01-01" }, - new List { "Item2", 200, "2024-01-02" } + new() { "Name", "Value", "Date" }, + new() { "Item1", 100, "2024-01-01" }, + new() { "Item2", 200, "2024-01-02" } }; - var setValuesResult = ExcelRangeTool.ExcelRange( - RangeAction.SetValues, - _testExcelFile, - sessionId, - sheetName: "Data", - rangeAddress: "A1:C3", - values: values); - AssertSuccess(setValuesResult, "Set values in batch"); - - var getValuesResult = ExcelRangeTool.ExcelRange( - RangeAction.GetValues, - _testExcelFile, - sessionId, - sheetName: "Data", - rangeAddress: "A1:C3"); - AssertSuccess(getValuesResult, "Get values in batch"); - - var usedRangeResult = ExcelRangeTool.ExcelRange( - RangeAction.GetUsedRange, - _testExcelFile, - sessionId, - sheetName: "Data"); - AssertSuccess(usedRangeResult, "Get used range in batch"); - - _output.WriteLine(" ✓ excel_range: SET/GET values and USED RANGE in batch"); - - // Table operations via session API - var createTableResult = TableTool.Table( - TableAction.Create, - _testExcelFile, - sessionId, - tableName: "DataTable", - sheetName: "Data", - range: "A1:C3", - hasHeaders: true); - AssertSuccess(createTableResult, "Create table in batch"); - - var listTablesResult = TableTool.Table( - TableAction.List, - _testExcelFile, - sessionId); - AssertSuccess(listTablesResult, "List tables in batch"); - - _output.WriteLine(" ✓ excel_table: CREATE and LIST in batch"); - - // Named range operations via session API - var createParamResult = ExcelNamedRangeTool.ExcelParameter( - NamedRangeAction.Create, - _testExcelFile, - sessionId, - namedRangeName: "ReportDate", - value: "=Data!$C$2"); - AssertSuccess(createParamResult, "Create named range in batch"); - - var getParamResult = ExcelNamedRangeTool.ExcelParameter( - NamedRangeAction.Read, - _testExcelFile, - sessionId, - namedRangeName: "ReportDate"); - AssertSuccess(getParamResult, "Read named range in batch"); - - _output.WriteLine(" ✓ excel_namedrange: CREATE and READ in batch"); - - // Power Query operations via session API + var setValuesResult = await CallToolAsync("excel_range", new Dictionary + { + ["action"] = "SetValues", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["sheetName"] = "Data", + ["rangeAddress"] = "A1:C3", + ["values"] = values + }); + AssertSuccess(setValuesResult, "Set values"); + + var getValuesResult = await CallToolAsync("excel_range", new Dictionary + { + ["action"] = "GetValues", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["sheetName"] = "Data", + ["rangeAddress"] = "A1:C3" + }); + AssertSuccess(getValuesResult, "Get values"); + _output.WriteLine(" ✓ excel_range: SetValues and GetValues passed"); + + // ===================================================================== + // STEP 5: TABLE OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 5: Table operations..."); + + var createTableResult = await CallToolAsync("excel_table", new Dictionary + { + ["action"] = "Create", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["tableName"] = "DataTable", + ["sheetName"] = "Data", + ["range"] = "A1:C3", + ["hasHeaders"] = true + }); + AssertSuccess(createTableResult, "Create table"); + + var listTablesResult = await CallToolAsync("excel_table", new Dictionary + { + ["action"] = "List", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId + }); + AssertSuccess(listTablesResult, "List tables"); + _output.WriteLine(" ✓ excel_table: Create and List passed"); + + // ===================================================================== + // STEP 6: NAMED RANGE OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 6: Named range operations..."); + + var createParamResult = await CallToolAsync("excel_namedrange", new Dictionary + { + ["action"] = "Create", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["namedRangeName"] = "ReportDate", + ["value"] = "=Data!$C$2" + }); + AssertSuccess(createParamResult, "Create named range"); + + var readParamResult = await CallToolAsync("excel_namedrange", new Dictionary + { + ["action"] = "Read", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["namedRangeName"] = "ReportDate" + }); + AssertSuccess(readParamResult, "Read named range"); + _output.WriteLine(" ✓ excel_namedrange: Create and Read passed"); + + // ===================================================================== + // STEP 7: POWER QUERY OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 7: Power Query operations..."); + + // Create test CSV var csvContent = "Product,Quantity\nWidget,10\nGadget,20"; - File.WriteAllText(_testCsvFile, csvContent); + await File.WriteAllTextAsync(_testCsvFile, csvContent); var mCode = $@"let Source = Csv.Document(File.Contents(""{_testCsvFile.Replace("\\", "\\\\")}""),[Delimiter="","", Columns=2, Encoding=1252, QuoteStyle=QuoteStyle.None]), PromotedHeaders = Table.PromoteHeaders(Source, [PromoteAllScalars=true]) in PromotedHeaders"; - File.WriteAllText(_testQueryFile, mCode); - - var importQueryResult = ExcelPowerQueryTool.ExcelPowerQuery( - PowerQueryAction.Create, - sessionId, - queryName: "CsvData", - mCode: mCode, - loadDestination: "connection-only"); - AssertSuccess(importQueryResult, "Create Power Query in batch"); - - var listQueriesResult = ExcelPowerQueryTool.ExcelPowerQuery( - PowerQueryAction.List, - sessionId); - AssertSuccess(listQueriesResult, "List Power Queries in batch"); - - _output.WriteLine(" ✓ excel_powerquery: IMPORT and LIST in batch"); - - // Connection operations via session API - var listConnectionsResult = ExcelConnectionTool.ExcelConnection( - ConnectionAction.List, - _testExcelFile, - sessionId); - AssertSuccess(listConnectionsResult, "List connections in batch"); - - _output.WriteLine(" ✓ excel_connection: LIST in batch"); - - // Additional worksheet for session testing - var createBatchSheetResult = ExcelWorksheetTool.ExcelWorksheet( - WorksheetAction.Create, - sessionId, - sheetName: "BatchTest"); - AssertSuccess(createBatchSheetResult, "Create additional worksheet in batch"); - - // PivotTable operations via session API - var createPivotResult = ExcelPivotTableTool.ExcelPivotTable( - PivotTableAction.CreateFromTable, - _testExcelFile, - sessionId, - tableName: "DataTable", - destinationSheet: "Data", - destinationCell: "E1", - pivotTableName: "SalesPivot"); - AssertSuccess(createPivotResult, "Create PivotTable in batch"); - - var listPivotsResult = ExcelPivotTableTool.ExcelPivotTable( - PivotTableAction.List, - _testExcelFile, - sessionId); - AssertSuccess(listPivotsResult, "List PivotTables in batch"); - - _output.WriteLine(" ✓ excel_pivottable: CREATE and LIST in batch"); - - // Chart operations via session API - var createChartResult = ExcelChartTool.ExcelChart( - ChartAction.CreateFromRange, - _testExcelFile, - sessionId, - sheetName: "Data", - sourceRange: "A1:C3", - chartType: ChartType.ColumnClustered, - left: 50, - top: 50, - width: 400, - height: 300, - chartName: "DataChart"); - AssertSuccess(createChartResult, "Create Chart in batch"); - - var listChartsResult = ExcelChartTool.ExcelChart( - ChartAction.List, - _testExcelFile, - sessionId); - // Chart List returns List directly (no success wrapper) - var chartsDoc = JsonDocument.Parse(listChartsResult); - if (chartsDoc.RootElement.ValueKind != JsonValueKind.Array) - Assert.Fail("List Charts should return JSON array"); - _output.WriteLine(" ✓ List Charts in batch succeeded"); - - _output.WriteLine(" ✓ excel_chart: CREATE and LIST in batch"); - - // Data Model operations via session API - var listDataModelResult = ExcelDataModelTool.ExcelDataModel( - DataModelAction.ListTables, - _testExcelFile, - sessionId); - AssertSuccess(listDataModelResult, "List Data Model tables in batch"); - - _output.WriteLine(" ✓ excel_datamodel: LIST TABLES in batch"); - - // VBA operations via session API - var listVbaResult = ExcelVbaTool.ExcelVba( - VbaAction.List, - _testExcelFile, - sessionId); - AssertSuccess(listVbaResult, "List VBA modules in batch"); - - _output.WriteLine(" ✓ excel_vba: LIST in session"); - - // ===================================================================== - // STEP 4: CLOSE SESSION (saves by default, persisting all changes) - // ===================================================================== - _output.WriteLine("\n✓ Step 4: Closing session (saving changes)..."); - - var closeResult = ExcelFileTool.ExcelFile(FileAction.Close, sessionId: sessionId, save: true); - AssertSuccess(closeResult, "Close session with save"); + var createQueryResult = await CallToolAsync("excel_powerquery", new Dictionary + { + ["action"] = "Create", + ["sessionId"] = sessionId, + ["queryName"] = "CsvData", + ["mCode"] = mCode, + ["loadDestination"] = "connection-only" + }); + AssertSuccess(createQueryResult, "Create Power Query"); + + var listQueriesResult = await CallToolAsync("excel_powerquery", new Dictionary + { + ["action"] = "List", + ["sessionId"] = sessionId + }); + AssertSuccess(listQueriesResult, "List Power Queries"); + _output.WriteLine(" ✓ excel_powerquery: Create and List passed"); + + // ===================================================================== + // STEP 8: CONNECTION OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 8: Connection operations..."); + + var listConnectionsResult = await CallToolAsync("excel_connection", new Dictionary + { + ["action"] = "List", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId + }); + AssertSuccess(listConnectionsResult, "List connections"); + _output.WriteLine(" ✓ excel_connection: List passed"); + + // ===================================================================== + // STEP 9: PIVOTTABLE OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 9: PivotTable operations..."); + + var createPivotResult = await CallToolAsync("excel_pivottable", new Dictionary + { + ["action"] = "CreateFromTable", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["tableName"] = "DataTable", + ["destinationSheet"] = "Data", + ["destinationCell"] = "E1", + ["pivotTableName"] = "SalesPivot" + }); + AssertSuccess(createPivotResult, "Create PivotTable"); + + var listPivotsResult = await CallToolAsync("excel_pivottable", new Dictionary + { + ["action"] = "List", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId + }); + AssertSuccess(listPivotsResult, "List PivotTables"); + _output.WriteLine(" ✓ excel_pivottable: Create and List passed"); + + // ===================================================================== + // STEP 10: CHART OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 10: Chart operations..."); + + var createChartResult = await CallToolAsync("excel_chart", new Dictionary + { + ["action"] = "CreateFromRange", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["sheetName"] = "Data", + ["sourceRange"] = "A1:C3", + ["chartType"] = "ColumnClustered", + ["left"] = 50, + ["top"] = 50, + ["width"] = 400, + ["height"] = 300, + ["chartName"] = "DataChart" + }); + AssertSuccess(createChartResult, "Create Chart"); + + var listChartsResult = await CallToolAsync("excel_chart", new Dictionary + { + ["action"] = "List", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId + }); + // Chart List returns array directly + Assert.NotNull(listChartsResult); + _output.WriteLine(" ✓ excel_chart: Create and List passed"); + + // ===================================================================== + // STEP 11: DATA MODEL OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 11: Data Model operations..."); + + var listDataModelResult = await CallToolAsync("excel_datamodel", new Dictionary + { + ["action"] = "ListTables", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId + }); + AssertSuccess(listDataModelResult, "List Data Model tables"); + _output.WriteLine(" ✓ excel_datamodel: ListTables passed"); + + // ===================================================================== + // STEP 12: CONDITIONAL FORMAT OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 12: Conditional Format operations..."); + + var addRuleResult = await CallToolAsync("excel_conditionalformat", new Dictionary + { + ["action"] = "AddRule", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId, + ["sheetName"] = "Data", + ["rangeAddress"] = "B2:B3", + ["ruleType"] = "cellvalue", // Note: no hyphen - Core expects "cellvalue" not "cell-value" + ["operatorType"] = "greater", + ["formula1"] = "100", + ["interiorColor"] = "#00FF00" + }); + AssertSuccess(addRuleResult, "Add conditional format rule"); + _output.WriteLine(" ✓ excel_conditionalformat: AddRule passed"); + + // ===================================================================== + // STEP 13: VBA OPERATIONS + // ===================================================================== + _output.WriteLine("\n✓ Step 13: VBA operations..."); + + var listVbaResult = await CallToolAsync("excel_vba", new Dictionary + { + ["action"] = "List", + ["excelPath"] = _testExcelFile, + ["sessionId"] = sessionId + }); + AssertSuccess(listVbaResult, "List VBA modules"); + _output.WriteLine(" ✓ excel_vba: List passed"); + + // ===================================================================== + // STEP 14: CLOSE SESSION (save changes) + // ===================================================================== + _output.WriteLine("\n✓ Step 14: Closing session (saving changes)..."); + + var closeResult = await CallToolAsync("excel_file", new Dictionary + { + ["action"] = "Close", + ["sessionId"] = sessionId, + ["save"] = true + }); + AssertSuccess(closeResult, "Close session"); _output.WriteLine(" ✓ Session saved and closed"); // ===================================================================== - // STEP 5: VERIFY OPERATIONS AFTER SESSION (persistence check) + // STEP 15: VERIFY PERSISTENCE // ===================================================================== - _output.WriteLine("\n✓ Step 5: Verifying persistence after session close..."); + _output.WriteLine("\n✓ Step 15: Verifying persistence..."); - // Verify worksheets were created and saved via a fresh session - var verifyOpenResult = ExcelFileTool.ExcelFile(FileAction.Open, _testExcelFile); - AssertSuccess(verifyOpenResult, "Re-open session for verification"); - var verifySessionJson = JsonDocument.Parse(verifyOpenResult); - var verifySessionId = verifySessionJson.RootElement.GetProperty("sessionId").GetString(); - Assert.False(string.IsNullOrEmpty(verifySessionId), "Verification session should be created"); + var verifyOpenResult = await CallToolAsync("excel_file", new Dictionary + { + ["action"] = "Open", + ["excelPath"] = _testExcelFile + }); + AssertSuccess(verifyOpenResult, "Re-open for verification"); + var verifySessionId = GetJsonProperty(verifyOpenResult, "sessionId"); try { - var finalSheetsResult = ExcelWorksheetTool.ExcelWorksheet(WorksheetAction.List, verifySessionId!); + var finalSheetsResult = await CallToolAsync("excel_worksheet", new Dictionary + { + ["action"] = "List", + ["sessionId"] = verifySessionId + }); AssertSuccess(finalSheetsResult, "Final worksheet list"); - var sheetsJson = JsonDocument.Parse(finalSheetsResult); - var worksheets = sheetsJson.RootElement.GetProperty("worksheets").EnumerateArray(); - var sheetNames = worksheets.Select(w => w.GetProperty("name").GetString()).ToList(); - - Assert.Contains("Data", sheetNames); - Assert.Contains("BatchTest", sheetNames); - - // Verify data was saved - var finalDataResult = ExcelRangeTool.ExcelRange( - RangeAction.GetValues, - _testExcelFile, - verifySessionId!, - sheetName: "Data", - rangeAddress: "A1:C3"); - AssertSuccess(finalDataResult, "Final data verification"); + + // Verify Data sheet exists + Assert.Contains("Data", finalSheetsResult); + _output.WriteLine(" ✓ All changes persisted correctly"); } finally { - if (!string.IsNullOrEmpty(verifySessionId)) + await CallToolAsync("excel_file", new Dictionary { - ExcelFileTool.ExcelFile(FileAction.Close, sessionId: verifySessionId); - } + ["action"] = "Close", + ["sessionId"] = verifySessionId, + ["save"] = false + }); } - _output.WriteLine(" ✓ All changes persisted correctly"); - // ===================================================================== - // FINAL VERIFICATION + // FINAL SUMMARY // ===================================================================== - _output.WriteLine("\n=== BATCH MODE SMOKE TEST COMPLETE ==="); - _output.WriteLine("✅ All 13 MCP tools tested successfully in BATCH MODE"); - _output.WriteLine("✅ Batch workflow: BEGIN → 15+ operations → COMMIT"); - _output.WriteLine("✅ Performance optimized: 75-90% faster than individual operations"); - _output.WriteLine("✅ Data persistence verified after batch commit"); - _output.WriteLine("✅ Demonstrates proper LLM batch mode usage pattern"); - _output.WriteLine("\n🚀 MCP Server batch functionality is working perfectly!"); + _output.WriteLine("\n=== E2E SMOKE TEST COMPLETE ==="); + _output.WriteLine("✅ All 12 MCP tools tested via SDK client"); + _output.WriteLine("✅ Full MCP protocol stack validated"); + _output.WriteLine("✅ DI pipeline exercised (same as Program.cs)"); + _output.WriteLine("✅ Real Excel operations verified"); + _output.WriteLine("✅ Data persistence confirmed"); + _output.WriteLine("\n🚀 MCP Server E2E functionality working correctly!"); } /// - /// Helper method to assert operation success and provide clear error messages. + /// Tests that invalid actions return helpful error messages via MCP protocol. + /// + [Fact] + public async Task InvalidSession_ReturnsHelpfulErrorMessage() + { + _output.WriteLine("Testing error handling via MCP protocol..."); + + var result = await CallToolAsync("excel_file", new Dictionary + { + ["action"] = "Close", + ["sessionId"] = "nonexistent-session-id" + }); + + _output.WriteLine($"Result: {result[..Math.Min(300, result.Length)]}..."); + + // Should have success=false + var json = JsonDocument.Parse(result); + Assert.True(json.RootElement.TryGetProperty("success", out var success)); + Assert.False(success.GetBoolean()); + + // Should have helpful error message + Assert.True(json.RootElement.TryGetProperty("errorMessage", out var errorMessage)); + var errorText = errorMessage.GetString(); + Assert.NotNull(errorText); + Assert.Contains("not found", errorText, StringComparison.OrdinalIgnoreCase); + + _output.WriteLine("✓ Error message is clear and helpful via MCP protocol"); + } + + /// + /// Calls a tool via the MCP protocol and returns the text response. + /// + private async Task CallToolAsync(string toolName, Dictionary arguments) + { + var result = await _client!.CallToolAsync(toolName, arguments, cancellationToken: _cts.Token); + + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + var textBlock = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textBlock); + + return textBlock.Text; + } + + /// + /// Asserts the JSON response indicates success. /// private void AssertSuccess(string jsonResult, string operationName) { @@ -370,17 +579,17 @@ private void AssertSuccess(string jsonResult, string operationName) { var json = JsonDocument.Parse(jsonResult); - // Check for MCP error format + // Check for error property if (json.RootElement.TryGetProperty("error", out var error)) { var errorMsg = error.GetString(); Assert.Fail($"{operationName} failed with error: {errorMsg}"); } - // Check for Success property (most operations) - if (json.RootElement.TryGetProperty("Success", out var success)) + // Check for Success property (PascalCase) + if (json.RootElement.TryGetProperty("Success", out var successPascal)) { - if (!success.GetBoolean()) + if (!successPascal.GetBoolean()) { var errorMsg = json.RootElement.TryGetProperty("ErrorMessage", out var errProp) ? errProp.GetString() @@ -388,10 +597,10 @@ private void AssertSuccess(string jsonResult, string operationName) Assert.Fail($"{operationName} returned Success=false: {errorMsg}"); } } - // Check for success property (batch operations) - else if (json.RootElement.TryGetProperty("success", out var successLower)) + // Check for success property (camelCase) + else if (json.RootElement.TryGetProperty("success", out var successCamel)) { - if (!successLower.GetBoolean()) + if (!successCamel.GetBoolean()) { var errorMsg = json.RootElement.TryGetProperty("errorMessage", out var errProp) ? errProp.GetString() @@ -399,8 +608,6 @@ private void AssertSuccess(string jsonResult, string operationName) Assert.Fail($"{operationName} returned success=false: {errorMsg}"); } } - - _output.WriteLine($" ✓ {operationName} succeeded"); } catch (JsonException ex) { @@ -409,39 +616,11 @@ private void AssertSuccess(string jsonResult, string operationName) } /// - /// Regression test for improved error message when invalid action is provided. - /// GitHub Issue: User received unhelpful "An error occurred invoking 'excel_file'" when using action='Save'. - /// Expected: Clear error message listing valid actions and explaining correct save workflow. + /// Gets a string property from a JSON response. /// - [Fact] - public void InvalidAction_Save_ReturnsHelpfulErrorMessage() + private static string? GetJsonProperty(string jsonResult, string propertyName) { - _output.WriteLine("Testing error message for invalid action 'Save'..."); - - // Attempt to use non-existent 'Save' action - // Note: We can't pass an invalid enum value directly, so this test verifies the catch block - // by checking the tool's behavior when sessionId doesn't exist (similar error path) - var result = ExcelFileTool.ExcelFile(FileAction.Close, sessionId: "nonexistent-session-id"); - - _output.WriteLine($"Result: {result}"); - - // Parse the JSON result - var json = JsonDocument.Parse(result); - - // Should have success=false - Assert.True(json.RootElement.TryGetProperty("success", out var success)); - Assert.False(success.GetBoolean()); - - // Should have isError=true - Assert.True(json.RootElement.TryGetProperty("isError", out var isError)); - Assert.True(isError.GetBoolean()); - - // Should have helpful error message - Assert.True(json.RootElement.TryGetProperty("errorMessage", out var errorMessage)); - var errorText = errorMessage.GetString(); - Assert.NotNull(errorText); - Assert.Contains("not found", errorText, StringComparison.OrdinalIgnoreCase); - - _output.WriteLine("✓ Error message is clear and helpful"); + var json = JsonDocument.Parse(jsonResult); + return json.RootElement.TryGetProperty(propertyName, out var prop) ? prop.GetString() : null; } } diff --git a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs index d0559f11..31b7ccee 100644 --- a/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs +++ b/tests/ExcelMcp.McpServer.Tests/Integration/Tools/TelemetryIntegrationTests.cs @@ -16,26 +16,19 @@ namespace Sbroenne.ExcelMcp.McpServer.Tests.Integration.Tools; [Trait("Speed", "Fast")] [Trait("Layer", "McpServer")] [Trait("Feature", "Telemetry")] -public class TelemetryIntegrationTests +public class TelemetryIntegrationTests(ITestOutputHelper output) { - private readonly ITestOutputHelper _output; - - public TelemetryIntegrationTests(ITestOutputHelper output) - { - _output = output; - } - [Fact] public void TelemetryConfiguration_HasStableUserAndSessionIds() { - _output.WriteLine("=== TELEMETRY CONFIGURATION TEST ===\n"); + output.WriteLine("=== TELEMETRY CONFIGURATION TEST ===\n"); // Get user and session IDs var userId = ExcelMcpTelemetry.UserId; var sessionId = ExcelMcpTelemetry.SessionId; - _output.WriteLine($"User ID: {userId}"); - _output.WriteLine($"Session ID: {sessionId}"); + output.WriteLine($"User ID: {userId}"); + output.WriteLine($"Session ID: {sessionId}"); // Assert - user ID should be stable (16 hex chars from SHA256) Assert.NotNull(userId); @@ -58,8 +51,8 @@ public void SensitiveDataRedactor_RedactsFilePaths() var input = "Error loading file C:\\Users\\John\\Documents\\secret.xlsx"; var redacted = SensitiveDataRedactor.RedactSensitiveData(input); - _output.WriteLine($"Input: {input}"); - _output.WriteLine($"Redacted: {redacted}"); + output.WriteLine($"Input: {input}"); + output.WriteLine($"Redacted: {redacted}"); Assert.DoesNotContain("C:\\", redacted); Assert.Contains("[REDACTED_PATH]", redacted); @@ -71,8 +64,8 @@ public void SensitiveDataRedactor_RedactsConnectionStrings() var input = "Connection: Server=myserver;Password=secret123;User=admin"; var redacted = SensitiveDataRedactor.RedactSensitiveData(input); - _output.WriteLine($"Input: {input}"); - _output.WriteLine($"Redacted: {redacted}"); + output.WriteLine($"Input: {input}"); + output.WriteLine($"Redacted: {redacted}"); Assert.DoesNotContain("secret123", redacted); Assert.Contains("[REDACTED]", redacted); @@ -84,8 +77,8 @@ public void SensitiveDataRedactor_RedactsEmailAddresses() var input = "Contact john.doe@example.com for support"; var redacted = SensitiveDataRedactor.RedactSensitiveData(input); - _output.WriteLine($"Input: {input}"); - _output.WriteLine($"Redacted: {redacted}"); + output.WriteLine($"Input: {input}"); + output.WriteLine($"Redacted: {redacted}"); Assert.DoesNotContain("john.doe@example.com", redacted); Assert.Contains("[REDACTED_EMAIL]", redacted); @@ -97,8 +90,8 @@ public void SensitiveDataRedactor_RedactsExceptions() var exception = new InvalidOperationException("Failed to read C:\\Users\\Admin\\data.xlsx"); var (type, message, _) = SensitiveDataRedactor.RedactException(exception); - _output.WriteLine($"Exception Type: {type}"); - _output.WriteLine($"Redacted Message: {message}"); + output.WriteLine($"Exception Type: {type}"); + output.WriteLine($"Redacted Message: {message}"); Assert.Equal("InvalidOperationException", type); Assert.DoesNotContain("C:\\", message); @@ -108,7 +101,7 @@ public void SensitiveDataRedactor_RedactsExceptions() [Fact] public void ToolInvocation_ExecutesWithTelemetry() { - _output.WriteLine("=== TOOL INVOCATION TEST ===\n"); + output.WriteLine("=== TOOL INVOCATION TEST ===\n"); // Act - call a tool method that uses ExecuteToolAction // Using Test action since it doesn't require an actual file @@ -117,7 +110,7 @@ public void ToolInvocation_ExecutesWithTelemetry() excelPath: "C:\\fake\\test.xlsx", sessionId: null); - _output.WriteLine($"Tool result: {result[..Math.Min(200, result.Length)]}...\n"); + output.WriteLine($"Tool result: {result[..Math.Min(200, result.Length)]}...\n"); // Assert - tool executed (telemetry is tracked internally) Assert.NotNull(result); diff --git a/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs b/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs index 56dc8b49..b4a96c0e 100644 --- a/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs +++ b/tests/ExcelMcp.McpServer.Tests/Unit/TelemetryTests.cs @@ -67,18 +67,6 @@ public void GetConnectionString_ReturnsNullForPlaceholder() } } - [Fact] - public void OptOutEnvironmentVariable_HasCorrectName() - { - Assert.Equal("EXCELMCP_TELEMETRY_OPTOUT", ExcelMcpTelemetry.OptOutEnvironmentVariable); - } - - [Fact] - public void DebugTelemetryEnvironmentVariable_HasCorrectName() - { - Assert.Equal("EXCELMCP_DEBUG_TELEMETRY", ExcelMcpTelemetry.DebugTelemetryEnvironmentVariable); - } - #endregion #region SensitiveDataRedactor Tests diff --git a/tests/ExcelMcp.McpServer.Tests/Unit/ToolDiscoveryTests.cs b/tests/ExcelMcp.McpServer.Tests/Unit/ToolDiscoveryTests.cs deleted file mode 100644 index d0f7d771..00000000 --- a/tests/ExcelMcp.McpServer.Tests/Unit/ToolDiscoveryTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Reflection; -using ModelContextProtocol.Server; -using Sbroenne.ExcelMcp.McpServer.Tools; -using Xunit; - -namespace Sbroenne.ExcelMcp.McpServer.Tests.Unit; - -/// -/// Tests to verify that all MCP tools are properly decorated with required attributes -/// for discovery by the MCP SDK's WithToolsFromAssembly() method. -/// -[Trait("Category", "Unit")] -[Trait("Speed", "Fast")] -[Trait("Layer", "McpServer")] -[Trait("Feature", "ToolDiscovery")] -public class ToolDiscoveryTests -{ - [Fact] - public void ExcelPivotTableTool_HasMcpServerToolTypeAttribute() - { - // Arrange - var toolType = typeof(ExcelPivotTableTool); - - // Act - var attribute = toolType.GetCustomAttribute(); - - // Assert - Assert.NotNull(attribute); - } - - [Fact] - public void ExcelPivotTableTool_HasMcpServerToolAttributeWithName() - { - // Arrange - var toolType = typeof(ExcelPivotTableTool); - var method = toolType.GetMethod("ExcelPivotTable", BindingFlags.Public | BindingFlags.Static); - Assert.NotNull(method); - - // Act - var attribute = method!.GetCustomAttribute(); - - // Assert - Assert.NotNull(attribute); - Assert.Equal("excel_pivottable", attribute!.Name); - } - - [Theory] - [InlineData(typeof(ExcelChartTool), "ExcelChart", "excel_chart")] - [InlineData(typeof(ExcelConnectionTool), "ExcelConnection", "excel_connection")] - [InlineData(typeof(ExcelDataModelTool), "ExcelDataModel", "excel_datamodel")] - [InlineData(typeof(ExcelFileTool), "ExcelFile", "excel_file")] - [InlineData(typeof(ExcelNamedRangeTool), "ExcelParameter", "excel_namedrange")] - [InlineData(typeof(ExcelPivotTableTool), "ExcelPivotTable", "excel_pivottable")] - [InlineData(typeof(ExcelPowerQueryTool), "ExcelPowerQuery", "excel_powerquery")] - [InlineData(typeof(ExcelRangeTool), "ExcelRange", "excel_range")] - [InlineData(typeof(TableTool), "Table", "excel_table")] - [InlineData(typeof(ExcelVbaTool), "ExcelVba", "excel_vba")] - [InlineData(typeof(ExcelWorksheetTool), "ExcelWorksheet", "excel_worksheet")] - public void AllTools_HaveMcpServerToolAttributeWithCorrectName(Type toolType, string methodName, string expectedToolName) - { - // Arrange - var method = toolType.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static); - Assert.NotNull(method); - - // Act - var attribute = method!.GetCustomAttribute(); - - // Assert - Assert.NotNull(attribute); - Assert.Equal(expectedToolName, attribute!.Name); - } - - [Theory] - [InlineData(typeof(ExcelChartTool))] - [InlineData(typeof(ExcelConnectionTool))] - [InlineData(typeof(ExcelDataModelTool))] - [InlineData(typeof(ExcelFileTool))] - [InlineData(typeof(ExcelNamedRangeTool))] - [InlineData(typeof(ExcelPivotTableTool))] - [InlineData(typeof(ExcelPowerQueryTool))] - [InlineData(typeof(ExcelRangeTool))] - [InlineData(typeof(TableTool))] - [InlineData(typeof(ExcelVbaTool))] - [InlineData(typeof(ExcelWorksheetTool))] - public void AllTools_HaveMcpServerToolTypeAttribute(Type toolType) - { - // Act - var attribute = toolType.GetCustomAttribute(); - - // Assert - Assert.NotNull(attribute); - } - - /// - /// Tests that all expected tools are discoverable via assembly scanning, - /// simulating what the MCP SDK's WithToolsFromAssembly() does. - /// This catches issues like partial classes that prevent runtime discovery. - /// - [Fact] - public void AssemblyScan_DiscoversAllExpectedTools() - { - // Arrange - Expected tool names that should be discoverable - var expectedToolNames = new HashSet - { - "excel_chart", - "excel_conditionalformat", - "excel_connection", - "excel_datamodel", - "excel_file", - "excel_namedrange", - "excel_pivottable", - "excel_powerquery", - "excel_range", - "excel_table", - "excel_vba", - "excel_worksheet" - }; - // Act - Scan assembly for tool types (simulating MCP SDK behavior) - var assembly = typeof(ExcelPivotTableTool).Assembly; - var toolTypes = assembly.GetTypes() - .Where(t => t.GetCustomAttribute() != null) - .ToList(); - - // Extract tool names from discovered types - var discoveredToolNames = new HashSet(); - foreach (var toolType in toolTypes) - { - var methods = toolType.GetMethods(BindingFlags.Public | BindingFlags.Static); - foreach (var method in methods) - { - var toolAttr = method.GetCustomAttribute(); - if (toolAttr?.Name != null) - { - discoveredToolNames.Add(toolAttr.Name); - } - } - } - - // Assert - All expected tools must be discovered - Assert.Equal(expectedToolNames.Count, discoveredToolNames.Count); - - var missingTools = expectedToolNames.Except(discoveredToolNames).ToList(); - Assert.Empty(missingTools); // This would have failed before the fix! - - var unexpectedTools = discoveredToolNames.Except(expectedToolNames).ToList(); - Assert.Empty(unexpectedTools); - } -} diff --git a/tests/test.runsettings b/tests/test.runsettings deleted file mode 100644 index e1321f60..00000000 --- a/tests/test.runsettings +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - true - - -