Skip to content

[BUG] screenshot capture/capture-sheet fails with COMException 0x800A03EC on a protected worksheet #777

Description

@fugulab

Bug Description

screenshot capture and capture-sheet fail with a bare COMException: 0x800A03EC when the target worksheet is protected. Unprotecting the sheet and repeating the same call succeeds — protection alone is the trigger, not the range, not the workbook, not whether Excel is visible.

v1.10.6 made this easy to reach by adding worksheet protection to the server. A caller can now protect a sheet and then be unable to capture it in the same session, with an error that doesn't explain itself. I'd expect the capture behaviour predates v1.10.6, but I only tested v1.10.7, so I can't call it a regression either way.

I searched existing issues for "screenshot", "protected worksheet" and "0x800A03EC" and didn't find a duplicate. #563 and #583 are the closest, and both are closed. #563 does list 0x800A03EC among its symptoms — there it was occasional, tied to particular ranges of a real .xlsm on v1.8.33, and mixed in with blank and junk images. This one is deterministic on a two-cell workbook and switches on and off with sheet protection alone.

Component

  • CLI (Command-line interface)
  • Core Library (Shared functionality)

Command/Usage

For CLI (verbatim, v1.10.7, <id> is the sessionId from the first command):

> excelcli session create C:\tmp\probe.xlsx
{"success":true,"sessionId":"<id>","filePath":"C:/tmp/probe.xlsx"}

> excelcli range set-values --session <id> --sheet Sheet1 --range A1:B1 --values [["hello","world"]]
{"action":"set-values","success":true}

> excelcli screenshot capture --session <id> --sheet Sheet1 --range A1:B1 --output C:\tmp\a.jpg
{"success":true,"outputPath":"C:/tmp/a.jpg","sizeBytes":2617,"width":134,"height":52,"mimeType":"image/jpeg","sheetName":"Sheet1","rangeAddress":"$A$1:$B$1"}

> excelcli worksheetstyle set-protection --session <id> --sheet Sheet1 --is-protected true
{"success":true}

> excelcli screenshot capture --session <id> --sheet Sheet1 --range A1:B1 --output C:\tmp\b.jpg
{"success":false,"error":"COMException: 0x800A03EC","hresult":"0x800A03EC"}

> excelcli worksheetstyle set-protection --session <id> --sheet Sheet1 --is-protected false
{"success":true}

> excelcli screenshot capture --session <id> --sheet Sheet1 --range A1:B1 --output C:\tmp\d.jpg
{"success":true,"outputPath":"C:/tmp/d.jpg","sizeBytes":2428,"width":134,"height":52,"mimeType":"image/jpeg"}

For MCP Server: tool screenshot, actions capture and capture-sheet, with worksheet_style set-protection to put the sheet in that state. Both entry points reach the same ScreenshotCommands path, but I reproduced this through the CLI: the desktop client on this machine still has the v1.10.5 server loaded in memory, so I have not re-run it over MCP on v1.10.7. Happy to do that after a restart if it matters.

Expected Behavior

Capturing a protected worksheet returns an image, the way it does when the sheet is unprotected. Protection restricts editing, and other read paths keep working while it is on: range get-values and conditionalformat list-worksheet-rules both returned normally on the same protected sheet.

If that isn't feasible, an error naming sheet protection would be enough. The current message gives the caller nothing to act on.

Actual Behavior

No image is returned. The session stays usable, and the next call after unprotecting succeeds.

Error Message

{
  "success": false,
  "error": "COMException: 0x800A03EC",
  "errorMessage": "COMException: 0x800A03EC",
  "errorCategory": "ComInterop",
  "command": "screenshot.capture",
  "sessionId": "<redacted>",
  "isError": true,
  "exceptionType": "COMException",
  "hresult": "0x800A03EC"
}

capture-sheet returns the same with "command": "screenshot.capture-sheet".

Boundary matrix

All on v1.10.7, same workbook, same range:

Call Result
capture, sheet unprotected ✅ image returned
capture, sheet protected COMException: 0x800A03EC
capture-sheet, sheet protected ❌ same
capture, after unprotecting ✅ image returned
capture, protected, Excel window shown via window show ❌ same
capture, unprotected, Excel window hidden ✅ image returned

The last two rows are there because I first assumed this was the known "CopyPicture needs a visible Excel" behavior. Visibility makes no difference in either direction, which matches the code: ExportRangeAsImage forces app.Visible = true before capturing and restores the previous state in its finally block, so session-level visibility cannot reach this failure.

Root cause

The export path creates a temporary ChartObject on the target worksheet:

// Copy range as picture (with retry — CopyPicture is clipboard-dependent
// and intermittently fails when Excel is still rendering after chart/table operations)
try { app.CutCopyMode = false; } catch (COMException) { }
CopyPictureWithRetry(app, sheet, range);
Thread.Sleep(250);
// Create a temporary ChartObject to paste into and export
chartObjects = sheet.ChartObjects();
chartObject = chartObjects.Add(0, 0, width, height);
chart = chartObject.Chart;
// Paste the copied picture into the chart
PastePictureWithRetry(app, sheet, range, chart);
// Clear clipboard immediately after paste — releases clipboard for subsequent screenshot calls
// (otherwise marching ants remain and next CopyPicture may fail with clipboard contention)
try { app.CutCopyMode = false; } catch (COMException) { }
// Export to temp file in the appropriate format
tempFile = Path.Combine(Path.GetTempPath(), $"excelmcp-screenshot-{Guid.NewGuid():N}.{fileExt}");
chart.Export(tempFile, exportFormat);

