Add table-creation range preflight (#844)

* Add table creation preflight

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix: preserve sessions during stale cleanup

Wait for the tracked daemon to finish graceful shutdown even when its reply is lost, so pre-build cleanup cannot interrupt session auto-save.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix: preserve sessions during stale cleanup

Wait for the tracked daemon to finish graceful shutdown even when its reply is lost, so pre-build cleanup cannot interrupt session auto-save.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix quoted formula reference detection

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: ea1cae23-233e-46ce-9cf7-e22fa9f5ac8b

* fix: await tracked Excel shutdown

Treat graceful cleanup as complete only after the exact daemon generation and its tracked Excel processes exit, while retaining the existing bounded forced fallback.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b07d5dc3-38d8-4d20-9f07-122e079dd612

* fix: stabilize MCPB staging cleanup

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9b9eefe6-dd82-44e6-abdc-98202899cf32

* Bound merged-range discovery scans

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Bound table preflight heuristics

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Stefan Broenner <stbrnner@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea1cae23-233e-46ce-9cf7-e22fa9f5ac8b
Copilot-Session: b07d5dc3-38d8-4d20-9f07-122e079dd612
Copilot-Session: 9b9eefe6-dd82-44e6-abdc-98202899cf32
This commit is contained in:
Stefan Broenner
2026-08-31 17:13:31 +02:00
committed by GitHub
parent d2c993248c
commit 09694d8afe
35 changed files with 1206 additions and 133 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"excelmcp": minor
---
**Safer table creation** (#838): Preview merged cells, header problems, nearby excluded columns, formula sorting risks, and the effective table range before creating a table. Table creation now blocks deterministic problems while leaving uncertain warnings for review. Large-range merge discovery is bounded, and formula risk analysis reports when an oversized range was skipped.
+1 -1
View File
@@ -68,7 +68,7 @@ dotnet tool install --global Sbroenne.ExcelMcp.CLI
## What You Can Do
**31 feature command categories with 325 operations** for comprehensive Excel automation:
**31 feature command categories with 326 operations** for comprehensive Excel automation:
- **Power Query** (12 ops) — Create, update, refresh queries; M code management
- **Data Model/DAX** (20 ops) — Measures, relationships, source metadata, EVALUATE queries
+1 -1
View File
@@ -58,7 +58,7 @@ pwsh -ExecutionPolicy Bypass -File `
## What You Can Do
**31 specialized tools with 325 operations** for comprehensive Excel automation:
**31 specialized tools with 326 operations** for comprehensive Excel automation:
### Core Operations
+1 -1
View File
@@ -1,6 +1,6 @@
# ExcelMcp - Complete Feature Reference
**31 specialized tools with 325 operations for comprehensive Excel automation**
**31 specialized tools with 326 operations for comprehensive Excel automation**
Excel MCP Server automates the real Microsoft Excel application through four focused capability areas. Start with the category that matches your goal, or use the quick reference below.
+2 -2
View File
@@ -26,7 +26,7 @@ its official COM API. It can refresh Power Query, recalculate formulas, evaluate
DAX, run VBA and Python `=PY()`, and preserve PivotTables, charts, macros, the
Data Model, and workbook formatting.
**31 tools with 325 operations** cover end-to-end Excel automation.
**31 tools with 326 operations** cover end-to-end Excel automation.
> [!IMPORTANT]
> Requires **Windows**, **Microsoft Excel 2016 or later**, and an interactive
@@ -55,7 +55,7 @@ while automating them.
- **[Automation & advanced](https://excelmcpserver.dev/features/automation-advanced/):**
VBA, Python in Excel, Goal Seek, scenarios, data tables, windows, and XML Maps.
Explore the [complete reference for all 325 operations](https://excelmcpserver.dev/features/).
Explore the [complete reference for all 326 operations](https://excelmcpserver.dev/features/).
## See It in Action
+2 -2
View File
@@ -6,7 +6,7 @@ This document outlines how the Excel MCP Server and Excel CLI are distributed as
ExcelMcp is published as **two complementary plugins** in the GitHub Copilot plugin marketplace:
- **`excel-mcp`** — MCP Server with 31 tools (325 operations) for conversational AI (Claude Desktop, Copilot chat)
- **`excel-mcp`** — MCP Server with 31 tools (326 operations) for conversational AI (Claude Desktop, Copilot chat)
- **`excel-cli`** — CLI-only skill for coding agents (token-efficient, `--help` discoverable)
Both plugins are maintained in a separate published repository and auto-synced from this source repo.
@@ -67,7 +67,7 @@ copilot plugin install excel-cli@mcp-server-excel-plugins
### Excel MCP Plugin
Provides the full MCP Server with 31 tools (325 operations) for conversational AI:
Provides the full MCP Server with 31 tools (326 operations) for conversational AI:
```powershell
copilot plugin install excel-mcp@mcp-server-excel-plugins
+2 -1
View File
@@ -83,13 +83,14 @@ Build a Power Pivot Data Model — manage tables, DAX measures, and relationship
---
## 📇 Excel Tables (ListObjects) (27 operations)
## 📇 Excel Tables (ListObjects) (28 operations)
Create and manage Excel Tables (ListObjects) — structured ranges with styling, filtering, and sorting.
**Lifecycle:**
- **List:** List Excel Tables in a worksheet or workbook
- **Read:** Get table structure (columns, range, style)
- **Preflight:** Non-destructively check the effective range, merged cells, blank or duplicate headers, excluded contiguous columns, and formulas that may be unsafe to sort. Deterministic problems block creation; uncertain adjacency and formula findings are warnings. Ranges over 100,000 cells return an explicit warning that formula risk analysis was skipped.
- **Create:** Create a new Excel Table from a range
- **Rename:** Rename an existing table
- **Resize:** Resize table range to match new data bounds
+1 -1
View File
@@ -31,7 +31,7 @@ possible - you don't need to memorize it.
### CLI or MCP Server - which should I install?
Both expose the **same 325 operations**. Use the **MCP Server** for
Both expose the **same 326 operations**. Use the **MCP Server** for
conversational AI (Claude Desktop, VS Code Chat); use the **CLI** (`excelcli`)
for coding agents and scripting, where it uses ~64% fewer tokens. You can
install both. See [Installation](installation.md).
+2 -2
View File
@@ -1,6 +1,6 @@
---
title: Excel Automation Features
description: Explore 31 Excel automation tools and 325 operations for Power Query, DAX, PivotTables, charts, formulas, VBA, and more.
description: Explore 31 Excel automation tools and 326 operations for Power Query, DAX, PivotTables, charts, formulas, VBA, and more.
keywords: "Excel automation features, Excel MCP tools, Power Query automation, DAX, PivotTables, VBA, Excel AI"
---
@@ -11,7 +11,7 @@ keywords: "Excel automation features, Excel MCP tools, Power Query automation, D
<figcaption>A PivotTable — revenue by region and quarter with grand totals — created in the real Excel application from a plain-language request.</figcaption>
</figure>
Excel MCP Server provides **31 specialized tools and 325 operations** for
Excel MCP Server provides **31 specialized tools and 326 operations** for
automating the real Microsoft Excel application. You can ask your AI assistant
in plain language—the reference pages below are for discovering what is
possible and looking up individual operations.
+1 -1
View File
@@ -132,7 +132,7 @@ hide:
</div>
[See all 31 tools and 325 operations :material-arrow-right:](features.md){ .md-button .md-button--primary }
[See all 31 tools and 326 operations :material-arrow-right:](features.md){ .md-button .md-button--primary }
## Popular guides
+1 -1
View File
@@ -13,7 +13,7 @@ Excel MCP Server lets you automate Excel through conversation with Claude:
- **Automate** - VBA macros, batch operations, data refresh
- **Agent Mode** - Say "show me Excel" and watch AI work in real-time, side-by-side with Claude
**31 tools with 325 operations** for comprehensive Excel automation.
**31 tools with 326 operations** for comprehensive Excel automation.
## Requirements
+1 -1
View File
@@ -4,7 +4,7 @@
"display_name": "Excel (Windows)",
"version": "2.0.5",
"description": "Manage Sheets, Power Query, DAX, VBA, PowerPivot, Tables, Ranges, Charts, Formatting, Validation & more - requires Excel to be installed",
"long_description": "Automate the real Microsoft Excel application from Claude. 31 specialized tools with 325 operations for Power Query, DAX and the Data Model, VBA, PivotTables, Charts, Conditional Formatting, and more. Unlike file-parser libraries, it drives Excel through its official COM API, so PivotTables, macros, charts, the Data Model and workbook formatting are preserved. Windows-only, requires Microsoft Excel desktop application (2016 or later).",
"long_description": "Automate the real Microsoft Excel application from Claude. 31 specialized tools with 326 operations for Power Query, DAX and the Data Model, VBA, PivotTables, Charts, Conditional Formatting, and more. Unlike file-parser libraries, it drives Excel through its official COM API, so PivotTables, macros, charts, the Data Model and workbook formatting are preserved. Windows-only, requires Microsoft Excel desktop application (2016 or later).",
"author": {
"name": "Stefan Broenner",
"url": "https://github.com/sbroenne"
+1 -1
View File
@@ -12,7 +12,7 @@ compatibility: Requires Windows, Microsoft Excel 2016 or later, and network acce
# Excel MCP Server Skill
Provides 325 Excel operations via Model Context Protocol. The MCP Server hosts the ExcelMCP Service in-process and calls it directly for low-latency Excel automation. Tools are auto-discovered - this documents quirks, workflows, and gotchas.
Provides 326 Excel operations via Model Context Protocol. The MCP Server hosts the ExcelMCP Service in-process and calls it directly for low-latency Excel automation. Tools are auto-discovered - this documents quirks, workflows, and gotchas.
## Workflow Checklist
+3 -1
View File
@@ -5,13 +5,14 @@
Excel Tables on worksheets are NOT automatically in the Data Model (Power Pivot).
To analyze worksheet data with DAX measures:
1. Ensure data is formatted as an Excel Table (use create action if needed)
1. Ensure data is formatted as an Excel Table (use preflight, then create if needed)
2. Use `add-to-data-model` action to add the table to Power Pivot
3. Then use `datamodel` to create DAX measures on it
**Action disambiguation**:
- create: Create NEW table from a range (requires `sheet_name`, `table_name`, and `range_address`). Pass `table_style` here to style at creation time.
- preflight: Check a proposed table without changing the workbook. It returns the effective range, typed findings, and `safeToCreate`. Merged cells plus blank or duplicate headers are blockers. Excluded contiguous columns and formulas that may be unsafe to sort are heuristic warnings. For ranges over 100,000 cells, it returns a `FormulaScanSkipped` warning instead of allocating the full formula matrix.
- read: Get table metadata (range, columns, style, row counts)
- get-data: Get actual table DATA as 2D array (use `visible_only=true` for filtered data)
- rename: Rename an existing table
@@ -83,6 +84,7 @@ Example DAX queries for create-from-dax:
- Using datamodel to add tables (it only manages existing Data Model tables)
- Confusing get-data (returns cell values) with read (returns metadata)
- Forgetting `has_headers` when creating tables from headerless data
- Skipping preflight when warnings about excluded columns or formula sorting need human review. Create always enforces deterministic blockers, but warnings do not block it.
**Server-specific quirks**:
+3 -1
View File
@@ -5,13 +5,14 @@
Excel Tables on worksheets are NOT automatically in the Data Model (Power Pivot).
To analyze worksheet data with DAX measures:
1. Ensure data is formatted as an Excel Table (use create action if needed)
1. Ensure data is formatted as an Excel Table (use preflight, then create if needed)
2. Use `add-to-data-model` action to add the table to Power Pivot
3. Then use `datamodel` to create DAX measures on it
**Action disambiguation**:
- create: Create NEW table from a range (requires `sheet_name`, `table_name`, and `range_address`). Pass `table_style` here to style at creation time.
- preflight: Check a proposed table without changing the workbook. It returns the effective range, typed findings, and `safeToCreate`. Merged cells plus blank or duplicate headers are blockers. Excluded contiguous columns and formulas that may be unsafe to sort are heuristic warnings. For ranges over 100,000 cells, it returns a `FormulaScanSkipped` warning instead of allocating the full formula matrix.
- read: Get table metadata (range, columns, style, row counts)
- get-data: Get actual table DATA as 2D array (use `visible_only=true` for filtered data)
- rename: Rename an existing table
@@ -83,6 +84,7 @@ Example DAX queries for create-from-dax:
- Using datamodel to add tables (it only manages existing Data Model tables)
- Confusing get-data (returns cell values) with read (returns metadata)
- Forgetting `has_headers` when creating tables from headerless data
- Skipping preflight when warnings about excluded columns or formula sorting need human review. Create always enforces deterministic blockers, but warnings do not block it.
**Server-specific quirks**:
+1 -1
View File
@@ -24,7 +24,7 @@
<!-- NuGet Package Configuration (secondary distribution — primary is standalone exe) -->
<PackageId>Sbroenne.ExcelMcp.CLI</PackageId>
<Title>ExcelMcp CLI</Title>
<Description>Command-line interface tool for automating Microsoft Excel operations using COM interop by Sbroenne. 325 operations across Power Query M code, Power Pivot DAX measures, VBA macros, PivotTables, Excel Tables, ranges, formatting, data validation, and connections. Perfect for RPA, CI/CD pipelines, scripting (PowerShell/Bash), and batch processing. Windows x64 and ARM64 support.</Description>
<Description>Command-line interface tool for automating Microsoft Excel operations using COM interop by Sbroenne. 326 operations across Power Query M code, Power Pivot DAX measures, VBA macros, PivotTables, Excel Tables, ranges, formatting, data validation, and connections. Perfect for RPA, CI/CD pipelines, scripting (PowerShell/Bash), and batch processing. Windows x64 and ARM64 support.</Description>
<PackageTags>excel;cli;powerquery;dax;power-pivot;automation;com;rpa;ci-cd;scripting;github-copilot;mcp;windows;dotnet-tool;sbroenne;excel-automation;vba;pivottables;excel-tables;batch-processing;devops</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>See https://github.com/sbroenne/mcp-server-excel/releases for release notes</PackageReleaseNotes>
+2 -2
View File
@@ -10,7 +10,7 @@
> **Primary distribution: Standalone executable** — Download `excelcli.exe` from the [latest release](https://github.com/sbroenne/mcp-server-excel/releases/latest). No .NET runtime required.
> **Secondary distribution: NuGet .NET tool**`dotnet tool install --global Sbroenne.ExcelMcp.CLI` (requires .NET 10 runtime).
The CLI provides 31 feature command categories with 325 operations matching the MCP Server, plus `session`, `service`, and `batch` commands — the same capabilities without loading 31 tool schemas into context.
The CLI provides 31 feature command categories with 326 operations matching the MCP Server, plus `session`, `service`, and `batch` commands — the same capabilities without loading 31 tool schemas into context.
| Interface | Best For | Why |
|-----------|----------|-----|
@@ -48,7 +48,7 @@ dotnet tool install --global Sbroenne.ExcelMcp.CLI
## 📋 What You Can Do
ExcelMcp.CLI provides **325 operations** across 31 feature command categories including Power Query, Python in Excel, Data Model/DAX, What-If Analysis, PivotTables, Excel Tables, Charts, Drawings, VBA, Ranges, Worksheets, Workbooks, QueryTables, XML Maps, Connections, and Window Management.
ExcelMcp.CLI provides **326 operations** across 31 feature command categories including Power Query, Python in Excel, Data Model/DAX, What-If Analysis, PivotTables, Excel Tables, Charts, Drawings, VBA, Ranges, Worksheets, Workbooks, QueryTables, XML Maps, Connections, and Window Management.
Drives the **actual Excel application** via COM — not a file-format parser — so live operations (Power Query refresh, recalculation, DAX evaluation, VBA execution) run for real and existing workbooks stay intact.
@@ -1,4 +1,3 @@
using System.Globalization;
using Sbroenne.ExcelMcp.ComInterop;
using Sbroenne.ExcelMcp.ComInterop.Session;
using Sbroenne.ExcelMcp.Core.Models;
@@ -100,10 +99,10 @@ public partial class RangeCommands
range = sheet.Range[rangeAddress];
object? mergeCells = range.MergeCells;
bool? isMergedState = GetMergeCellsState(mergeCells);
bool? isMergedState = RangeMergeDiscovery.GetMergeCellsState(mergeCells);
IReadOnlyList<string> mergedRanges = isMergedState == false
? []
: CollectMergedRanges(range, ct);
: RangeMergeDiscovery.CollectMergedRanges(range, isMergedState, ct);
return new RangeMergeInfoResult
{
@@ -123,65 +122,6 @@ public partial class RangeCommands
});
}
private static bool? GetMergeCellsState(object? mergeCells)
{
if (mergeCells is null || mergeCells == DBNull.Value)
{
return null;
}
return Convert.ToBoolean(mergeCells, CultureInfo.InvariantCulture);
}
private static List<string> CollectMergedRanges(dynamic range, CancellationToken cancellationToken)
{
dynamic? cells = null;
var mergedRanges = new List<string>();
var seenRanges = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
cells = range.Cells;
int cellCount = Convert.ToInt32(cells.Count);
for (int i = 1; i <= cellCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
dynamic? cell = null;
dynamic? mergeArea = null;
try
{
cell = cells.Item[i];
object? cellMergeCells = cell.MergeCells;
if (GetMergeCellsState(cellMergeCells) != true)
{
continue;
}
mergeArea = cell.MergeArea;
string address = mergeArea.Address?.ToString() ?? string.Empty;
if (address.Length > 0 && seenRanges.Add(address))
{
mergedRanges.Add(address);
}
}
finally
{
ComUtilities.Release(ref mergeArea);
ComUtilities.Release(ref cell);
}
}
}
finally
{
ComUtilities.Release(ref cells);
}
return mergedRanges;
}
/// <inheritdoc />
public OperationResult SetCellLock(
IExcelBatch batch,
@@ -260,5 +200,3 @@ public partial class RangeCommands
}
@@ -236,7 +236,7 @@ public partial class RangeCommands
CancellationToken cancellationToken)
{
object? mergeCells = range.MergeCells;
bool? isMergedState = GetMergeCellsState(mergeCells);
bool? isMergedState = RangeMergeDiscovery.GetMergeCellsState(mergeCells);
if (isMergedState == false)
{
return;
@@ -263,7 +263,10 @@ public partial class RangeCommands
}
}
var mergedRanges = CollectMergedRanges(range, cancellationToken);
var mergedRanges = RangeMergeDiscovery.CollectMergedRanges(
range,
isMergedState,
cancellationToken);
if (mergedRanges.Count > 0)
{
ThrowMergedCellWriteError(requestedRangeAddress, mergedRanges);
@@ -0,0 +1,123 @@
using System.Globalization;
using Sbroenne.ExcelMcp.ComInterop;
namespace Sbroenne.ExcelMcp.Core.Commands.Range;
/// <summary>
/// Shared merged-range discovery for range and table safety operations.
/// </summary>
internal static class RangeMergeDiscovery
{
// Unmerged ranges bypass this scan; mixed ranges require one COM lookup per cell.
private const long MaxMergedRangeScanCells = 4_096;
internal static bool? GetMergeCellsState(object? mergeCells)
{
if (mergeCells is null || mergeCells == DBNull.Value)
{
return null;
}
return Convert.ToBoolean(mergeCells, CultureInfo.InvariantCulture);
}
internal static List<string> CollectMergedRanges(
dynamic range,
bool? mergeCellsState,
CancellationToken cancellationToken)
{
if (mergeCellsState == true)
{
if (TryGetSingleMergedArea(range, out string mergedAreaAddress))
{
return [mergedAreaAddress];
}
}
dynamic? cells = null;
var mergedRanges = new List<string>();
var seenRanges = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
long cellCount = Convert.ToInt64(range.CountLarge, CultureInfo.InvariantCulture);
if (cellCount > MaxMergedRangeScanCells)
{
string rangeAddress = Convert.ToString(range.Address, CultureInfo.InvariantCulture)
?? "(unknown range)";
throw new InvalidOperationException(
$"Cannot inspect merged cells in range '{rangeAddress}' because it contains " +
$"{cellCount.ToString("N0", CultureInfo.InvariantCulture)} cells, exceeding the safe scan limit " +
$"of {MaxMergedRangeScanCells.ToString("N0", CultureInfo.InvariantCulture)} cells. " +
"Use a smaller range for this operation, or unmerge the affected cells before retrying.");
}
cells = range.Cells;
int boundedCellCount = checked((int)cellCount);
for (int i = 1; i <= boundedCellCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
dynamic? cell = null;
dynamic? mergeArea = null;
try
{
cell = cells.Item[i];
object? cellMergeCells = cell.MergeCells;
if (GetMergeCellsState(cellMergeCells) != true)
{
continue;
}
mergeArea = cell.MergeArea;
string address = mergeArea.Address?.ToString() ?? string.Empty;
if (address.Length > 0 && seenRanges.Add(address))
{
mergedRanges.Add(address);
}
}
finally
{
ComUtilities.Release(ref mergeArea);
ComUtilities.Release(ref cell);
}
}
}
finally
{
ComUtilities.Release(ref cells);
}
return mergedRanges;
}
private static bool TryGetSingleMergedArea(dynamic range, out string mergedAreaAddress)
{
dynamic? cells = null;
dynamic? firstCell = null;
dynamic? mergeArea = null;
try
{
cells = range.Cells;
firstCell = cells.Item[1];
mergeArea = firstCell.MergeArea;
string rangeAddress = Convert.ToString(range.Address, CultureInfo.InvariantCulture)
?? string.Empty;
string candidateAddress = Convert.ToString(mergeArea.Address, CultureInfo.InvariantCulture)
?? string.Empty;
bool isSameArea = rangeAddress.Length > 0
&& string.Equals(rangeAddress, candidateAddress, StringComparison.OrdinalIgnoreCase);
mergedAreaAddress = isSameArea ? candidateAddress : string.Empty;
return isSameArea;
}
finally
{
ComUtilities.Release(ref mergeArea);
ComUtilities.Release(ref firstCell);
ComUtilities.Release(ref cells);
}
}
}
@@ -27,7 +27,7 @@ namespace Sbroenne.ExcelMcp.Core.Commands.Table;
/// </summary>
[ServiceCategory("table", "Table")]
[McpTool("table", Title = "Table Operations", Destructive = true, Category = "data",
Description = "Excel Tables (ListObjects) - lifecycle and data operations. CONVERT TO TABLE: When user asks to 'format as table', 'create a table', 'put data in an Excel Table', or 'use a table' — use table(action: 'create') on the data range. Excel Tables provide built-in alternating row colors, automatic filter arrows, structured references, and automatic expansion. WORKFLOW: write data to range first, then table(action: 'create') to convert. STYLING: Pass tableStyle on create (TableStyleLight1-21, TableStyleMedium1-28, TableStyleDark1-11) or use set-style action later — these are the ONLY ways to style a table. Never apply range_format to table header or data rows — it conflicts with the table style system. BEST PRACTICE: List before creating, prefer append/resize/rename over delete+recreate. WARNING: Deleting tables used as PivotTable sources or in Data Model breaks those objects. DATA MODEL: add-to-datamodel to load into Power Pivot, then datamodel for DAX measures. APPEND: rows (inline JSON 2D array) or rowsFile (.json/.csv). Use table_column for filtering/sorting/columns.")]
Description = "Excel Tables (ListObjects) - lifecycle and data operations. SAFE CREATION: Use preflight to inspect merged cells, headers, excluded contiguous columns, formula-sort risks, and the effective range without changing the workbook. Create runs the same checks and rejects deterministic blockers; heuristic warnings remain advisory. Formula risk analysis is skipped with an explicit warning when the proposed range exceeds 100,000 cells. CONVERT TO TABLE: Write data to a range, then use create. STYLING: Pass tableStyle on create or use set-style later; never apply range_format to table headers or data rows. Prefer append/resize/rename over delete+recreate. Deleting tables used by PivotTables or the Data Model breaks those objects. Use table_column for filtering, sorting, and columns.")]
public interface ITableCommands
{
/// <summary>
@@ -37,14 +37,27 @@ public interface ITableCommands
TableListResult List(IExcelBatch batch);
/// <summary>
/// Creates a new Excel Table from a range
/// Checks a proposed table range without changing the workbook.
/// Reports deterministic blockers separately from heuristic warnings and returns
/// the effective range after single-cell CurrentRegion expansion.
/// </summary>
/// <param name="sheetName">Name of the worksheet containing the proposed table</param>
/// <param name="tableName">Name for the proposed table (must be unique in workbook)</param>
/// <param name="rangeAddress">Cell range address, or one cell to expand to its CurrentRegion</param>
/// <param name="hasHeaders">True if the first row contains column headers (default: true)</param>
[ServiceAction("preflight")]
TablePreflightResult Preflight(IExcelBatch batch, string sheetName, string tableName, string rangeAddress, bool hasHeaders = true);
/// <summary>
/// Creates a new Excel Table from a range after running the same checks as Preflight.
/// Deterministic blockers reject creation; heuristic warnings do not.
/// </summary>
/// <param name="sheetName">Name of the worksheet to create the table on</param>
/// <param name="tableName">Name for the new table (must be unique in workbook)</param>
/// <param name="rangeAddress">Cell range address for the table (e.g., 'A1:D10')</param>
/// <param name="hasHeaders">True if first row contains column headers (default: true)</param>
/// <param name="tableStyle">Table style name (e.g., 'TableStyleMedium2', 'TableStyleLight1'). Optional.</param>
/// <exception cref="InvalidOperationException">Sheet not found, table name already exists, or range invalid</exception>
/// <exception cref="InvalidOperationException">Sheet not found, table name already exists, range invalid, or preflight found a blocker</exception>
[ServiceAction("create")]
OperationResult Create(IExcelBatch batch, string sheetName, string tableName, string rangeAddress, bool hasHeaders = true, string? tableStyle = null);
@@ -130,13 +130,12 @@ public partial class TableCommands
/// <inheritdoc />
public OperationResult Create(IExcelBatch batch, string sheetName, string tableName, string rangeAddress, bool hasHeaders = true, string? tableStyle = null)
{
// Security: Validate table name
ValidateTableName(tableName);
ValidateCreateInputs(sheetName, tableName, rangeAddress);
return batch.Execute((ctx, ct) =>
{
Excel.Worksheet? sheet = null;
dynamic? rangeObj = null;
Excel.Range? rangeObj = null;
dynamic? listObjects = null;
dynamic? newTable = null;
try
@@ -147,36 +146,25 @@ public partial class TableCommands
throw new InvalidOperationException($"Sheet '{sheetName}' not found.");
}
// Check if table name already exists
if (TableExists(ctx.Book, tableName))
rangeObj = ResolveEffectiveRange(sheet, rangeAddress);
TablePreflightResult preflight = AnalyzePreflight(
ctx.Book,
rangeObj,
batch.WorkbookPath,
sheetName,
tableName,
rangeAddress,
hasHeaders,
ct);
if (!preflight.SafeToCreate)
{
throw new InvalidOperationException($"Table '{tableName}' already exists");
}
// Get the range to convert to table
rangeObj = sheet.Range[rangeAddress];
// Auto-expand single cell to current region (common UX pattern)
// This allows users to specify just "A1" instead of the full range
dynamic? currentRegion = null;
try
{
// Check if single cell (no colon in address = single cell)
if (!rangeAddress.Contains(':'))
{
currentRegion = rangeObj.CurrentRegion;
if (currentRegion != null && currentRegion.Cells.Count > 1)
{
// Use the expanded current region instead
ComUtilities.Release(ref rangeObj);
rangeObj = currentRegion;
currentRegion = null; // Don't release twice
}
}
}
finally
{
ComUtilities.Release(ref currentRegion);
string reasons = string.Join(
" ",
preflight.Findings
.Where(finding => finding.Severity == TablePreflightSeverity.Blocker)
.Select(finding => finding.Message));
throw new InvalidOperationException(
$"Table '{tableName}' cannot be created because preflight found blocking issues. {reasons}");
}
listObjects = sheet.ListObjects;
@@ -377,5 +365,3 @@ public partial class TableCommands
}
}
@@ -0,0 +1,495 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Sbroenne.ExcelMcp.ComInterop;
using Sbroenne.ExcelMcp.ComInterop.Session;
using Sbroenne.ExcelMcp.Core.Commands.Range;
using Sbroenne.ExcelMcp.Core.Models;
using Excel = Microsoft.Office.Interop.Excel;
namespace Sbroenne.ExcelMcp.Core.Commands.Table;
/// <summary>
/// Table creation safety checks.
/// </summary>
public partial class TableCommands
{
private const long MaxSortSensitiveFormulaScanCells = 100_000;
private static readonly Regex A1ReferenceRegex = new(
@"(?<![A-Z0-9_])(?<column>\$?[A-Z]{1,3})(?<row>\$?\d+)(?![A-Z0-9_])",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
/// <inheritdoc />
public TablePreflightResult Preflight(
IExcelBatch batch,
string sheetName,
string tableName,
string rangeAddress,
bool hasHeaders = true)
{
ValidateCreateInputs(sheetName, tableName, rangeAddress);
return batch.Execute((ctx, ct) =>
{
Excel.Worksheet? sheet = null;
Excel.Range? effectiveRange = null;
try
{
sheet = ComUtilities.FindSheet(ctx.Book, sheetName)
?? throw new InvalidOperationException($"Sheet '{sheetName}' not found.");
effectiveRange = ResolveEffectiveRange(sheet, rangeAddress);
return AnalyzePreflight(
ctx.Book,
effectiveRange,
batch.WorkbookPath,
sheetName,
tableName,
rangeAddress,
hasHeaders,
ct);
}
finally
{
ComUtilities.Release(ref effectiveRange);
ComUtilities.Release(ref sheet);
}
});
}
private static void ValidateCreateInputs(string sheetName, string tableName, string rangeAddress)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sheetName);
ArgumentException.ThrowIfNullOrWhiteSpace(rangeAddress);
ValidateTableName(tableName);
}
private static Excel.Range ResolveEffectiveRange(Excel.Worksheet sheet, string rangeAddress)
{
Excel.Range? requestedRange = null;
Excel.Range? currentRegion = null;
try
{
requestedRange = sheet.Range[rangeAddress];
if (Convert.ToInt64(requestedRange.CountLarge, CultureInfo.InvariantCulture) == 1)
{
currentRegion = requestedRange.CurrentRegion;
if (Convert.ToInt64(currentRegion.CountLarge, CultureInfo.InvariantCulture) > 1)
{
ComUtilities.Release(ref requestedRange);
requestedRange = currentRegion;
currentRegion = null;
}
}
Excel.Range result = requestedRange;
requestedRange = null;
return result;
}
finally
{
ComUtilities.Release(ref currentRegion);
ComUtilities.Release(ref requestedRange);
}
}
private static TablePreflightResult AnalyzePreflight(
Excel.Workbook workbook,
Excel.Range effectiveRange,
string workbookPath,
string sheetName,
string tableName,
string requestedRange,
bool hasHeaders,
CancellationToken cancellationToken)
{
var result = new TablePreflightResult
{
Success = true,
FilePath = workbookPath,
SheetName = sheetName,
TableName = tableName,
RequestedRange = requestedRange,
EffectiveRange = effectiveRange.Address
};
if (TableExists(workbook, tableName))
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.TableNameExists,
Severity = TablePreflightSeverity.Blocker,
Message = $"A table named '{tableName}' already exists in this workbook.",
Remediation = "Choose a unique table name or use the existing table."
});
}
bool? isMerged = RangeMergeDiscovery.GetMergeCellsState(effectiveRange.MergeCells);
List<string> mergedRanges = isMerged == false
? []
: RangeMergeDiscovery.CollectMergedRanges(effectiveRange, isMerged, cancellationToken);
if (mergedRanges.Count > 0)
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.MergedCells,
Severity = TablePreflightSeverity.Blocker,
Addresses = [.. mergedRanges],
Message = "The proposed table range intersects merged cells, which Excel tables cannot safely preserve.",
Remediation = "Unmerge these cells and repeat any shared labels in each resulting cell before creating the table."
});
}
InspectHeaders(effectiveRange, hasHeaders, result);
InspectExcludedContiguousColumns(effectiveRange, result);
InspectSortSensitiveFormulas(effectiveRange, hasHeaders, result, cancellationToken);
result.SafeToCreate = result.Findings.All(
finding => finding.Severity != TablePreflightSeverity.Blocker);
return result;
}
private static void InspectHeaders(
Excel.Range effectiveRange,
bool hasHeaders,
TablePreflightResult result)
{
if (!hasHeaders)
{
return;
}
Excel.Range? rows = null;
Excel.Range? columns = null;
Excel.Range? headerRange = null;
try
{
rows = effectiveRange.Rows;
columns = effectiveRange.Columns;
headerRange = rows[1];
object values = headerRange.Value2;
int columnCount = Convert.ToInt32(columns.Count, CultureInfo.InvariantCulture);
int headerRow = Convert.ToInt32(effectiveRange.Row, CultureInfo.InvariantCulture);
int firstColumn = Convert.ToInt32(effectiveRange.Column, CultureInfo.InvariantCulture);
var blankAddresses = new List<string>();
var addressesByHeader = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
for (int columnOffset = 0; columnOffset < columnCount; columnOffset++)
{
string address = GetAbsoluteAddress(firstColumn + columnOffset, headerRow);
string header = Convert.ToString(
GetMatrixValue(values, 0, columnOffset),
CultureInfo.InvariantCulture)?.Trim() ?? string.Empty;
if (header.Length == 0)
{
blankAddresses.Add(address);
continue;
}
if (!addressesByHeader.TryGetValue(header, out List<string>? addresses))
{
addresses = [];
addressesByHeader.Add(header, addresses);
}
addresses.Add(address);
}
if (blankAddresses.Count > 0)
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.BlankHeaders,
Severity = TablePreflightSeverity.Blocker,
Addresses = blankAddresses,
Message = "One or more table headers are blank, so Excel would generate names silently.",
Remediation = "Enter a unique, descriptive header in each listed cell before creating the table."
});
}
List<string> duplicateAddresses = addressesByHeader
.Where(group => group.Value.Count > 1)
.SelectMany(group => group.Value)
.ToList();
if (duplicateAddresses.Count > 0)
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.DuplicateHeaders,
Severity = TablePreflightSeverity.Blocker,
Addresses = duplicateAddresses,
Message = "Two or more table headers have the same name after trimming spaces and ignoring case.",
Remediation = "Rename the listed headers so every table column has a unique name."
});
}
}
finally
{
ComUtilities.Release(ref headerRange);
ComUtilities.Release(ref columns);
ComUtilities.Release(ref rows);
}
}
private static void InspectExcludedContiguousColumns(
Excel.Range effectiveRange,
TablePreflightResult result)
{
Excel.Range? effectiveColumns = null;
Excel.Range? currentRegion = null;
Excel.Range? regionRows = null;
Excel.Range? regionColumns = null;
try
{
effectiveColumns = effectiveRange.Columns;
currentRegion = effectiveRange.CurrentRegion;
regionRows = currentRegion.Rows;
regionColumns = currentRegion.Columns;
int effectiveFirstColumn = Convert.ToInt32(effectiveRange.Column, CultureInfo.InvariantCulture);
int effectiveLastColumn = effectiveFirstColumn
+ Convert.ToInt32(effectiveColumns.Count, CultureInfo.InvariantCulture) - 1;
int regionFirstColumn = Convert.ToInt32(currentRegion.Column, CultureInfo.InvariantCulture);
int regionLastColumn = regionFirstColumn
+ Convert.ToInt32(regionColumns.Count, CultureInfo.InvariantCulture) - 1;
int regionFirstRow = Convert.ToInt32(currentRegion.Row, CultureInfo.InvariantCulture);
int regionLastRow = regionFirstRow
+ Convert.ToInt32(regionRows.Count, CultureInfo.InvariantCulture) - 1;
var excludedRanges = new List<string>();
if (regionFirstColumn < effectiveFirstColumn)
{
excludedRanges.Add(GetAbsoluteRangeAddress(
regionFirstColumn,
regionFirstRow,
effectiveFirstColumn - 1,
regionLastRow));
}
if (regionLastColumn > effectiveLastColumn)
{
excludedRanges.Add(GetAbsoluteRangeAddress(
effectiveLastColumn + 1,
regionFirstRow,
regionLastColumn,
regionLastRow));
}
if (excludedRanges.Count > 0)
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.ExcludedContiguousColumns,
Severity = TablePreflightSeverity.Warning,
IsHeuristic = true,
Addresses = excludedRanges,
Message = "The same contiguous data region contains populated columns outside the proposed table range.",
Remediation = "Expand the table range to include these columns, or confirm that they are separate data."
});
}
}
finally
{
ComUtilities.Release(ref regionColumns);
ComUtilities.Release(ref regionRows);
ComUtilities.Release(ref currentRegion);
ComUtilities.Release(ref effectiveColumns);
}
}
private static void InspectSortSensitiveFormulas(
Excel.Range effectiveRange,
bool hasHeaders,
TablePreflightResult result,
CancellationToken cancellationToken)
{
Excel.Range? rows = null;
Excel.Range? columns = null;
try
{
rows = effectiveRange.Rows;
columns = effectiveRange.Columns;
int rowCount = Convert.ToInt32(rows.Count, CultureInfo.InvariantCulture);
int columnCount = Convert.ToInt32(columns.Count, CultureInfo.InvariantCulture);
int firstDataRowOffset = hasHeaders ? 1 : 0;
if (rowCount <= firstDataRowOffset)
{
return;
}
long cellCount = Convert.ToInt64(effectiveRange.CountLarge, CultureInfo.InvariantCulture);
if (cellCount > MaxSortSensitiveFormulaScanCells)
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.FormulaScanSkipped,
Severity = TablePreflightSeverity.Warning,
IsHeuristic = true,
Message = $"Formula sorting risk analysis was skipped because the proposed range contains " +
$"{cellCount.ToString("N0", CultureInfo.InvariantCulture)} cells, exceeding the bounded scan limit " +
$"of {MaxSortSensitiveFormulaScanCells.ToString("N0", CultureInfo.InvariantCulture)} cells.",
Remediation = "Run preflight on a smaller range or review the table formulas manually before sorting."
});
return;
}
int firstRow = Convert.ToInt32(effectiveRange.Row, CultureInfo.InvariantCulture);
int firstColumn = Convert.ToInt32(effectiveRange.Column, CultureInfo.InvariantCulture);
int lastColumn = firstColumn + columnCount - 1;
object formulas = effectiveRange.Formula;
var warningAddresses = new List<string>();
for (int rowOffset = firstDataRowOffset; rowOffset < rowCount; rowOffset++)
{
for (int columnOffset = 0; columnOffset < columnCount; columnOffset++)
{
cancellationToken.ThrowIfCancellationRequested();
string formula = Convert.ToString(
GetMatrixValue(formulas, rowOffset, columnOffset),
CultureInfo.InvariantCulture) ?? string.Empty;
if (!formula.StartsWith('='))
{
continue;
}
int formulaRow = firstRow + rowOffset;
if (HasSortSensitiveReference(formula, formulaRow, firstColumn, lastColumn))
{
warningAddresses.Add(GetAbsoluteAddress(firstColumn + columnOffset, formulaRow));
}
}
}
if (warningAddresses.Count > 0)
{
result.Findings.Add(new TablePreflightFinding
{
Kind = TablePreflightFindingKind.SortSensitiveFormula,
Severity = TablePreflightSeverity.Warning,
IsHeuristic = true,
Addresses = warningAddresses,
Message = "These formulas use fixed-row or cross-row A1 references that may no longer align after sorting the table.",
Remediation = "Use structured references where possible, or confirm the formulas still point to the intended rows after sorting."
});
}
}
finally
{
ComUtilities.Release(ref columns);
ComUtilities.Release(ref rows);
}
}
internal static bool HasSortSensitiveReference(
string formula,
int formulaRow,
int firstTableColumn,
int lastTableColumn)
{
foreach (Match match in EnumerateUnquotedA1References(formula))
{
string rowToken = match.Groups["row"].Value;
if (!int.TryParse(rowToken.TrimStart('$'), NumberStyles.None, CultureInfo.InvariantCulture, out int referencedRow))
{
continue;
}
string columnToken = match.Groups["column"].Value;
int referencedColumn = GetColumnIndex(columnToken.TrimStart('$'));
bool isSheetQualified = match.Index > 0 && formula[match.Index - 1] == '!';
if (rowToken.StartsWith('$')
|| referencedRow != formulaRow
|| referencedColumn < firstTableColumn
|| referencedColumn > lastTableColumn
|| isSheetQualified)
{
return true;
}
}
return false;
}
private static IEnumerable<Match> EnumerateUnquotedA1References(string formula)
{
int scanIndex = 0;
bool isInStringLiteral = false;
foreach (Match match in A1ReferenceRegex.Matches(formula))
{
while (scanIndex < match.Index)
{
if (formula[scanIndex] != '"')
{
scanIndex++;
continue;
}
if (isInStringLiteral
&& scanIndex + 1 < match.Index
&& formula[scanIndex + 1] == '"')
{
scanIndex += 2;
continue;
}
isInStringLiteral = !isInStringLiteral;
scanIndex++;
}
scanIndex = match.Index + match.Length;
if (!isInStringLiteral)
{
yield return match;
}
}
}
private static int GetColumnIndex(string columnName)
{
int column = 0;
foreach (char character in columnName)
{
column = checked(column * 26 + char.ToUpperInvariant(character) - 'A' + 1);
}
return column;
}
private static object? GetMatrixValue(object valueOrArray, int rowOffset, int columnOffset)
{
if (valueOrArray is not Array values || values.Rank != 2)
{
return rowOffset == 0 && columnOffset == 0 ? valueOrArray : null;
}
return values.GetValue(
values.GetLowerBound(0) + rowOffset,
values.GetLowerBound(1) + columnOffset);
}
private static string GetAbsoluteRangeAddress(
int firstColumn,
int firstRow,
int lastColumn,
int lastRow) =>
$"{GetAbsoluteAddress(firstColumn, firstRow)}:{GetAbsoluteAddress(lastColumn, lastRow)}";
private static string GetAbsoluteAddress(int column, int row) =>
$"${GetColumnName(column)}${row}";
private static string GetColumnName(int column)
{
string name = string.Empty;
while (column > 0)
{
column--;
name = Convert.ToChar('A' + column % 26) + name;
column /= 26;
}
return name;
}
}
@@ -0,0 +1,37 @@
namespace Sbroenne.ExcelMcp.Core.Models;
/// <summary>
/// One actionable result from a table-creation preflight.
/// </summary>
public sealed class TablePreflightFinding
{
/// <summary>
/// Finding type.
/// </summary>
public TablePreflightFindingKind Kind { get; set; }
/// <summary>
/// Whether the finding blocks creation or is advisory.
/// </summary>
public TablePreflightSeverity Severity { get; set; }
/// <summary>
/// True when the finding is based on a heuristic rather than a deterministic Excel constraint.
/// </summary>
public bool IsHeuristic { get; set; }
/// <summary>
/// Cells or ranges related to the finding.
/// </summary>
public List<string> Addresses { get; set; } = [];
/// <summary>
/// Plain-English explanation of the problem.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Plain-English action that resolves or reviews the finding.
/// </summary>
public string Remediation { get; set; } = string.Empty;
}
@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace Sbroenne.ExcelMcp.Core.Models;
/// <summary>
/// Type of problem found while checking a proposed table range.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TablePreflightFindingKind>))]
public enum TablePreflightFindingKind
{
/// <summary>
/// The proposed range intersects one or more merged ranges.
/// </summary>
MergedCells,
/// <summary>
/// One or more header cells are blank.
/// </summary>
BlankHeaders,
/// <summary>
/// Two or more header cells resolve to the same name.
/// </summary>
DuplicateHeaders,
/// <summary>
/// Populated columns in the same current region are excluded.
/// </summary>
ExcludedContiguousColumns,
/// <summary>
/// A formula contains row references that may not remain aligned after sorting.
/// </summary>
SortSensitiveFormula,
/// <summary>
/// Formula risk analysis was skipped because the proposed range exceeds the bounded scan size.
/// </summary>
FormulaScanSkipped,
/// <summary>
/// The requested table name already exists.
/// </summary>
TableNameExists
}
@@ -0,0 +1,37 @@
namespace Sbroenne.ExcelMcp.Core.Models;
/// <summary>
/// Non-destructive safety report for creating an Excel table from a range.
/// </summary>
public sealed class TablePreflightResult : ResultBase
{
/// <summary>
/// Worksheet containing the proposed table.
/// </summary>
public string SheetName { get; set; } = string.Empty;
/// <summary>
/// Requested table name.
/// </summary>
public string TableName { get; set; } = string.Empty;
/// <summary>
/// Range supplied by the caller.
/// </summary>
public string RequestedRange { get; set; } = string.Empty;
/// <summary>
/// Absolute range Excel will use after single-cell CurrentRegion expansion.
/// </summary>
public string EffectiveRange { get; set; } = string.Empty;
/// <summary>
/// True when no deterministic blocker was found.
/// </summary>
public bool SafeToCreate { get; set; }
/// <summary>
/// Blocking and advisory findings for the proposed table.
/// </summary>
public List<TablePreflightFinding> Findings { get; set; } = [];
}
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Sbroenne.ExcelMcp.Core.Models;
/// <summary>
/// Severity of a table-creation preflight finding.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TablePreflightSeverity>))]
public enum TablePreflightSeverity
{
/// <summary>
/// A deterministic problem that prevents safe table creation.
/// </summary>
Blocker,
/// <summary>
/// A heuristic concern that should be reviewed but does not prevent creation.
/// </summary>
Warning
}
+2 -2
View File
@@ -63,9 +63,9 @@ dotnet tool install --global Sbroenne.ExcelMcp.McpServer
## 🛠️ What You Can Do
**31 specialized tools with 325 operations** covering Power Query, Data Model/DAX, What-If Analysis, PivotTables, Excel Tables, Charts, Drawings, VBA, Ranges, Worksheets, Workbooks, QueryTables, XML Maps, Connections, Named Ranges, File/Session management, Calculation Mode, Slicers, Conditional Formatting, Screenshots, and Window Management.
**31 specialized tools with 326 operations** covering Power Query, Data Model/DAX, What-If Analysis, PivotTables, Excel Tables, Charts, Drawings, VBA, Ranges, Worksheets, Workbooks, QueryTables, XML Maps, Connections, Named Ranges, File/Session management, Calculation Mode, Slicers, Conditional Formatting, Screenshots, and Window Management.
📚 **[Complete Feature Reference →](https://github.com/sbroenne/mcp-server-excel/blob/main/FEATURES.md)** - Detailed documentation of all 325 operations, grouped by category
📚 **[Complete Feature Reference →](https://github.com/sbroenne/mcp-server-excel/blob/main/FEATURES.md)** - Detailed documentation of all 326 operations, grouped by category
**AI-Powered Workflows:**
- 💬 Natural language Excel commands through GitHub Copilot, Claude, or ChatGPT
@@ -21,6 +21,7 @@ public sealed class ActionValidatorTests
[typeof(RangeFormatAction), typeof(ServiceRegistry.RangeFormat)],
[typeof(RangeLinkAction), typeof(ServiceRegistry.RangeLink)],
[typeof(DrawingAction), typeof(ServiceRegistry.Drawing)],
[typeof(TableAction), typeof(ServiceRegistry.Table)],
[typeof(WorkbookAction), typeof(ServiceRegistry.Workbook)]
];
@@ -99,6 +100,14 @@ public sealed class ActionValidatorTests
Assert.Contains("read-connection", actual);
}
[Fact]
public void TableActions_IncludePreflight()
{
var actions = GetActualActions(typeof(ServiceRegistry.Table));
Assert.Contains("preflight", actions);
}
[Fact]
public void SheetDescription_DoesNotReferenceUnregisteredStyleCommand()
{
@@ -306,8 +306,74 @@ public partial class RangeCommandsTests
Assert.False(result.IsMerged);
Assert.Empty(result.MergedRanges);
}
[Fact]
[Trait("Speed", "Medium")]
public void GetMergeInfo_NormalMixedRange_ReturnsEveryMergedRange()
{
using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath);
var sheetName = _fixture.CreateTestSheet(batch);
_commands.MergeCells(batch, sheetName, "B2:C2");
_commands.MergeCells(batch, sheetName, "F3:H3");
var result = _commands.GetMergeInfo(batch, sheetName, "A1:H4");
Assert.True(result.Success, $"GetMergeInfo failed: {result.ErrorMessage}");
Assert.True(result.IsMerged);
Assert.Equal(["$B$2:$C$2", "$F$3:$H$3"], result.MergedRanges);
}
[Fact]
[Trait("Speed", "Medium")]
public void GetMergeInfo_OversizedMixedRange_ThrowsActionableScanLimitError()
{
using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath);
var sheetName = _fixture.CreateTestSheet(batch);
_commands.MergeCells(batch, sheetName, "B2:C2");
var exception = Assert.Throws<InvalidOperationException>(
() => _commands.GetMergeInfo(batch, sheetName, "A1:AO100"));
Assert.Contains("4,100", exception.Message, StringComparison.Ordinal);
Assert.Contains("scan limit", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("smaller range", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("unmerge", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Trait("Speed", "Medium")]
public void GetMergeInfo_OversizedSingleMergedArea_ReturnsAreaWithoutScanningEveryCell()
{
using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath);
var sheetName = _fixture.CreateTestSheet(batch);
_commands.MergeCells(batch, sheetName, "A1:AO100");
var result = _commands.GetMergeInfo(batch, sheetName, "A1:AO100");
Assert.True(result.Success, $"GetMergeInfo failed: {result.ErrorMessage}");
Assert.True(result.IsMerged);
Assert.Equal(["$A$1:$AO$100"], result.MergedRanges);
}
[Fact]
[Trait("Speed", "Medium")]
public void GetMergeInfo_SeparateMergedAreasCoveringRange_ReturnsEveryArea()
{
using var batch = ExcelSession.BeginBatch(_fixture.TestFilePath);
var sheetName = _fixture.CreateTestSheet(batch);
_commands.MergeCells(batch, sheetName, "A1:B1");
_commands.MergeCells(batch, sheetName, "C1:D1");
var result = _commands.GetMergeInfo(batch, sheetName, "A1:D1");
Assert.True(result.Success, $"GetMergeInfo failed: {result.ErrorMessage}");
Assert.True(result.IsMerged);
Assert.Equal(["$A$1:$B$1", "$C$1:$D$1"], result.MergedRanges);
}
}
@@ -0,0 +1,228 @@
using Sbroenne.ExcelMcp.ComInterop.Session;
using Sbroenne.ExcelMcp.Core.Models;
using Xunit;
namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table;
public partial class TableCommandsTests
{
[Fact]
public void Preflight_SingleCellInsideData_ReportsExpandedEffectiveRange()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:H3",
[
["Name", "Region", "Amount"],
["Widget", "North", 10],
["Gadget", "South", 20]
]);
var result = _tableCommands.Preflight(batch, "Sales", "ExpandedTable", "G2");
Assert.True(result.Success, result.ErrorMessage);
Assert.True(result.SafeToCreate);
Assert.Equal("G2", result.RequestedRange);
Assert.Equal("$F$1:$H$3", result.EffectiveRange);
Assert.Empty(result.Findings);
}
[Fact]
public void Preflight_MergedCells_ReturnsBlockerAndCreateDoesNotChangeWorkbook()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:H3",
[
["Name", "Region", "Amount"],
["Widget", "North", 10],
["Gadget", "South", 20]
]);
_rangeCommands.MergeCells(batch, "Sales", "G2:H2");
var result = _tableCommands.Preflight(batch, "Sales", "MergedTable", "F1:H3");
Assert.False(result.SafeToCreate);
var finding = Assert.Single(result.Findings, item => item.Kind == TablePreflightFindingKind.MergedCells);
Assert.Equal(TablePreflightSeverity.Blocker, finding.Severity);
Assert.False(finding.IsHeuristic);
Assert.Contains("$G$2:$H$2", finding.Addresses);
Assert.False(string.IsNullOrWhiteSpace(finding.Remediation));
var exception = Assert.Throws<InvalidOperationException>(
() => _tableCommands.Create(batch, "Sales", "MergedTable", "F1:H3"));
Assert.Contains("merged", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(_tableCommands.List(batch).Tables, table => table.Name == "MergedTable");
Assert.True(_rangeCommands.GetMergeInfo(batch, "Sales", "G2:H2").IsMerged);
}
[Fact]
public void Preflight_BlankAndDuplicateHeaders_ReturnsAddressedBlockers()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:H2",
[
[null, "Name", " name "],
[1, "Widget", "Duplicate"]
]);
var result = _tableCommands.Preflight(batch, "Sales", "HeaderTable", "F1:H2");
Assert.False(result.SafeToCreate);
var blank = Assert.Single(result.Findings, item => item.Kind == TablePreflightFindingKind.BlankHeaders);
Assert.Equal(TablePreflightSeverity.Blocker, blank.Severity);
Assert.Equal(["$F$1"], blank.Addresses);
var duplicate = Assert.Single(result.Findings, item => item.Kind == TablePreflightFindingKind.DuplicateHeaders);
Assert.Equal(TablePreflightSeverity.Blocker, duplicate.Severity);
Assert.Equal(["$G$1", "$H$1"], duplicate.Addresses);
Assert.Throws<InvalidOperationException>(
() => _tableCommands.Create(batch, "Sales", "HeaderTable", "F1:H2"));
Assert.DoesNotContain(_tableCommands.List(batch).Tables, table => table.Name == "HeaderTable");
}
[Fact]
public void Preflight_ExcludedContiguousColumn_ReturnsNonBlockingWarning()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:H3",
[
["Name", "Region", "Amount"],
["Widget", "North", 10],
["Gadget", "South", 20]
]);
var result = _tableCommands.Preflight(batch, "Sales", "NarrowTable", "F1:G3");
Assert.True(result.SafeToCreate);
var finding = Assert.Single(
result.Findings,
item => item.Kind == TablePreflightFindingKind.ExcludedContiguousColumns);
Assert.Equal(TablePreflightSeverity.Warning, finding.Severity);
Assert.True(finding.IsHeuristic);
Assert.Equal(["$H$1:$H$3"], finding.Addresses);
_tableCommands.Create(batch, "Sales", "NarrowTable", "F1:G3");
Assert.Equal("$F$1:$G$3", _tableCommands.Read(batch, "NarrowTable").Table!.Range);
}
[Fact]
public void Preflight_SortSensitiveFormula_ReturnsNonBlockingWarning()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:G3",
[
["Amount", "Calculated"],
[10, null],
[20, 40]
]);
_rangeCommands.SetFormulas(
batch,
"Sales",
"G2:G3",
[
["=$F$2*2"],
["=I3*2"]
]);
var result = _tableCommands.Preflight(batch, "Sales", "FormulaTable", "F1:G3");
Assert.True(result.SafeToCreate);
var finding = Assert.Single(
result.Findings,
item => item.Kind == TablePreflightFindingKind.SortSensitiveFormula);
Assert.Equal(TablePreflightSeverity.Warning, finding.Severity);
Assert.True(finding.IsHeuristic);
Assert.Equal(["$G$2", "$G$3"], finding.Addresses);
_tableCommands.Create(batch, "Sales", "FormulaTable", "F1:G3");
Assert.Contains(_tableCommands.List(batch).Tables, table => table.Name == "FormulaTable");
}
[Fact]
public void Preflight_ExistingTableName_ReturnsBlocker()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:G2",
[
["Name", "Amount"],
["Widget", 10]
]);
var result = _tableCommands.Preflight(batch, "Sales", "SalesTable", "F1:G2");
Assert.False(result.SafeToCreate);
var finding = Assert.Single(
result.Findings,
item => item.Kind == TablePreflightFindingKind.TableNameExists);
Assert.Equal(TablePreflightSeverity.Blocker, finding.Severity);
Assert.Empty(finding.Addresses);
Assert.Contains("already exists", finding.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Preflight_WithoutHeaders_DoesNotReportBlankHeaderBlocker()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
SetValues(batch, "F1:G2",
[
[10, null],
[20, 40]
]);
_rangeCommands.SetFormulas(batch, "Sales", "G1", [["=$F$1*2"]]);
var result = _tableCommands.Preflight(
batch,
"Sales",
"HeaderlessTable",
"F1:G2",
hasHeaders: false);
Assert.True(result.SafeToCreate);
Assert.DoesNotContain(
result.Findings,
item => item.Kind is TablePreflightFindingKind.BlankHeaders
or TablePreflightFindingKind.DuplicateHeaders);
var formulaFinding = Assert.Single(
result.Findings,
item => item.Kind == TablePreflightFindingKind.SortSensitiveFormula);
Assert.Equal(["$G$1"], formulaFinding.Addresses);
}
[Fact]
public void Preflight_OversizedRange_ReturnsExplicitFormulaScanSkippedWarning()
{
var testFile = _fixture.CreateModificationTestFile();
using var batch = ExcelSession.BeginBatch(testFile);
var result = _tableCommands.Preflight(
batch,
"Sales",
"LargeTable",
"A1:CV1001",
hasHeaders: false);
Assert.True(result.Success, result.ErrorMessage);
Assert.True(result.SafeToCreate);
var finding = Assert.Single(
result.Findings,
item => item.Kind == TablePreflightFindingKind.FormulaScanSkipped);
Assert.Equal(TablePreflightSeverity.Warning, finding.Severity);
Assert.True(finding.IsHeuristic);
Assert.Empty(finding.Addresses);
Assert.Contains("100,100", finding.Message, StringComparison.Ordinal);
Assert.Contains("100,000", finding.Message, StringComparison.Ordinal);
Assert.Contains("smaller range", finding.Remediation, StringComparison.OrdinalIgnoreCase);
}
private void SetValues(IExcelBatch batch, string address, List<List<object?>> values)
{
_rangeCommands.SetValues(batch, "Sales", address, values);
}
}
@@ -21,7 +21,7 @@ namespace Sbroenne.ExcelMcp.Core.Tests.Commands.Table;
public partial class TableCommandsTests : IClassFixture<TableTestsFixture>
{
private readonly TableCommands _tableCommands;
private readonly IRangeCommands _rangeCommands;
private readonly RangeCommands _rangeCommands;
private readonly TableTestsFixture _fixture;
private readonly string _tableFile;
private readonly TableCreationResult _creationResult;
@@ -469,4 +469,3 @@ public partial class TableCommandsTests : IClassFixture<TableTestsFixture>
@@ -0,0 +1,39 @@
using Sbroenne.ExcelMcp.Core.Commands.Table;
using Xunit;
namespace Sbroenne.ExcelMcp.Core.Tests.Unit;
[Trait("Layer", "Core")]
[Trait("Category", "Unit")]
[Trait("Feature", "Tables")]
[Trait("Speed", "Fast")]
public class TableFormulaReferenceTests
{
[Fact]
public void HasSortSensitiveReference_A1LookingStrings_ReturnsFalse()
{
const string formula = "=HYPERLINK(\"#A1\",\"Jump to \"\"B2\"\"\")";
bool result = TableCommands.HasSortSensitiveReference(
formula,
formulaRow: 2,
firstTableColumn: 6,
lastTableColumn: 7);
Assert.False(result);
}
[Fact]
public void HasSortSensitiveReference_TrueReferenceAfterA1LookingString_ReturnsTrue()
{
const string formula = "=IF(\"A1\"=\"B2\",$F$2,F2)";
bool result = TableCommands.HasSortSensitiveReference(
formula,
formulaRow: 2,
firstTableColumn: 6,
lastTableColumn: 7);
Assert.True(result);
}
}
@@ -207,6 +207,32 @@ public class McpServerSmokeTests : IAsyncLifetime, IAsyncDisposable
// =====================================================================
_output.WriteLine("\n✓ Step 5: Table operations...");
var preflightTableResult = await CallToolAsync("table", new Dictionary<string, object?>
{
["action"] = "preflight",
["path"] = _testExcelFile,
["session_id"] = sessionId,
["table_name"] = "DataTable",
["sheet_name"] = "Data",
["range_address"] = "A1:B3",
["has_headers"] = true
});
AssertSuccess(preflightTableResult, "Preflight table");
using (var preflightJson = JsonDocument.Parse(preflightTableResult))
{
Assert.True(preflightJson.RootElement.GetProperty("safeToCreate").GetBoolean());
Assert.Equal("$A$1:$B$3", preflightJson.RootElement.GetProperty("effectiveRange").GetString());
var finding = Assert.Single(
preflightJson.RootElement.GetProperty("findings").EnumerateArray().ToArray());
Assert.Equal("ExcludedContiguousColumns", finding.GetProperty("kind").GetString());
Assert.Equal("Warning", finding.GetProperty("severity").GetString());
Assert.True(finding.GetProperty("isHeuristic").GetBoolean());
Assert.Equal(
"$C$1:$C$3",
Assert.Single(finding.GetProperty("addresses").EnumerateArray().ToArray()).GetString());
Assert.False(string.IsNullOrWhiteSpace(finding.GetProperty("remediation").GetString()));
}
var createTableResult = await CallToolAsync("table", new Dictionary<string, object?>
{
["action"] = "create",
@@ -226,7 +252,7 @@ public class McpServerSmokeTests : IAsyncLifetime, IAsyncDisposable
["session_id"] = sessionId
});
AssertSuccess(listTablesResult, "List tables");
_output.WriteLine(" ✓ table: Create and List passed");
_output.WriteLine(" ✓ table: Preflight, Create, and List passed");
// =====================================================================
// STEP 6: NAMED RANGE OPERATIONS
@@ -816,4 +842,3 @@ End Sub
return json.RootElement.TryGetProperty(propertyName, out var prop) ? prop.GetString() : null;
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ Other tools (openpyxl-based MCP servers and Agent Skills, including Anthropic's
## Key features
The Excel MCP Server (excel-mcp) provides **31 specialized tools with 325 operations** for comprehensive Excel automation:
The Excel MCP Server (excel-mcp) provides **31 specialized tools with 326 operations** for comprehensive Excel automation:
- 🔄 **Power Query & M code** - Create, edit and optimize M code. Import from files, databases and APIs. Refresh queries and manage load destinations.
- 🧮 **Power Pivot & DAX** - Build Data Models, create DAX measures and manage table relationships. Full Power Pivot automation.
@@ -31,7 +31,7 @@ The Excel MCP Server (excel-mcp) provides **31 specialized tools with 325 operat
- 🐍 **Python in Excel** - Write and run `=PY()` formulas that execute in Excel's cloud Python engine — process worksheet data with pandas, NumPy and more, from your AI assistant.
- 🧪 **LLM-tested quality** - Tool behavior validated with real LLM workflows using [pytest-skill-engineering](https://github.com/sbroenne/pytest-skill-engineering), so AI assistants reliably understand and use every operation.
📚 **[See all 31 tools and 325 operations →](https://excelmcpserver.dev/features/)**
📚 **[See all 31 tools and 326 operations →](https://excelmcpserver.dev/features/)**
### Agent Skills (Bundled)