Kookerella.FsOpenXmlDsl
MCP serverFiles & storageExcel tools for AI agents: read/write, decompile to F#/C#/XML/JSON, or build from XML/JSON.
Unavailable. This server has no hosted endpoint yet, so ahel can't serve it.
Connect ahel once, and every AI you use reads what you have installed.
From the project's README
As published by kookerella-ltd/kookerella.fsopenxmldsl in README.md.
A typesafe F# DSL for building Excel workbooks, interpreted into calls against the
DocumentFormat.OpenXml SDK. The DSL is a plain
data model (records/DUs with structural equality) — the interpreter (Writer) compiles it
to OOXML, and the reverse transform (Reader) parses an existing .xlsx back into the
same DSL.
See MAPPING.md for exactly which SpreadsheetML features map 1:1, which are approximated, and which aren't modeled yet.
This round-trips in both directions, which most Excel libraries (EPPlus, ClosedXML,
NPOI, ...) don't: they give you an imperative API to build a workbook from scratch or mutate
an existing one, but no way to turn an existing file back into readable source. Here,
Reader parses a real .xlsx/.xlsm back into the same DSL, and Workbook.generateScript
(F#) / CsCodeGen.Generate (C#) go one step further and render that model back out as a
self-contained script that rebuilds an equivalent file - a decompiler for spreadsheets, not
just a writer. Two more surfaces, Xml.ofWorkbook/Xml.toWorkbook (see "## XML"
below) and Json.ofWorkbook/Json.toWorkbook (see "## JSON" below), do the same
translation to/from plain XML or JSON against a real schema - for a caller who'd rather
generate or consume data than write code at all, e.g. an XSLT pipeline producing a report.
Kookerella.FsOpenXmlDsl.Mcp exposes all four directions as MCP tools
(generate_fsharp_script/generate_csharp_script/generate_xml/create_workbook_from_xml/
generate_json/create_workbook_from_json) for an AI agent, and as fsopenxmldsl-mcp convert/build CLI commands for anyone else - try it on any spreadsheet you already have,
no code required:
dotnet tool install -g Kookerella.FsOpenXmlDsl.Mcp
fsopenxmldsl-mcp convert your-file.xlsx --lang csharp
Demos
Full worked examples of the decompile-then-extend workflow above - reverse-engineering the same invoice template into C#, F#, an XSLT transform, and a plain JSON-generation script, each wired up to real data and real tests proving the result stays schema-valid - live in a companion repo: Kookerella.Demo.DecompileToSource.
Layout
src/Kookerella.FsOpenXmlDsl— the library.Reference.fs—CellRefand"A1"-style address conversions.Styles.fs— cell formatting:Color,FontStyle,FillStyle,BorderStyle,AlignmentStyle,NumberFormat,CellProtection,CellStyle.Validation.fs— conditional formatting and data validation:ComparisonOperator(shared by both),ConditionalFormatRule,ValidationKind,ValidationAlert, and theConditionalFormatEntry/DataValidationEntryrecords stored onWorksheet.Hyperlinks.fs—HyperlinkTarget(external URL/mailto:vs. internal same-workbook reference) and theHyperlinkEntryrecord stored onWorksheet.Comments.fs—CommentEntry(classic cell comments, i.e. current Excel's "Notes" - see MAPPING.md for the modern threaded-comments gap).Protection.fs—SheetProtection, the sheet-level protection flags stored onWorksheet(pairs withCellStyle.Protectionfor per-cell locking), andWorkbookProtection, the workbook-level structure/window protection flags stored onWorkbook.DefinedNames.fs—DefinedNameScope/DefinedNameEntry, stored onWorkbookrather thanWorksheet- the one DSL concept that's genuinely workbook-level.PageSetup.fs— print settings:PageOrientation,PaperSize,PrintScaling,PageMargins, and thePageSetuprecord stored onWorksheet.Tables.fs— Excel Tables:TableColumn,TableStyle, and theTableEntryrecord stored as a list onWorksheet(a sheet can have several).Sparklines.fs— in-cell mini-charts:SparklineType,SparklineStyle,SparklineCell, and theSparklineGroupEntryrecord stored as a list onWorksheet(a sheet can have several independently-styled groups).Charts.fs— column/bar/line/pie charts:ChartType,ChartSeries, and theChartEntryrecord stored as a list onWorksheet(a sheet can have several).Images.fs— raster images:ImageFormatand theImageEntryrecord (raw file bytes plus a cell-range anchor) stored as a list onWorksheet.PivotTables.fs—PivotAggregationand thePivotTableEntryrecord (source range, row/column/value fields, an anchor cell) stored as a list onWorksheet.Model.fs—CellValue,Cell,Worksheet,Workbook(includingWorkbook. VbaProject, a macro-enabled workbook's rawvbaProject.binbytes - see its own doc comment; there's no dedicatedMacros.fssince it's a single opaque field, not a new type).Xml.fs/Xml.xsd— the XML surface:Xml.toWorkbook/Xml.ofWorkbooktranslate aWorkbookto/from anXElementtree, andXml.schemaSet()loads the paired schema (embedded in the assembly as a resource) for validating either direction. See "## XML" below.Json.fs— the JSON surface:Json.toWorkbook/Json.ofWorkbooktranslate aWorkbookto/from aSystem.Text.Json.Nodes.JsonObjecttree, covering the same worksheet/workbook-level feature setXml.fsdoes. Schema validation (Json.schema.json) is test-suite only, not a public API - see "## JSON" below.Builders.fs— ergonomic helpers: plain functional constructors (cellA1, ...) for the canonical model, plus theSheetItem/CellEntrytypes (each a single simple DU case with optional fields) and thesheetfold function - a small tree-shaped "AST for building a sheet" (rows of cells, plus sheet-level facts like column widths, merges, conditional formats, data validations, hyperlinks, comments, autofilter, and sheet protection) that mirrors how SpreadsheetML itself nests.SheetDslis what you actually write against:cell/row/autoFilter/conditionalFormat/dataValidation/hyperlink/commentmembers with real optional parameters (?col,?style,?index, the data validation alert fields,?tooltip,?author) - no builder objects, no separate "styled" function, noNone-noise for the common case. (Protectis the oneSheetItemcase with no smart constructor -SheetProtectionis a plain record you build the usual F# way,{ SheetProtection.Default with ... }.)Interpreter/StyleRegistry.fs— interns fonts/fills/borders/number formats into a shared OOXML stylesheet (internal).Interpreter/ChartWriter.fs/ChartReader.fs— charts' own DSL ↔ DrawingML/ChartML translation, split out fromWriter.fs/Reader.fsgiven how much larger that one feature's OOXML surface is than everything else combined (internal).Interpreter/ImageWriter.fs/ImageReader.fs— images' own DSL ↔ DrawingML translation (internal).Interpreter/DrawingWriter.fs/DrawingReader.fs— own the oneDrawingsPart/<drawing>relationship a worksheet gets when it has charts and/or images, since both features share that one drawing canvas rather than each managing their own (internal).Interpreter/PivotTableWriter.fs/PivotTableReader.fs— pivot tables' own group-by- aggregate engine plus DSL ↔ OOXML translation (
pivotCacheDefinition/pivotCacheRecords/pivotTableDefinition), split out fromWriter.fs/Reader.fsthe same way charts and images are (internal).
- aggregate engine plus DSL ↔ OOXML translation (
Interpreter/Writer.fs— DSL → OOXML (internal).Interpreter/Reader.fs— OOXML → DSL, the reverse transform (internal).Interpreter/CodeGen.fs— DSL → F# source text: renders aWorkbookback out as a self-contained.fsxscript that rebuilds an equivalent file when run (internal).Api.fs— the publicWorkbook.save/saveToStream/load/loadFromStream/generateScriptentry points.
tests/Kookerella.FsOpenXmlDsl.Tests— one test per feature, each validating the produced file against the OOXML schema (DocumentFormat.OpenXml.Validation.OpenXmlValidator) and asserting an exact round trip back through the DSL. Each test also writes the workbook it builds toExamples/<test name>/output.xlsx(checked into the repo), so every feature has a real, openable.xlsxdemonstrating it - a browsable gallery, not just assertions. Each scenario also gets anExamples/<test name>/script.fsx- see "Regenerating a file as F# source" below - which a separate, slowerCategory=Slowtest group actually executes viadotnet fsiand verifies against the committed.xlsx, and anExamples/<test name>/workbook.xml- the same workbook throughXml.ofWorkbook, validated againstXml.xsdat generation time (see "## XML" below) - and anExamples/<test name>/workbook.json- the same workbook throughJson.ofWorkbook, validated againstJson.schema.jsonat generation time (see "## JSON" below) - so one folder always has four views of the same example: the real file, the F# source that rebuilds it, and the XML/JSON that also rebuild it.Assets/holds the one test fixture too large to inline as a base64 literal like every other binary fixture inTests.fs- a realvbaProject.binextracted from a workbook actually saved by Excel, used by the macro example.samples/Kookerella.FsOpenXmlDsl.Sample— a small console app that builds a workbook, saves it, and reads it back.src/Kookerella.CsOpenXmlDsl— an idiomatic, immutable, fluent C# wrapper over this library, for callers who'd rather not touch F# discriminated unions/option types directly. Now covers every feature this library models at the worksheet/workbook level - see its own README for scope and an example.tests/Kookerella.CsOpenXmlDsl.Testsis its own C# xUnit suite, exercising the wrapper the way a real C# caller would rather than reusing the F# test project.src/Kookerella.FsOpenXmlDsl.Mcp— a local MCP (Model Context Protocol) server exposing this library's read/write/code-generation/XML/JSON capabilities as tools any MCP-compatible AI agent can call directly, and the same conversion capability as plainfsopenxmldsl-mcp convert/buildCLI commands for anyone not going through an MCP client - see its own README for the tool list and how to configure it.
Quick start
open Kookerella.FsOpenXmlDsl
open type Kookerella.FsOpenXmlDsl.SheetDsl
let headerStyle =
{ CellStyle.Default with
Font = Some { FontStyle.Default with Bold = true }
Fill = Some { Color = Rgb(220uy, 220uy, 220uy) } }
let data =
sheet
"Sheet1"
[ row [ cell (Text "Name", style = headerStyle)
cell (Text "Amount", style = headerStyle) ]
row [ cell (Text "Widgets")
cell (Number 42.5, style = { CellStyle.Default with NumberFormat = Some TwoDecimal }) ]
Freeze(1, 0) ]
workbook [ data ] |> Workbook.save "out.xlsx"
// Reverse transform:
let roundTripped = Workbook.load "out.xlsx"
CellEntry and SheetItem's row case are each a single simple DU case with optional
fields (Col/Index) rather than separate "styled" or "explicit position" cases - None
means "the next column/row after the previous entry" (starting at 0), Some n jumps there
explicitly and sequential numbering resumes right after it. You don't construct the case
directly, though: SheetDsl.cell/SheetDsl.row are members with real optional
parameters (?col/?style on cell, ?index on row) that hide the Nones for
the common case - plain let functions can't have optional parameters in F#, which is why
this one bit of the DSL is a type. open type Kookerella.FsOpenXmlDsl.SheetDsl (alongside open Kookerella.FsOpenXmlDsl) brings cell/row
into scope unqualified, same as a module. Explicit column/row jumps go through the same
two members, just with the optional argument supplied: cell (value, col = 2) and
row (cells, index = 4). sheet is the one fold that interprets the resulting item
list into the canonical Worksheet (the same relationship Writer has to OOXML). If you
already have cells pre-addressed by CellRef rather than grouped by row, sheetOfCells
builds a Worksheet directly from a flat Cell list instead.
A Formula cell is Formula(expression, cachedValue: float option) - this library never
evaluates formulas itself, so cachedValue is the only number that will ever exist for that
cell until something else computes one. Real Excel recalculates on open and overwrites it,
so leaving it None is fine if a human always opens the result in Excel first. It's not
safe for a headless pipeline - e.g. generating a workbook and piping it straight into a PDF
converter, another automated reader, or anything else that never opens it in real Excel.
Whether that downstream step shows a correct number, a blank, or a stale one depends
entirely on whether it happens to have its own formula engine; some do (Aspose.Cells,
Syncfusion, GemBox, real Excel via COM), many lighter-weight or headless converters don't and
will just render whatever's already in the cell. Since you already have the numbers that fed
into the formula, always pass the real result as cachedValue for anything that isn't
guaranteed to pass through Excel first - it costs nothing and sidesteps the problem
entirely, since a downstream reader with no evaluator at all can still show a correct value
someone else already computed.
Conditional formatting and data validation are SheetItems too:
[ conditionalFormat (
CellRef.ofA1 "A1",
CellRef.ofA1 "A10",
CellValueRule(GreaterThan, "100", None, { CellStyle.Default with Fill = Some { Color = Rgb(255uy, 199uy, 206uy) } })
)
dataValidation (CellRef.ofA1 "B1", CellRef.ofA1 "B10", ListValidation [ "Small"; "Medium"; "Large" ]) ]
See MAPPING.md for exactly which rule kinds of each are covered.
Defined names are workbook-level, so they attach to the Workbook, not a Worksheet:
workbook [ data ]
|> withDefinedNames
[ definedName "TaxRate" "Sheet1!$A$1"
sheetScopedDefinedName "Sheet1" "LocalTotal" "Sheet1!$A$2" ]
Workbook-level protection (as distinct from a Worksheet's own SheetProtection) is
also workbook-level, same pipe-friendly shape:
workbook [ data ]
|> withProtection { WorkbookProtection.Default with LockStructure = Some true }
withDefinedNames/withProtection compose - pipe both onto the same workbook [...].
Macros are also workbook-level, same pipe-friendly shape - withVbaProject takes the raw
bytes of an existing vbaProject.bin (extracted from an .xlsm you already have, e.g. via
System.IO.Compression.ZipFile, or authored in Excel's VBA editor and harvested the same
way). Core doesn't decode, generate, or otherwise understand VBA source - it embeds and
reads back exactly the bytes you give it, the same "opaque payload" treatment
ImageEntry.Data gets for raster images:
workbook [ data ]
|> withVbaProject (System.IO.File.ReadAllBytes("vbaProject.bin"))
Save the result with an .xlsm path - Workbook.save/saveToStream automatically switch
the file's own declared content type to Excel's macro-enabled kind whenever a VbaProject
is present, but real Excel also expects the .xlsm extension to trust and run macros at
all. See MAPPING.md for what isn't modeled (authoring macro source, and the
one case where the default sheet/workbook codenames Core writes won't match what a macro's
original author intended).
Print settings are a SheetItem too - PageSetup (the DU case) takes a plain
PageSetup record (the type), no smart constructor, same as Protect/SheetProtection.
PrintArea is a list of ranges (Excel supports several disjoint print rectangles per
sheet) - under the hood it's actually a hidden defined name, but Writer/Reader
translate transparently, so it reads and writes like any other PageSetup field:
[ PageSetup
{ PageSetup.Default with
Orientation = Landscape
Scaling = Some(FitToPage(1, 0)) // 1 page wide, unlimited tall
PrintArea = [ (CellRef.ofA1 "A1", CellRef.ofA1 "D10") ]
Header = Some "&C&\"Arial,Bold\"Quarterly Report"
FirstHeader = Some "&CCover Page" // shown only on page 1
EvenFooter = Some "&L&F" } ] // shown only on even pages
See MAPPING.md for what isn't modeled (totals-row/headerless tables, and a
handful of minor pageSetup attributes like print page order).
Tables are also a SheetItem - Table (the DU case) takes a plain TableEntry record
(the type), no smart constructor, same as Protect/PageSetup. Core doesn't synthesize
the header row's cell text for you, so it must already be there as ordinary cells - the
same way conditional formatting/autofilter/merges only describe metadata layered on top of
cells you've already placed:
sheet
"Sheet1"
[ row [ cell (Text "Item"); cell (Text "Quantity") ]
row [ cell (Text "Widgets"); cell (Number 12.0) ]
Table
{ TopLeft = CellRef.ofA1 "A1"
BottomRight = CellRef.ofA1 "B2"
Name = "Inventory"
Columns = [ { Name = "Item"; CalculatedFormula = None }; { Name = "Quantity"; CalculatedFormula = None } ]
Style = TableStyle.Default } ]
Structured references (Table1[Column]) need no special handling - they're just raw
formula text in a Formula cell, same as any other formula. See MAPPING.md
for what isn't modeled (totals row, headerless tables).
Sparklines follow the same shape - SparklineGroup (the DU case) takes a plain
SparklineGroupEntry record:
[ SparklineGroup
{ Style = { SparklineStyle.Default with Type = Column; ShowNegative = true }
Sparklines =
[ { Cell = CellRef.ofA1 "E1"; DataTopLeft = CellRef.ofA1 "A1"; DataBottomRight = CellRef.ofA1 "D1" } ] } ]
Sparklines are a Microsoft extension (living in the worksheet's extLst), not core
SpreadsheetML - unlike the rest of this library, schema validation alone can't confirm
real Excel renders one correctly, so treat this one with a bit more caution and verify in
real Excel before relying on it. See MAPPING.md for what isn't modeled
(axis settings, per-role colors beyond the main series color).
Charts are the same shape too - EmbeddedChart (not bare Chart, which collides with
the OOXML SDK's own type - see Builders.fs) takes a plain ChartEntry record. A
series' Name is a reference to the cell that names it (its column header, typically),
live-updating the same way a real Excel chart's series name does - not a static copy:
[ EmbeddedChart
{ Type = ChartColumn
Title = Some "Sales by Quarter"
CategoriesTopLeft = CellRef.ofA1 "A2"
CategoriesBottomRight = CellRef.ofA1 "A4"
Series = [ { Name = CellRef.ofA1 "B1"; ValuesTopLeft = CellRef.ofA1 "B2"; ValuesBottomRight = CellRef.ofA1 "B4" } ]
ShowLegend = true
TopLeftAnchor = CellRef.ofA1 "E1"
BottomRightAnchor = CellRef.ofA1 "L15" } ]
Unlike Sparklines, charts are core, fully schema-driven DrawingML/ChartML - built from typed OOXML SDK classes the same way every other feature is, not an extension mechanism. See MAPPING.md for what isn't modeled (chart kinds beyond column/bar/line/ pie, per-series styling, stacked grouping).
Images are anchored the same way - EmbeddedImage takes a plain ImageEntry record.
Data is just the image file's own raw bytes (read it with System.IO.File.ReadAllBytes,
for example) - this DSL doesn't decode or re-encode anything, only embeds and hands back
exactly what you give it:
[ EmbeddedImage
{ Data = System.IO.File.ReadAllBytes("logo.png")
Format = Png
TopLeftAnchor = CellRef.ofA1 "A1"
BottomRightAnchor = CellRef.ofA1 "C6" } ]
A worksheet's charts and images share one drawing canvas under the hood (Excel only gives
a sheet one at all), which is transparent to you as a caller - just add both kinds of
SheetItem to the same sheet. See MAPPING.md for what isn't modeled (formats
beyond PNG/JPEG/GIF/BMP, free-floating position, cropping, linked-not-embedded images).
Pivot tables are also a SheetItem - EmbeddedPivotTable (not bare PivotTable, again
for naming consistency with EmbeddedChart/EmbeddedImage) takes a plain PivotTableEntry
record. Unlike every other feature, this one does real work at write time rather than a
pure translation: it groups the source range by RowField (and ColumnField, if given),
aggregates ValueField, and writes both a real Excel pivot cache and the resulting grid of
computed cells:
[ EmbeddedPivotTable
{ SourceSheet = None // defaults to this sheet; can name another
SourceTopLeft = CellRef.ofA1 "A1"
SourceBottomRight = CellRef.ofA1 "C5"
RowField = "Region"
ColumnField = Some "Quarter"
ValueField = "Sales"
Aggregation = PivotSum
ValueCaption = Some "Total Sales"
TopLeftAnchor = CellRef.ofA1 "E1" } ]
The source range's first row must be plain Text header cells naming each field. This is
deliberately scoped to what a single field per axis can express - one row field, at most
one column field, one value field, Tabular layout, grand totals only - see
MAPPING.md for the reasoning and what a richer pivot table (nested fields,
multiple value fields, page filters) would need instead.
Regenerating a file as F# source
Given a Workbook (typically one you just Workbook.loaded from an existing file),
Workbook.generateScript renders it back out as a self-contained .fsx script that
rebuilds an equivalent file when run - a code-generating counterpart to Workbook.load,
one level further than the reverse transform: instead of data, you get DSL source text.
It has no opinion on how the script locates the FsOpenXmlDsl assembly, so you supply the
#r lines yourself:
let wb = Workbook.load "input.xlsx"
let referenceLines =
[ "#r \"path/to/Kookerella.FsOpenXmlDsl.dll\""
"#r \"path/to/DocumentFormat.OpenXml.dll\"" ]
let script = Workbook.generateScript referenceLines "output.xlsx" wb
System.IO.File.WriteAllText("regenerate.fsx", script)
Running dotnet fsi regenerate.fsx produces output.xlsx - not byte-identical to the
original (zip metadata/timestamps differ) but structurally equivalent through the same
round-trip lens every other test in this repo uses. Generated code only ever mentions
fields that differ from CellStyle.Default/BorderStyle.None/etc., and only gives a
row/cell an explicit index/col where the source actually has a gap - see
Interpreter/CodeGen.fs. Every scenario under tests/Kookerella.FsOpenXmlDsl.Tests/Examples/ has a
committed script.fsx generated exactly this way; the Category=Slow test group is what
actually runs each one via dotnet fsi and checks it reproduces the committed .xlsx.
XML
Xml.toWorkbook/Xml.ofWorkbook (in Xml.fs) are a third way in and out of the DSL,
alongside writing F#/C# directly and code generation: plain XML, against a real schema
(Xml.xsd, embedded in the assembly). This exists for a caller who'd rather generate or
consume data than write code at all. Two concrete uses:
Shortened here. Read the whole README on GitHub.
Signals
- GitHub stars
- 7
- Last commit
- Sep 2026
ahel review
S4low
published under marknicholls's namespace; repository belongs to kookerella-ltd
Automated review, not a security audit. Ruleset v1.
Advanced
- Delivery
- fsopenxmldsl-mcp MCP server → your ahel gateway (mcp.ahel.ai) → every connected AI client.
- Catalog kind
- mcp-server
- Gateway key
io-github-marknicholls-fsopenxmldsl-mcp- Source
- github.com/kookerella-ltd/kookerella.fsopenxmldsl