ChartObjects.Add is what Excel refuses on a protected sheet. Probing the calls directly through COM on this machine, with the sheet protected by Worksheet.Protect() with no arguments, which is what SetProtection calls:

Direct COM call on the protected sheet Result
Worksheet.ChartObjects().Add(0, 0, 150, 60) 0x800A03EC
Worksheet.Shapes.AddShape(...) 0x800A03EC
Range.CopyPicture(1, -4147) (xlScreen/xlPicture, the first mode the retry ladder tries) returns
Range.Copy() (the final fallback) returns

The DrawingObjects argument of Protect does not govern this. With Protect(DrawingObjects:=False), where ProtectDrawingObjects reads back False, Shapes.AddShape succeeds on the protected sheet while ChartObjects.Add still raises 0x800A03EC. Protect(UserInterfaceOnly:=True) is not an escape hatch either. ProtectContents looks like the whole trigger — convenient, since that is the property GetProtection already reads:

if (isProtected)
{
if (string.IsNullOrWhiteSpace(password))
{
sheet.Protect();
}
else
{
sheet.Protect(password);
}
}

Two things point away from CopyPicture being the throw site, independently of that probing:

  • CopyPictureWithRetry cannot emit a bare COMException. It swallows every one across four appearance/format modes and ten attempts, then across ten more Range.Copy() attempts, and finally throws an InvalidOperationException with a descriptive message. A raw HRESULT reaching the caller means the failure happened outside that method.
    private static void CopyPictureWithRetry(dynamic app, dynamic sheet, dynamic range)
    {
    COMException? lastException = null;
    (int Appearance, int Format)[] modes =
    [
    (XlScreen, XlPicture),
    (XlPrinter, XlPicture),
    (XlScreen, XlBitmap),
    (XlPrinter, XlBitmap)
    ];
    for (int attempt = 0; attempt < CopyPictureMaxRetries; attempt++)
    {
    foreach (var mode in modes)
    {
    try
    {
    try { app.CutCopyMode = false; } catch (COMException) { }
    range.CopyPicture(mode.Appearance, mode.Format);
    return;
    }
    catch (COMException ex)
    {
    lastException = ex;
    }
    }
    if (attempt < CopyPictureMaxRetries - 1)
    {
    Thread.Sleep(CopyPictureRetryDelayMs * (attempt + 1));
    PrepareRangeForCapture(app, sheet, range);
    }
    }
    for (int attempt = 0; attempt < CopyPictureMaxRetries; attempt++)
    {
    try
    {
    try { app.CutCopyMode = false; } catch (COMException) { }
    range.Copy();
    return;
    }
    catch (COMException ex)
    {
    lastException = ex;
    if (attempt < CopyPictureMaxRetries - 1)
    {
    Thread.Sleep(CopyPictureRetryDelayMs * (attempt + 1));
    PrepareRangeForCapture(app, sheet, range);
    }
    }
    }
    throw new InvalidOperationException(
    $"Excel could not capture the range after {CopyPictureMaxRetries} attempts because CopyPicture and the range copy fallback kept failing. " +
    "Excel may still be rendering, busy, minimized, or unable to access the clipboard. " +
    "Retry the screenshot after the workbook finishes refreshing, or capture a smaller range.",
    lastException);
    }
  • The timing agrees. Failing calls returned in 3.7 s against 4.6 s for successful captures — exhausting the CopyPicture ladder would take over 30 s in sleeps alone before its InvalidOperationException.

Directions, smallest change first:

  1. Catch it and return a message naming sheet protection, in the same spirit as the Python in Excel availability errors from [MCP] pythoninexcel: return a clear "Python in Excel unavailable" error instead of raw #NAME? #753.
  2. Host the temporary chart off the target sheet, on a scratch worksheet or a temporary chart sheet. I have not verified whether the paste and the export survive that move, so this is a direction rather than a tested one.
  3. Unprotect and restore around the export. A password-protected sheet can't be restored the same way, so this one would need a guard.

Which of these fits depends on how you want protection treated elsewhere in the server.

Environment

  • Windows Version: Windows 11 Pro (10.0.26200)
  • Excel Version: Microsoft 365 (Click-to-Run)
  • ExcelMcp Version: v1.10.7
  • .NET Version: standalone executables, no separate .NET runtime installed on this machine
  • Installation Method: Binary download, standalone excelcli.exe and mcp-excel.exe from the GitHub Release zips
  • File Format: .xlsx
  • AI Assistant (if using MCP Server): Claude Desktop

Sample File

Reproduced on a workbook created by session create with two cells set and nothing else — nothing useful to attach, but I can put one together if it helps.

The v1.10.6 drawing, page setup and PDF export actions let me drop a win32com detour I had been carrying for a while. Happy to retest after a fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions