├── .dockerignore
├── .npmignore
├── docs
├── img
│ ├── icon-400.png
│ └── icon-800.png
└── design
│ └── excel-style-schema.md
├── .devcontainer
├── postCreateCommand.sh
├── devcontainer.json
├── Dockerfile
└── init-firewall.sh
├── Dockerfile
├── .gitignore
├── .editorconfig
├── cmd
└── excel-mcp-server
│ └── main.go
├── smithery.yaml
├── tsconfig.json
├── internal
├── tools
│ ├── config.go
│ ├── excel_copy_sheet.go
│ ├── excel_create_table.go
│ ├── excel_describe_sheets.go
│ ├── excel_screen_capture.go
│ ├── excel_read_sheet.go
│ ├── excel_write_to_sheet.go
│ ├── excel_format_range.go
│ └── common.go
├── mcp
│ └── error.go
├── server
│ └── server.go
└── excel
│ ├── util.go
│ ├── pagination.go
│ ├── excel.go
│ ├── excel_excelize.go
│ └── excel_ole.go
├── projectBrief.md
├── package.json
├── .goreleaser.yaml
├── go.mod
├── launcher
└── launcher.ts
├── LICENSE
├── .github
└── workflows
│ └── publish.yml
├── CLAUDE.md
├── go.sum
└── README.md
/.dockerignore:
--------------------------------------------------------------------------------
1 | /dist
2 | /node_modules
3 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | /.github
2 | /cmd
3 | /internal
4 | /go.mod
5 | /go.sum
6 | /.goreleaser.yaml
7 |
--------------------------------------------------------------------------------
/docs/img/icon-400.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/negokaz/excel-mcp-server/HEAD/docs/img/icon-400.png
--------------------------------------------------------------------------------
/docs/img/icon-800.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/negokaz/excel-mcp-server/HEAD/docs/img/icon-800.png
--------------------------------------------------------------------------------
/.devcontainer/postCreateCommand.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -ex
4 |
5 | npm install -g @anthropic-ai/claude-code
6 | npm install -g @goreleaser/goreleaser
7 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 |
2 | FROM node:20-slim AS release
3 |
4 | # Set the working directory
5 | WORKDIR /app
6 |
7 | RUN npm install -g @negokaz/excel-mcp-server@0.12.0
8 |
9 | # Command to run the application
10 | ENTRYPOINT ["excel-mcp-server"]
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 | /memory-bank
4 | # Added by goreleaser init:
5 | dist/
6 | # RooFlow
7 | .roo
8 | .roomodes
9 | .clinerules-default
10 | # repomix
11 | repomix-output.xml
12 | # Claude Code
13 | CLAUDE.local.md
14 | .claude/settings.local.json
15 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | indent_style = space
9 | indent_size = 2
10 | end_of_line = lf
11 | trim_trailing_whitespace = true
12 | insert_final_newline = true
13 |
14 | [*.md]
15 | trim_trailing_whitespace = false
16 | indent_size = 4
17 |
--------------------------------------------------------------------------------
/cmd/excel-mcp-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/negokaz/excel-mcp-server/internal/server"
8 | )
9 |
10 | var (
11 | version = "dev"
12 | )
13 |
14 | func main() {
15 | s := server.New(version)
16 | err := s.Start()
17 | if err != nil {
18 | fmt.Fprintf(os.Stderr, "Failed to start the server: %v\n", err)
19 | os.Exit(1)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2 |
3 | startCommand:
4 | type: stdio
5 | configSchema:
6 | # JSON Schema defining the configuration options for the MCP.
7 | {}
8 | commandFunction:
9 | # A function that produces the CLI command to start the MCP on stdio.
10 | |-
11 | (config) => ({ command: 'node', args: ['dist/launcher.js'], env: {} })
12 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Claude Code Dev Container",
3 | "build": {
4 | "dockerfile": "Dockerfile"
5 | },
6 | "features": {
7 | "ghcr.io/devcontainers/features/go:1":{}
8 | },
9 | "mounts": [
10 | "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind,consistency=cached"
11 | ],
12 | "postCreateCommand": ".devcontainer/postCreateCommand.sh",
13 | "remoteUser": "node"
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "resolveJsonModule": true,
11 | "outDir": "./dist",
12 | "rootDir": "./launcher",
13 | },
14 | "include": ["launcher/**/*"],
15 | "exclude": ["node_modules"]
16 | }
17 |
--------------------------------------------------------------------------------
/internal/tools/config.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | z "github.com/Oudwins/zog"
5 | "github.com/Oudwins/zog/zenv"
6 | )
7 |
8 | type EnvConfig struct {
9 | EXCEL_MCP_PAGING_CELLS_LIMIT int
10 | }
11 |
12 | var configSchema = z.Struct(z.Shape{
13 | "EXCEL_MCP_PAGING_CELLS_LIMIT": z.Int().GT(0).Default(4000),
14 | })
15 |
16 | func LoadConfig() (EnvConfig, z.ZogIssueMap) {
17 | config := EnvConfig{}
18 | issues := configSchema.Parse(zenv.NewDataProvider(), &config)
19 | return config, issues
20 | }
21 |
--------------------------------------------------------------------------------
/projectBrief.md:
--------------------------------------------------------------------------------
1 | # Excel MCP Server プロジェクト概要
2 |
3 | ## プロジェクトの目的
4 | Model Context Protocol (MCP) を使用してMicrosoft Excelファイルの読み書きを行うサーバーを提供する。
5 |
6 | ## 主な機能
7 | - Excelファイルからのテキストデータの読み取り
8 | - Excelファイルへのテキストデータの書き込み
9 | - ページネーション機能によるデータの効率的な取り扱い
10 |
11 | ## 対応ファイル形式
12 | - xlsx (Excel book)
13 | - xlsm (Excel macro-enabled book)
14 | - xltx (Excel template)
15 | - xltm (Excel macro-enabled template)
16 |
17 | ## 技術スタック
18 | - 開発言語: Go
19 | - フレームワーク/ライブラリ:
20 | - goxcel: Excel操作用ライブラリ
21 | - その他Go標準ライブラリ
22 |
23 | ## 要件
24 | - Node.js 20.x 以上(実行環境)
25 |
26 | ## インストール方法
27 | - NPM経由でのインストール
28 | - Smithery経由でのインストール(Claude Desktop向け)
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@negokaz/excel-mcp-server",
3 | "version": "0.12.0",
4 | "description": "An MCP server that reads and writes spreadsheet data to MS Excel file",
5 | "author": "negokaz",
6 | "license": "MIT",
7 | "bin": {
8 | "excel-mcp-server": "dist/launcher.js"
9 | },
10 | "scripts": {
11 | "build": "goreleaser build --snapshot --clean && tsc",
12 | "watch": "tsc --watch",
13 | "debug": "npx @modelcontextprotocol/inspector dist/launcher.js"
14 | },
15 | "devDependencies": {
16 | "@types/node": "^22.13.4",
17 | "typescript": "^5.7.3"
18 | },
19 | "publishConfig": {
20 | "access": "public"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/internal/mcp/error.go:
--------------------------------------------------------------------------------
1 | package mcp
2 |
3 | import (
4 | "fmt"
5 |
6 | z "github.com/Oudwins/zog"
7 | "github.com/mark3labs/mcp-go/mcp"
8 | )
9 |
10 | func NewToolResultInvalidArgumentError(message string) *mcp.CallToolResult {
11 | return mcp.NewToolResultError(fmt.Sprintf("Invalid argument: %s", message))
12 | }
13 |
14 | func NewToolResultZogIssueMap(errs z.ZogIssueMap) *mcp.CallToolResult {
15 | issues := z.Issues.SanitizeMap(errs)
16 |
17 | var issueResults []mcp.Content
18 | for k, messages := range issues {
19 | for _, message := range messages {
20 | issueResults = append(issueResults, mcp.NewTextContent(fmt.Sprintf("Invalid argument: %s: %s", k, message)))
21 | }
22 | }
23 |
24 | return &mcp.CallToolResult{
25 | Content: issueResults,
26 | IsError: true,
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/internal/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "runtime"
5 |
6 | "github.com/mark3labs/mcp-go/server"
7 | "github.com/negokaz/excel-mcp-server/internal/tools"
8 | )
9 |
10 | type ExcelServer struct {
11 | server *server.MCPServer
12 | }
13 |
14 | func New(version string) *ExcelServer {
15 | s := &ExcelServer{}
16 | s.server = server.NewMCPServer(
17 | "excel-mcp-server",
18 | version,
19 | )
20 | tools.AddExcelDescribeSheetsTool(s.server)
21 | tools.AddExcelReadSheetTool(s.server)
22 | if runtime.GOOS == "windows" {
23 | tools.AddExcelScreenCaptureTool(s.server)
24 | }
25 | tools.AddExcelWriteToSheetTool(s.server)
26 | tools.AddExcelCreateTableTool(s.server)
27 | tools.AddExcelCopySheetTool(s.server)
28 | tools.AddExcelFormatRangeTool(s.server)
29 | return s
30 | }
31 |
32 | func (s *ExcelServer) Start() error {
33 | return server.ServeStdio(s.server)
34 | }
35 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | before:
4 | hooks:
5 | # You may remove this if you don't use go modules.
6 | - go mod tidy
7 | # you may remove this if you don't need go generate
8 | - go generate ./...
9 |
10 | builds:
11 | - env:
12 | - CGO_ENABLED=0
13 | main: ./cmd/excel-mcp-server/main.go
14 | goos:
15 | - linux
16 | - windows
17 | - darwin
18 |
19 | archives:
20 | - formats: ['tar.gz']
21 | # this name template makes the OS and Arch compatible with the results of `uname`.
22 | name_template: >-
23 | {{ .ProjectName }}_
24 | {{- title .Os }}_
25 | {{- if eq .Arch "amd64" }}x86_64
26 | {{- else if eq .Arch "386" }}i386
27 | {{- else }}{{ .Arch }}{{ end }}
28 | {{- if .Arm }}v{{ .Arm }}{{ end }}
29 | # use zip for windows archives
30 | format_overrides:
31 | - goos: windows
32 | formats: ['zip']
33 | builds_info:
34 | mode: 0755
35 |
36 | changelog:
37 | sort: asc
38 | filters:
39 | exclude:
40 | - "^docs:"
41 | - "^test:"
42 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/negokaz/excel-mcp-server
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.0
6 |
7 | require (
8 | github.com/Oudwins/zog v0.21.4
9 | github.com/go-ole/go-ole v1.3.0
10 | github.com/goccy/go-yaml v1.18.0
11 | github.com/mark3labs/mcp-go v0.34.0
12 | github.com/skanehira/clipboard-image v1.0.0
13 | github.com/xuri/excelize/v2 v2.9.2-0.20250717000717-dd07139785fe
14 | )
15 |
16 | require (
17 | github.com/google/uuid v1.6.0 // indirect
18 | github.com/richardlehane/mscfb v1.0.4 // indirect
19 | github.com/richardlehane/msoleps v1.0.4 // indirect
20 | github.com/spf13/cast v1.9.2 // indirect
21 | github.com/tiendc/go-deepcopy v1.6.1 // indirect
22 | github.com/xuri/efp v0.0.1 // indirect
23 | github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
24 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
25 | golang.org/x/crypto v0.40.0 // indirect
26 | golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
27 | golang.org/x/net v0.42.0 // indirect
28 | golang.org/x/sys v0.34.0 // indirect
29 | golang.org/x/text v0.27.0 // indirect
30 | )
31 |
--------------------------------------------------------------------------------
/launcher/launcher.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import * as path from 'path'
3 | import * as childProcess from 'child_process'
4 |
5 | const BINARY_DISTRIBUTION_PACKAGES: any = {
6 | win32_ia32: "excel-mcp-server_windows_386_sse2",
7 | win32_x64: "excel-mcp-server_windows_amd64_v1",
8 | win32_arm64: "excel-mcp-server_windows_arm64_v8.0",
9 | darwin_x64: "excel-mcp-server_darwin_amd64_v1",
10 | darwin_arm64: "excel-mcp-server_darwin_arm64_v8.0",
11 | linux_ia32: "excel-mcp-server_linux_386_sse2",
12 | linux_x64: "excel-mcp-server_linux_amd64_v1",
13 | linux_arm64: "excel-mcp-server_linux_arm64_v8.0",
14 | }
15 |
16 | function getBinaryPath(): string {
17 | const suffix = process.platform === 'win32' ? '.exe' : '';
18 | const pkg = BINARY_DISTRIBUTION_PACKAGES[`${process.platform}_${process.arch}`];
19 | if (pkg) {
20 | return path.resolve(__dirname, pkg, `excel-mcp-server${suffix}`);
21 | } else {
22 | throw new Error(`Unsupported platform: ${process.platform}_${process.arch}`);
23 | }
24 | }
25 |
26 | childProcess.execFileSync(getBinaryPath(), process.argv, {
27 | stdio: 'inherit',
28 | });
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2025 Kazuki Negoro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Package to npmjs
2 | on:
3 | push:
4 | tags:
5 | - 'v*'
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | with:
13 | fetch-depth: 0
14 | - name: Set up Go
15 | uses: actions/setup-go@v5
16 | with:
17 | go-version: stable
18 |
19 | - name: Install GoReleaser
20 | uses: goreleaser/goreleaser-action@v6
21 | with:
22 | install-only: true
23 | distribution: goreleaser
24 | # 'latest', 'nightly', or a semver
25 | version: "~> v2"
26 |
27 | - name: Setup Node.js
28 | uses: actions/setup-node@v4
29 | with:
30 | node-version: '20.x'
31 | registry-url: 'https://registry.npmjs.org'
32 |
33 | - name: Install dependencies
34 | run: npm ci
35 |
36 | - name: Build
37 | run: npm run build
38 |
39 | - name: Publish to npmjs
40 | run: npm publish --no-git-checks
41 | env:
42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
43 |
44 | - name: Create GitHub Releases
45 | run: goreleaser release --clean
46 | env:
47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48 |
--------------------------------------------------------------------------------
/internal/excel/util.go:
--------------------------------------------------------------------------------
1 | package excel
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path"
7 | "regexp"
8 |
9 | "github.com/xuri/excelize/v2"
10 | )
11 |
12 | // parseRange parses Excel's range string (e.g. A1:C10 or A1)
13 | func ParseRange(rangeStr string) (int, int, int, int, error) {
14 | re := regexp.MustCompile(`^(\$?[A-Z]+\$?\d+)(?::(\$?[A-Z]+\$?\d+))?$`)
15 | matches := re.FindStringSubmatch(rangeStr)
16 | if matches == nil {
17 | return 0, 0, 0, 0, fmt.Errorf("invalid range format: %s", rangeStr)
18 | }
19 | startCol, startRow, err := excelize.CellNameToCoordinates(matches[1])
20 | if err != nil {
21 | return 0, 0, 0, 0, err
22 | }
23 |
24 | if matches[2] == "" {
25 | // Single cell case
26 | return startCol, startRow, startCol, startRow, nil
27 | }
28 |
29 | endCol, endRow, err := excelize.CellNameToCoordinates(matches[2])
30 | if err != nil {
31 | return 0, 0, 0, 0, err
32 | }
33 | return startCol, startRow, endCol, endRow, nil
34 | }
35 |
36 | func NormalizeRange(rangeStr string) string {
37 | startCol, startRow, endCol, endRow, _ := ParseRange(rangeStr)
38 | startCell, _ := excelize.CoordinatesToCellName(startCol, startRow)
39 | endCell, _ := excelize.CoordinatesToCellName(endCol, endRow)
40 | return fmt.Sprintf("%s:%s", startCell, endCell)
41 | }
42 |
43 | // FileIsNotReadable checks if a file is not writable
44 | func FileIsNotWritable(absolutePath string) bool {
45 | f, err := os.OpenFile(path.Clean(absolutePath), os.O_WRONLY, os.ModePerm)
46 | if err != nil {
47 | return true
48 | }
49 | defer f.Close()
50 | return false
51 | }
52 |
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20
2 |
3 | ARG TZ
4 | ENV TZ="$TZ"
5 |
6 | # Install basic development tools and iptables/ipset
7 | RUN apt update && apt install -y less \
8 | git \
9 | procps \
10 | sudo \
11 | fzf \
12 | zsh \
13 | man-db \
14 | unzip \
15 | gnupg2 \
16 | gh \
17 | iptables \
18 | ipset \
19 | iproute2 \
20 | dnsutils \
21 | aggregate \
22 | jq
23 |
24 | # Ensure default node user has access to /usr/local/share
25 | RUN mkdir -p /usr/local/share/npm-global && \
26 | chown -R node:node /usr/local/share
27 |
28 | ARG USERNAME=node
29 |
30 | # Persist bash history.
31 | RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
32 | && mkdir /commandhistory \
33 | && touch /commandhistory/.bash_history \
34 | && chown -R $USERNAME /commandhistory
35 |
36 | # Set `DEVCONTAINER` environment variable to help with orientation
37 | ENV DEVCONTAINER=true
38 |
39 | # Create workspace and config directories and set permissions
40 | RUN mkdir -p /workspace /home/node/.claude && \
41 | chown -R node:node /workspace /home/node/.claude
42 |
43 | WORKDIR /workspace
44 |
45 | RUN ARCH=$(dpkg --print-architecture) && \
46 | wget "https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_${ARCH}.deb" && \
47 | sudo dpkg -i "git-delta_0.18.2_${ARCH}.deb" && \
48 | rm "git-delta_0.18.2_${ARCH}.deb"
49 |
50 | # Set up non-root user
51 | USER node
52 |
53 | # Install global packages
54 | ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
55 | ENV PATH=$PATH:/usr/local/share/npm-global/bin
56 |
57 | # Set the default shell to zsh rather than sh
58 | ENV SHELL=/bin/zsh
59 |
60 | # Default powerline10k theme
61 | RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.0/zsh-in-docker.sh)" -- \
62 | -p git \
63 | -p fzf \
64 | -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
65 | -a "source /usr/share/doc/fzf/examples/completion.zsh" \
66 | -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
67 | -x
68 |
69 | # Install Claude
70 | RUN npm install -g @anthropic-ai/claude-code
71 |
72 | # Copy and set up firewall script
73 | COPY init-firewall.sh /usr/local/bin/
74 | USER root
75 | RUN chmod +x /usr/local/bin/init-firewall.sh && \
76 | echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
77 | chmod 0440 /etc/sudoers.d/node-firewall
78 | USER node
79 |
--------------------------------------------------------------------------------
/internal/tools/excel_copy_sheet.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "html"
7 |
8 | z "github.com/Oudwins/zog"
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/mark3labs/mcp-go/server"
11 | excel "github.com/negokaz/excel-mcp-server/internal/excel"
12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp"
13 | )
14 |
15 | type ExcelCopySheetArguments struct {
16 | FileAbsolutePath string `zog:"fileAbsolutePath"`
17 | SrcSheetName string `zog:"srcSheetName"`
18 | DstSheetName string `zog:"dstSheetName"`
19 | }
20 |
21 | var excelCopySheetArgumentsSchema = z.Struct(z.Shape{
22 | "fileAbsolutePath": z.String().Required(),
23 | "srcSheetName": z.String().Required(),
24 | "dstSheetName": z.String().Required(),
25 | })
26 |
27 | func AddExcelCopySheetTool(server *server.MCPServer) {
28 | server.AddTool(mcp.NewTool("excel_copy_sheet",
29 | mcp.WithDescription("Copy existing sheet to a new sheet"),
30 | mcp.WithString("fileAbsolutePath",
31 | mcp.Required(),
32 | mcp.Description("Absolute path to the Excel file"),
33 | ),
34 | mcp.WithString("srcSheetName",
35 | mcp.Required(),
36 | mcp.Description("Source sheet name in the Excel file"),
37 | ),
38 | mcp.WithString("dstSheetName",
39 | mcp.Required(),
40 | mcp.Description("Sheet name to be copied"),
41 | ),
42 | ), handleCopySheet)
43 | }
44 |
45 | func handleCopySheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
46 | args := ExcelCopySheetArguments{}
47 | if issues := excelCopySheetArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 {
48 | return imcp.NewToolResultZogIssueMap(issues), nil
49 | }
50 | return copySheet(args.FileAbsolutePath, args.SrcSheetName, args.DstSheetName)
51 | }
52 |
53 | func copySheet(fileAbsolutePath string, srcSheetName string, dstSheetName string) (*mcp.CallToolResult, error) {
54 | workbook, release, err := excel.OpenFile(fileAbsolutePath)
55 | if err != nil {
56 | return nil, err
57 | }
58 | defer release()
59 |
60 | srcSheet, err := workbook.FindSheet(srcSheetName)
61 | if err != nil {
62 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil
63 | }
64 | defer srcSheet.Release()
65 | srcSheetName, err = srcSheet.Name()
66 | if err != nil {
67 | return nil, err
68 | }
69 |
70 | if err := workbook.CopySheet(srcSheetName, dstSheetName); err != nil {
71 | return nil, err
72 | }
73 | if err := workbook.Save(); err != nil {
74 | return nil, err
75 | }
76 |
77 | result := "# Notice\n"
78 | result += fmt.Sprintf("backend: %s\n", workbook.GetBackendName())
79 | result += fmt.Sprintf("Sheet [%s] copied to [%s].\n", html.EscapeString(srcSheetName), html.EscapeString(dstSheetName))
80 | return mcp.NewToolResultText(result), nil
81 | }
82 |
--------------------------------------------------------------------------------
/internal/tools/excel_create_table.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "html"
7 |
8 | z "github.com/Oudwins/zog"
9 | "github.com/mark3labs/mcp-go/mcp"
10 | "github.com/mark3labs/mcp-go/server"
11 | excel "github.com/negokaz/excel-mcp-server/internal/excel"
12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp"
13 | )
14 |
15 | type ExcelCreateTableArguments struct {
16 | FileAbsolutePath string `zog:"fileAbsolutePath"`
17 | SheetName string `zog:"sheetName"`
18 | Range string `zog:"range"`
19 | TableName string `zog:"tableName"`
20 | }
21 |
22 | var excelCreateTableArgumentsSchema = z.Struct(z.Shape{
23 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(),
24 | "sheetName": z.String().Required(),
25 | "range": z.String(),
26 | "tableName": z.String().Required(),
27 | })
28 |
29 | func AddExcelCreateTableTool(server *server.MCPServer) {
30 | server.AddTool(mcp.NewTool("excel_create_table",
31 | mcp.WithDescription("Create a table in the Excel sheet"),
32 | mcp.WithString("fileAbsolutePath",
33 | mcp.Required(),
34 | mcp.Description("Absolute path to the Excel file"),
35 | ),
36 | mcp.WithString("sheetName",
37 | mcp.Required(),
38 | mcp.Description("Sheet name where the table is created"),
39 | ),
40 | mcp.WithString("range",
41 | mcp.Description("Range to be a table (e.g., \"A1:C10\")"),
42 | ),
43 | mcp.WithString("tableName",
44 | mcp.Required(),
45 | mcp.Description("Table name to be created"),
46 | ),
47 | ), handleCreateTable)
48 | }
49 |
50 | func handleCreateTable(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
51 | args := ExcelCreateTableArguments{}
52 | if issues := excelCreateTableArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 {
53 | return imcp.NewToolResultZogIssueMap(issues), nil
54 | }
55 | return createTable(args.FileAbsolutePath, args.SheetName, args.Range, args.TableName)
56 | }
57 |
58 | func createTable(fileAbsolutePath string, sheetName string, tableRange string, tableName string) (*mcp.CallToolResult, error) {
59 | workbook, release, err := excel.OpenFile(fileAbsolutePath)
60 | if err != nil {
61 | return nil, err
62 | }
63 | defer release()
64 |
65 | worksheet, err := workbook.FindSheet(sheetName)
66 | if err != nil {
67 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil
68 | }
69 | defer worksheet.Release()
70 | if err := worksheet.AddTable(tableRange, tableName); err != nil {
71 | return nil, err
72 | }
73 | if err := workbook.Save(); err != nil {
74 | return nil, err
75 | }
76 |
77 | result := "# Notice\n"
78 | result += fmt.Sprintf("backend: %s\n", workbook.GetBackendName())
79 | result += fmt.Sprintf("Table [%s] created.\n", html.EscapeString(tableName))
80 | return mcp.NewToolResultText(result), nil
81 | }
82 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | This is an Excel MCP (Model Context Protocol) Server that enables AI assistants to interact with Microsoft Excel files. It provides a bridge between AI models and Excel spreadsheets for programmatic data manipulation.
8 |
9 | ## Development Commands
10 |
11 | ### Build and Development
12 | ```bash
13 | npm run build # Build Go binaries + compile TypeScript
14 | npm run watch # Watch TypeScript files for changes
15 | npm run debug # Debug with MCP inspector
16 | ```
17 |
18 | ### Testing
19 | ```bash
20 | go test ./... # Run all Go tests
21 | go test ./internal/tools -v # Run specific package tests
22 | go test -run TestReadSheetData ./internal/tools # Run specific test
23 | ```
24 |
25 | ### Linting and Formatting
26 | ```bash
27 | go fmt ./... # Format Go code
28 | go vet ./... # Vet Go code for issues
29 | ```
30 |
31 | ## Architecture
32 |
33 | ### Core Components
34 |
35 | **Dual Backend Architecture**: The server supports two Excel backends:
36 | - **Windows**: OLE automation for live Excel interaction (`excel_ole.go`)
37 | - **Cross-platform**: Excelize library for file operations (`excel_excelize.go`)
38 |
39 | **Key Interfaces**:
40 | - `ExcelInterface` in `internal/excel/excel.go` - Unified Excel operations API
41 | - `Tool` interface in `internal/tools/` - MCP tool implementations
42 |
43 | **Entry Points**:
44 | - `cmd/excel-mcp-server/main.go` - Go binary entry point
45 | - `launcher/launcher.ts` - Cross-platform launcher that selects appropriate binary
46 |
47 | ### Tool System
48 |
49 | MCP tools are implemented in `internal/tools/`:
50 | - `excel_describe_sheets` - List worksheets and metadata
51 | - `excel_read_sheet` - Read sheet data with pagination
52 | - `excel_write_to_sheet` - Write data to sheets
53 | - `excel_create_table` - Create Excel tables
54 | - `excel_copy_sheet` - Copy sheets between workbooks
55 | - `excel_screen_capture` - Windows-only screenshot functionality
56 |
57 | ### Pagination System
58 |
59 | Large datasets are handled through configurable pagination:
60 | - Default limit: 4000 cells
61 | - Configurable via `EXCEL_MCP_PAGING_CELLS_LIMIT` environment variable
62 | - Implemented in `internal/excel/pagination.go`
63 |
64 | ## File Structure
65 |
66 | ```
67 | cmd/excel-mcp-server/ # Main application entry point
68 | internal/
69 | excel/ # Excel abstraction layer
70 | server/ # MCP server implementation
71 | tools/ # MCP tool implementations
72 | launcher/ # TypeScript launcher
73 | memory-bank/ # Development context and progress
74 | ```
75 |
76 | ## Build System
77 |
78 | Uses GoReleaser (`.goreleaser.yaml`) to create cross-platform binaries:
79 | - Windows: amd64, 386, arm64
80 | - macOS: amd64, arm64
81 | - Linux: amd64, 386, arm64
82 |
83 | TypeScript launcher is compiled to `dist/launcher.js` and published to NPM.
84 |
85 | ## Platform Differences
86 |
87 | **Windows-specific features**:
88 | - Live Excel interaction via OLE automation
89 | - Screen capture capabilities
90 | - Requires Excel to be installed
91 |
92 | **Cross-platform features**:
93 | - File-based Excel operations only
94 | - No live editing capabilities
95 | - Works with xlsx, xlsm, xltx, xltm formats
96 |
97 | ## Configuration
98 |
99 | Environment variables:
100 | - `EXCEL_MCP_PAGING_CELLS_LIMIT` - Maximum cells per page (default: 4000)
101 |
102 | ## Dependencies
103 |
104 | **Go**: Requires Go 1.23.0+ with Go 1.24.0 toolchain
105 | **Node.js**: Requires Node.js 20.x+ for TypeScript compilation
106 | **Key packages**:
107 | - `github.com/mark3labs/mcp-go` - MCP framework
108 | - `github.com/xuri/excelize/v2` - Excel file operations
109 | - `github.com/go-ole/go-ole` - Windows OLE automation
--------------------------------------------------------------------------------
/internal/tools/excel_describe_sheets.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 |
7 | z "github.com/Oudwins/zog"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/negokaz/excel-mcp-server/internal/excel"
11 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp"
12 | )
13 |
14 | type ExcelDescribeSheetsArguments struct {
15 | FileAbsolutePath string `zog:"fileAbsolutePath"`
16 | }
17 |
18 | var excelDescribeSheetsArgumentsSchema = z.Struct(z.Shape{
19 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(),
20 | })
21 |
22 | func AddExcelDescribeSheetsTool(server *server.MCPServer) {
23 | server.AddTool(mcp.NewTool("excel_describe_sheets",
24 | mcp.WithDescription("List all sheet information of specified Excel file"),
25 | mcp.WithString("fileAbsolutePath",
26 | mcp.Required(),
27 | mcp.Description("Absolute path to the Excel file"),
28 | ),
29 | ), handleDescribeSheets)
30 | }
31 |
32 | func handleDescribeSheets(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
33 | args := ExcelDescribeSheetsArguments{}
34 | issues := excelDescribeSheetsArgumentsSchema.Parse(request.Params.Arguments, &args)
35 | if len(issues) != 0 {
36 | return imcp.NewToolResultZogIssueMap(issues), nil
37 | }
38 | return describeSheets(args.FileAbsolutePath)
39 | }
40 |
41 | type Response struct {
42 | Backend string `json:"backend"`
43 | Sheets []Worksheet `json:"sheets"`
44 | }
45 | type Worksheet struct {
46 | Name string `json:"name"`
47 | UsedRange string `json:"usedRange"`
48 | Tables []Table `json:"tables"`
49 | PivotTables []PivotTable `json:"pivotTables"`
50 | PagingRanges []string `json:"pagingRanges"`
51 | }
52 |
53 | type Table struct {
54 | Name string `json:"name"`
55 | Range string `json:"range"`
56 | }
57 |
58 | type PivotTable struct {
59 | Name string `json:"name"`
60 | Range string `json:"range"`
61 | }
62 |
63 | func describeSheets(fileAbsolutePath string) (*mcp.CallToolResult, error) {
64 | config, issues := LoadConfig()
65 | if issues != nil {
66 | return imcp.NewToolResultZogIssueMap(issues), nil
67 | }
68 | workbook, release, err := excel.OpenFile(fileAbsolutePath)
69 | defer release()
70 | if err != nil {
71 | return nil, err
72 | }
73 |
74 | sheetList, err := workbook.GetSheets()
75 | if err != nil {
76 | return nil, err
77 | }
78 | worksheets := make([]Worksheet, len(sheetList))
79 | for i, sheet := range sheetList {
80 | defer sheet.Release()
81 | name, err := sheet.Name()
82 | if err != nil {
83 | return nil, err
84 | }
85 | usedRange, err := sheet.GetDimention()
86 | if err != nil {
87 | return nil, err
88 | }
89 | tables, err := sheet.GetTables()
90 | if err != nil {
91 | return nil, err
92 | }
93 | tableList := make([]Table, len(tables))
94 | for i, table := range tables {
95 | tableList[i] = Table{
96 | Name: table.Name,
97 | Range: table.Range,
98 | }
99 | }
100 | pivotTables, err := sheet.GetPivotTables()
101 | if err != nil {
102 | return nil, err
103 | }
104 | pivotTableList := make([]PivotTable, len(pivotTables))
105 | for i, pivotTable := range pivotTables {
106 | pivotTableList[i] = PivotTable{
107 | Name: pivotTable.Name,
108 | Range: pivotTable.Range,
109 | }
110 | }
111 | var pagingRanges []string
112 | strategy, err := sheet.GetPagingStrategy(config.EXCEL_MCP_PAGING_CELLS_LIMIT)
113 | if err == nil {
114 | pagingService := excel.NewPagingRangeService(strategy)
115 | pagingRanges = pagingService.GetPagingRanges()
116 | }
117 | worksheets[i] = Worksheet{
118 | Name: name,
119 | UsedRange: usedRange,
120 | Tables: tableList,
121 | PivotTables: pivotTableList,
122 | PagingRanges: pagingRanges,
123 | }
124 | }
125 | response := Response{
126 | Backend: workbook.GetBackendName(),
127 | Sheets: worksheets,
128 | }
129 | jsonBytes, err := json.MarshalIndent(response, "", " ")
130 | if err != nil {
131 | return nil, err
132 | }
133 |
134 | return mcp.NewToolResultText(string(jsonBytes)), nil
135 | }
136 |
--------------------------------------------------------------------------------
/internal/tools/excel_screen_capture.go:
--------------------------------------------------------------------------------
1 | package tools
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | z "github.com/Oudwins/zog"
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/mark3labs/mcp-go/server"
10 | "github.com/negokaz/excel-mcp-server/internal/excel"
11 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp"
12 | )
13 |
14 | type ExcelScreenCaptureArguments struct {
15 | FileAbsolutePath string `zog:"fileAbsolutePath"`
16 | SheetName string `zog:"sheetName"`
17 | Range string `zog:"range"`
18 | }
19 |
20 | var ExcelScreenCaptureArgumentsSchema = z.Struct(z.Shape{
21 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(),
22 | "sheetName": z.String().Required(),
23 | "range": z.String(),
24 | })
25 |
26 | func AddExcelScreenCaptureTool(server *server.MCPServer) {
27 | server.AddTool(mcp.NewTool("excel_screen_capture",
28 | mcp.WithDescription("[Windows only] Take a screenshot of the Excel sheet with pagination."),
29 | mcp.WithString("fileAbsolutePath",
30 | mcp.Required(),
31 | mcp.Description("Absolute path to the Excel file"),
32 | ),
33 | mcp.WithString("sheetName",
34 | mcp.Required(),
35 | mcp.Description("Sheet name in the Excel file"),
36 | ),
37 | mcp.WithString("range",
38 | mcp.Description("Range of cells to read in the Excel sheet (e.g., \"A1:C10\"). [default: first paging range]"),
39 | ),
40 | ), handleScreenCapture)
41 | }
42 |
43 | func handleScreenCapture(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
44 | args := ExcelScreenCaptureArguments{}
45 | issues := ExcelScreenCaptureArgumentsSchema.Parse(request.Params.Arguments, &args)
46 | if len(issues) != 0 {
47 | return imcp.NewToolResultZogIssueMap(issues), nil
48 | }
49 | return readSheetImage(args.FileAbsolutePath, args.SheetName, args.Range)
50 | }
51 |
52 | func readSheetImage(fileAbsolutePath string, sheetName string, rangeStr string) (*mcp.CallToolResult, error) {
53 | workbook, releaseWorkbook, err := excel.NewExcelOle(fileAbsolutePath)
54 | defer releaseWorkbook()
55 | if err != nil {
56 | workbook, releaseWorkbook, err = excel.NewExcelOleWithNewObject(fileAbsolutePath)
57 | defer releaseWorkbook()
58 | if err != nil {
59 | return imcp.NewToolResultInvalidArgumentError(fmt.Errorf("failed to open workbook: %w", err).Error()), nil
60 | }
61 | }
62 |
63 | worksheet, err := workbook.FindSheet(sheetName)
64 | if err != nil {
65 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil
66 | }
67 | defer worksheet.Release()
68 |
69 | pagingStrategy, err := worksheet.GetPagingStrategy(5000)
70 | if err != nil {
71 | return nil, err
72 | }
73 | pagingService := excel.NewPagingRangeService(pagingStrategy)
74 |
75 | allRanges := pagingService.GetPagingRanges()
76 | if len(allRanges) == 0 {
77 | return imcp.NewToolResultInvalidArgumentError("no range available to read"), nil
78 | }
79 |
80 | var currentRange string
81 | if rangeStr == "" && len(allRanges) > 0 {
82 | // range が指定されていない場合は最初の Range を使用
83 | currentRange = allRanges[0]
84 | } else {
85 | // range が指定されている場合は指定された範囲を使用
86 | currentRange = rangeStr
87 | }
88 | // Find next paging range if current range matches a paging range
89 | nextRange := pagingService.FindNextRange(allRanges, currentRange)
90 |
91 | base64image, err := worksheet.CapturePicture(currentRange)
92 | if err != nil {
93 | return nil, fmt.Errorf("failed to copy range to image: %w", err)
94 | }
95 |
96 | text := "# Metadata\n"
97 | text += fmt.Sprintf("- backend: %s\n", workbook.GetBackendName())
98 | text += fmt.Sprintf("- sheet name: %s\n", sheetName)
99 | text += fmt.Sprintf("- read range: %s\n", currentRange)
100 | text += "# Notice\n"
101 | if nextRange != "" {
102 | text += "This sheet has more ranges.\n"
103 | text += "To read the next range, you should specify 'range' argument as follows.\n"
104 | text += fmt.Sprintf("`{ \"range\": \"%s\" }`", nextRange)
105 | } else {
106 | text += "This is the last range or no more ranges available.\n"
107 | }
108 |
109 | // 結果を返却
110 | return mcp.NewToolResultImage(
111 | text,
112 | base64image,
113 | "image/png",
114 | ), nil
115 | }
116 |
--------------------------------------------------------------------------------
/.devcontainer/init-firewall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -euo pipefail # Exit on error, undefined vars, and pipeline failures
3 | IFS=$'\n\t' # Stricter word splitting
4 |
5 | # Flush existing rules and delete existing ipsets
6 | iptables -F
7 | iptables -X
8 | iptables -t nat -F
9 | iptables -t nat -X
10 | iptables -t mangle -F
11 | iptables -t mangle -X
12 | ipset destroy allowed-domains 2>/dev/null || true
13 |
14 | # First allow DNS and localhost before any restrictions
15 | # Allow outbound DNS
16 | iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
17 | # Allow inbound DNS responses
18 | iptables -A INPUT -p udp --sport 53 -j ACCEPT
19 | # Allow outbound SSH
20 | iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
21 | # Allow inbound SSH responses
22 | iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
23 | # Allow localhost
24 | iptables -A INPUT -i lo -j ACCEPT
25 | iptables -A OUTPUT -o lo -j ACCEPT
26 |
27 | # Create ipset with CIDR support
28 | ipset create allowed-domains hash:net
29 |
30 | # Fetch GitHub meta information and aggregate + add their IP ranges
31 | echo "Fetching GitHub IP ranges..."
32 | gh_ranges=$(curl -s https://api.github.com/meta)
33 | if [ -z "$gh_ranges" ]; then
34 | echo "ERROR: Failed to fetch GitHub IP ranges"
35 | exit 1
36 | fi
37 |
38 | if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
39 | echo "ERROR: GitHub API response missing required fields"
40 | exit 1
41 | fi
42 |
43 | echo "Processing GitHub IPs..."
44 | while read -r cidr; do
45 | if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
46 | echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
47 | exit 1
48 | fi
49 | echo "Adding GitHub range $cidr"
50 | ipset add allowed-domains "$cidr"
51 | done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
52 |
53 | # Resolve and add other allowed domains
54 | for domain in \
55 | "registry.npmjs.org" \
56 | "api.anthropic.com" \
57 | "sentry.io" \
58 | "statsig.anthropic.com" \
59 | "statsig.com"; do
60 | echo "Resolving $domain..."
61 | ips=$(dig +short A "$domain")
62 | if [ -z "$ips" ]; then
63 | echo "ERROR: Failed to resolve $domain"
64 | exit 1
65 | fi
66 |
67 | while read -r ip; do
68 | if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
69 | echo "ERROR: Invalid IP from DNS for $domain: $ip"
70 | exit 1
71 | fi
72 | echo "Adding $ip for $domain"
73 | ipset add allowed-domains "$ip"
74 | done < <(echo "$ips")
75 | done
76 |
77 | # Get host IP from default route
78 | HOST_IP=$(ip route | grep default | cut -d" " -f3)
79 | if [ -z "$HOST_IP" ]; then
80 | echo "ERROR: Failed to detect host IP"
81 | exit 1
82 | fi
83 |
84 | HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
85 | echo "Host network detected as: $HOST_NETWORK"
86 |
87 | # Set up remaining iptables rules
88 | iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
89 | iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
90 |
91 | # Set default policies to DROP first
92 | iptables -P INPUT DROP
93 | iptables -P FORWARD DROP
94 | iptables -P OUTPUT DROP
95 |
96 | # First allow established connections for already approved traffic
97 | iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
98 | iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
99 |
100 | # Then allow only specific outbound traffic to allowed domains
101 | iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
102 |
103 | echo "Firewall configuration complete"
104 | echo "Verifying firewall rules..."
105 | if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
106 | echo "ERROR: Firewall verification failed - was able to reach https://example.com"
107 | exit 1
108 | else
109 | echo "Firewall verification passed - unable to reach https://example.com as expected"
110 | fi
111 |
112 | # Verify GitHub API access
113 | if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
114 | echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
115 | exit 1
116 | else
117 | echo "Firewall verification passed - able to reach https://api.github.com as expected"
118 | fi
119 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Oudwins/zog v0.21.4 h1:WTp6pt2MPxHCOiKNGEHLwT2mnfDw5OnydmEn/kD9F7g=
2 | github.com/Oudwins/zog v0.21.4/go.mod h1:c4ADJ2zNkJp37ZViNy1o3ZZoeMvO7UQVO7BaPtRoocg=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
6 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
7 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
8 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
9 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
10 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
13 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
14 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
15 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
16 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
19 | github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0=
20 | github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
23 | github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
24 | github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
25 | github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
26 | github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
27 | github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
28 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
29 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
30 | github.com/skanehira/clipboard-image v1.0.0 h1:MJ5PeXxDMteS0HCsjvuoMscBi+AtoqCiPX7bZ2OAxDE=
31 | github.com/skanehira/clipboard-image v1.0.0/go.mod h1:WAxMgBkENpa206RHfrqV/5y8Kq7CitAozlvVxQxa9gs=
32 | github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
33 | github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
34 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
35 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
36 | github.com/tiendc/go-deepcopy v1.6.1 h1:uVRTItFeNHkMcLueHS7OCsxgxT9P8MzGB/taUa2Y4Tk=
37 | github.com/tiendc/go-deepcopy v1.6.1/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
38 | github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
39 | github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
40 | github.com/xuri/excelize/v2 v2.9.2-0.20250717000717-dd07139785fe h1:hv8jIvpUG4V/oNMb+5zoGVMJvpVhMLvFhHI+LH0hhZE=
41 | github.com/xuri/excelize/v2 v2.9.2-0.20250717000717-dd07139785fe/go.mod h1:isQygQdjiU88/HpJYtp+6TN/FH29HvVjWT8vFk3t5+w=
42 | github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
43 | github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
44 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
45 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
46 | golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
47 | golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
48 | golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
49 | golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
50 | golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
51 | golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
52 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
53 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
54 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
55 | golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
56 | golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
57 | golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
58 | golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
59 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
60 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Excel MCP Server
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | [](https://www.npmjs.com/package/@negokaz/excel-mcp-server)
10 | [](https://smithery.ai/server/@negokaz/excel-mcp-server)
11 |
12 | A Model Context Protocol (MCP) server that reads and writes MS Excel data.
13 |
14 | ## Features
15 |
16 | - Read/Write text values
17 | - Read/Write formulas
18 | - Create new sheets
19 |
20 | **🪟Windows only:**
21 | - Live editing
22 | - Capture screen image from a sheet
23 |
24 | For more details, see the [tools](#tools) section.
25 |
26 | ## Requirements
27 |
28 | - Node.js 20.x or later
29 |
30 | ## Supported file formats
31 |
32 | - xlsx (Excel book)
33 | - xlsm (Excel macro-enabled book)
34 | - xltx (Excel template)
35 | - xltm (Excel macro-enabled template)
36 |
37 | ## Installation
38 |
39 | ### Installing via NPM
40 |
41 | excel-mcp-server is automatically installed by adding the following configuration to the MCP servers configuration.
42 |
43 | For Windows:
44 | ```json
45 | {
46 | "mcpServers": {
47 | "excel": {
48 | "command": "cmd",
49 | "args": ["/c", "npx", "--yes", "@negokaz/excel-mcp-server"],
50 | "env": {
51 | "EXCEL_MCP_PAGING_CELLS_LIMIT": "4000"
52 | }
53 | }
54 | }
55 | }
56 | ```
57 |
58 | For other platforms:
59 | ```json
60 | {
61 | "mcpServers": {
62 | "excel": {
63 | "command": "npx",
64 | "args": ["--yes", "@negokaz/excel-mcp-server"],
65 | "env": {
66 | "EXCEL_MCP_PAGING_CELLS_LIMIT": "4000"
67 | }
68 | }
69 | }
70 | }
71 | ```
72 |
73 | ### Installing via Smithery
74 |
75 | To install Excel MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@negokaz/excel-mcp-server):
76 |
77 | ```bash
78 | npx -y @smithery/cli install @negokaz/excel-mcp-server --client claude
79 | ```
80 |
81 |
This sheet has more ranges.
\n" 146 | result += "To read the next range, you should specify 'range' argument as follows.
\n" 147 | result += fmt.Sprintf("{ \"range\": \"%s\" }\n", nextRange)
148 | } else {
149 | result += "This is the last range or no more ranges available.
\n" 150 | } 151 | return mcp.NewToolResultText(result), nil 152 | } 153 | 154 | func validateRangeWithinUsedRange(targetRange, usedRange string) error { 155 | // Parse target range 156 | targetStartCol, targetStartRow, targetEndCol, targetEndRow, err := excel.ParseRange(targetRange) 157 | if err != nil { 158 | return fmt.Errorf("failed to parse target range: %w", err) 159 | } 160 | 161 | // Parse used range 162 | usedStartCol, usedStartRow, usedEndCol, usedEndRow, err := excel.ParseRange(usedRange) 163 | if err != nil { 164 | return fmt.Errorf("failed to parse used range: %w", err) 165 | } 166 | 167 | // Check if target range is within used range 168 | if targetStartCol < usedStartCol || targetStartRow < usedStartRow || 169 | targetEndCol > usedEndCol || targetEndRow > usedEndRow { 170 | return fmt.Errorf("range is outside of used range: %s is not within %s", targetRange, usedRange) 171 | } 172 | 173 | return nil 174 | } 175 | -------------------------------------------------------------------------------- /internal/tools/excel_write_to_sheet.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | z "github.com/Oudwins/zog" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | "github.com/negokaz/excel-mcp-server/internal/excel" 11 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 12 | "github.com/xuri/excelize/v2" 13 | ) 14 | 15 | type ExcelWriteToSheetArguments struct { 16 | FileAbsolutePath string `zog:"fileAbsolutePath"` 17 | SheetName string `zog:"sheetName"` 18 | NewSheet bool `zog:"newSheet"` 19 | Range string `zog:"range"` 20 | Values [][]string `zog:"values"` 21 | } 22 | 23 | var excelWriteToSheetArgumentsSchema = z.Struct(z.Shape{ 24 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 25 | "sheetName": z.String().Required(), 26 | "newSheet": z.Bool().Required().Default(false), 27 | "range": z.String().Required(), 28 | "values": z.Slice(z.Slice(z.String())).Required(), 29 | }) 30 | 31 | func AddExcelWriteToSheetTool(server *server.MCPServer) { 32 | server.AddTool(mcp.NewTool("excel_write_to_sheet", 33 | mcp.WithDescription("Write values to the Excel sheet"), 34 | mcp.WithString("fileAbsolutePath", 35 | mcp.Required(), 36 | mcp.Description("Absolute path to the Excel file"), 37 | ), 38 | mcp.WithString("sheetName", 39 | mcp.Required(), 40 | mcp.Description("Sheet name in the Excel file"), 41 | ), 42 | mcp.WithBoolean("newSheet", 43 | mcp.Required(), 44 | mcp.Description("Create a new sheet if true, otherwise write to the existing sheet"), 45 | ), 46 | mcp.WithString("range", 47 | mcp.Required(), 48 | mcp.Description("Range of cells in the Excel sheet (e.g., \"A1:C10\")"), 49 | ), 50 | mcp.WithArray("values", 51 | mcp.Required(), 52 | mcp.Description("Values to write to the Excel sheet. If the value is a formula, it should start with \"=\""), 53 | mcp.Items(map[string]any{ 54 | "type": "array", 55 | "items": map[string]any{ 56 | "anyOf": []any{ 57 | map[string]any{ 58 | "type": "string", 59 | }, 60 | map[string]any{ 61 | "type": "number", 62 | }, 63 | map[string]any{ 64 | "type": "boolean", 65 | }, 66 | map[string]any{ 67 | "type": "null", 68 | }, 69 | }, 70 | }, 71 | }), 72 | ), 73 | ), handleWriteToSheet) 74 | } 75 | 76 | func handleWriteToSheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 77 | args := ExcelWriteToSheetArguments{} 78 | issues := excelWriteToSheetArgumentsSchema.Parse(request.Params.Arguments, &args) 79 | if len(issues) != 0 { 80 | return imcp.NewToolResultZogIssueMap(issues), nil 81 | } 82 | 83 | // zog が any type のスキーマをサポートしていないため、自力で実装 84 | valuesArg, ok := request.GetArguments()["values"].([]any) 85 | if !ok { 86 | return imcp.NewToolResultInvalidArgumentError("values must be a 2D array"), nil 87 | } 88 | values := make([][]any, len(valuesArg)) 89 | for i, v := range valuesArg { 90 | value, ok := v.([]any) 91 | if !ok { 92 | return imcp.NewToolResultInvalidArgumentError("values must be a 2D array"), nil 93 | } 94 | values[i] = value 95 | } 96 | 97 | return writeSheet(args.FileAbsolutePath, args.SheetName, args.NewSheet, args.Range, values) 98 | } 99 | 100 | func writeSheet(fileAbsolutePath string, sheetName string, newSheet bool, rangeStr string, values [][]any) (*mcp.CallToolResult, error) { 101 | workbook, closeFn, err := excel.OpenFile(fileAbsolutePath) 102 | if err != nil { 103 | return nil, err 104 | } 105 | defer closeFn() 106 | 107 | startCol, startRow, endCol, endRow, err := excel.ParseRange(rangeStr) 108 | if err != nil { 109 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 110 | } 111 | 112 | // データの整合性チェック 113 | rangeRowSize := endRow - startRow + 1 114 | if len(values) != rangeRowSize { 115 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of rows in data (%d) does not match range size (%d)", len(values), rangeRowSize)), nil 116 | } 117 | 118 | if newSheet { 119 | if err := workbook.CreateNewSheet(sheetName); err != nil { 120 | return nil, err 121 | } 122 | } 123 | 124 | // シートの取得 125 | worksheet, err := workbook.FindSheet(sheetName) 126 | if err != nil { 127 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 128 | } 129 | defer worksheet.Release() 130 | 131 | // データの書き込み 132 | wroteFormula := false 133 | for i, row := range values { 134 | rangeColumnSize := endCol - startCol + 1 135 | if len(row) != rangeColumnSize { 136 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of columns in row %d (%d) does not match range size (%d)", i, len(row), rangeColumnSize)), nil 137 | } 138 | for j, cellValue := range row { 139 | cell, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) 140 | if err != nil { 141 | return nil, err 142 | } 143 | if cellStr, ok := cellValue.(string); ok && isFormula(cellStr) { 144 | // if cellValue is formula, set it as formula 145 | err = worksheet.SetFormula(cell, cellStr) 146 | wroteFormula = true 147 | } else { 148 | // if cellValue is not formula, set it as value 149 | err = worksheet.SetValue(cell, cellValue) 150 | } 151 | if err != nil { 152 | return nil, err 153 | } 154 | } 155 | } 156 | 157 | if err := workbook.Save(); err != nil { 158 | return nil, err 159 | } 160 | 161 | // HTMLテーブルの生成 162 | var table *string 163 | if wroteFormula { 164 | table, err = CreateHTMLTableOfFormula(worksheet, startCol, startRow, endCol, endRow) 165 | } else { 166 | table, err = CreateHTMLTableOfValues(worksheet, startCol, startRow, endCol, endRow) 167 | } 168 | if err != nil { 169 | return nil, err 170 | } 171 | html := "Values wrote successfully.
\n" 181 | 182 | return mcp.NewToolResultText(html), nil 183 | } 184 | 185 | func isFormula(value string) bool { 186 | return len(value) > 0 && value[0] == '=' 187 | } 188 | -------------------------------------------------------------------------------- /docs/design/excel-style-schema.md: -------------------------------------------------------------------------------- 1 | # MCP Excel Style Structure Definition 2 | 3 | This document presents JsonSchema definitions for exchanging Excel styles through MCP (Model Context Protocol), based on the Excelize library's style API. 4 | 5 | ## Target Style Elements 6 | 7 | - Border 8 | - Font 9 | - Fill 10 | - NumFmt (Number Format) 11 | - DecimalPlaces 12 | 13 | ## JsonSchema Definition 14 | 15 | ### ExcelStyle Structure 16 | 17 | ```json 18 | { 19 | "$schema": "http://json-schema.org/draft-07/schema#", 20 | "type": "object", 21 | "title": "ExcelStyle", 22 | "description": "Excel cell style configuration", 23 | "properties": { 24 | "border": { 25 | "type": "array", 26 | "description": "Border configuration for cell edges", 27 | "items": { 28 | "$ref": "#/definitions/Border" 29 | } 30 | }, 31 | "font": { 32 | "$ref": "#/definitions/Font", 33 | "description": "Font configuration" 34 | }, 35 | "fill": { 36 | "$ref": "#/definitions/Fill", 37 | "description": "Fill pattern and color configuration" 38 | }, 39 | "numFmt": { 40 | "type": "string", 41 | "description": "Custom number format string", 42 | "example": "#,##0.00" 43 | }, 44 | "decimalPlaces": { 45 | "type": "integer", 46 | "description": "Number of decimal places (0-30)", 47 | "minimum": 0, 48 | "maximum": 30 49 | } 50 | }, 51 | "definitions": { 52 | "Border": { 53 | "type": "object", 54 | "description": "Border style configuration", 55 | "properties": { 56 | "type": { 57 | "type": "string", 58 | "description": "Border position", 59 | "enum": ["left", "right", "top", "bottom", "diagonalDown", "diagonalUp"] 60 | }, 61 | "color": { 62 | "type": "string", 63 | "description": "Border color in hex format", 64 | "pattern": "^#[0-9A-Fa-f]{6}$", 65 | "example": "#000000" 66 | }, 67 | "style": { 68 | "type": "string", 69 | "description": "Border style", 70 | "enum": ["none", "continuous", "dash", "dashDot", "dashDotDot", "dot", "double", "hair", "medium", "mediumDash", "mediumDashDot", "mediumDashDotDot", "slantDashDot", "thick"] 71 | } 72 | }, 73 | "required": ["type"], 74 | "additionalProperties": false 75 | }, 76 | "Font": { 77 | "type": "object", 78 | "description": "Font style configuration", 79 | "properties": { 80 | "bold": { 81 | "type": "boolean", 82 | "description": "Bold text" 83 | }, 84 | "italic": { 85 | "type": "boolean", 86 | "description": "Italic text" 87 | }, 88 | "underline": { 89 | "type": "string", 90 | "description": "Underline style", 91 | "enum": ["none", "single", "double", "singleAccounting", "doubleAccounting"] 92 | }, 93 | "size": { 94 | "type": "number", 95 | "description": "Font size in points", 96 | "minimum": 1, 97 | "maximum": 409 98 | }, 99 | "strike": { 100 | "type": "boolean", 101 | "description": "Strikethrough text" 102 | }, 103 | "color": { 104 | "type": "string", 105 | "description": "Font color in hex format", 106 | "pattern": "^#[0-9A-Fa-f]{6}$", 107 | "example": "#000000" 108 | }, 109 | "vertAlign": { 110 | "type": "string", 111 | "description": "Vertical alignment", 112 | "enum": ["baseline", "superscript", "subscript"] 113 | } 114 | }, 115 | "additionalProperties": false 116 | }, 117 | "Fill": { 118 | "type": "object", 119 | "description": "Fill pattern and color configuration", 120 | "properties": { 121 | "type": { 122 | "type": "string", 123 | "description": "Fill type", 124 | "enum": ["gradient", "pattern"] 125 | }, 126 | "pattern": { 127 | "type": "string", 128 | "description": "Pattern style", 129 | "enum": ["none", "solid", "mediumGray", "darkGray", "lightGray", "darkHorizontal", "darkVertical", "darkDown", "darkUp", "darkGrid", "darkTrellis", "lightHorizontal", "lightVertical", "lightDown", "lightUp", "lightGrid", "lightTrellis", "gray125", "gray0625"] 130 | }, 131 | "color": { 132 | "type": "array", 133 | "description": "Fill colors in hex format", 134 | "items": { 135 | "type": "string", 136 | "pattern": "^#[0-9A-Fa-f]{6}$", 137 | "example": "#FFFFFF" 138 | } 139 | }, 140 | "shading": { 141 | "type": "string", 142 | "description": "Gradient shading direction", 143 | "enum": ["horizontal", "vertical", "diagonalDown", "diagonalUp", "fromCenter", "fromCorner"] 144 | } 145 | }, 146 | "additionalProperties": false 147 | } 148 | } 149 | } 150 | ``` 151 | 152 | ## Usage Examples 153 | 154 | ### Basic Style Configuration 155 | 156 | ```json 157 | { 158 | "font": { 159 | "bold": true, 160 | "size": 12, 161 | "color": "#000000" 162 | }, 163 | "fill": { 164 | "type": "pattern", 165 | "pattern": "solid", 166 | "color": ["#FFFF00"] 167 | } 168 | } 169 | ``` 170 | 171 | ### Style with Borders 172 | 173 | ```json 174 | { 175 | "border": [ 176 | { 177 | "type": "top", 178 | "style": "continuous", 179 | "color": "#000000" 180 | }, 181 | { 182 | "type": "bottom", 183 | "style": "continuous", 184 | "color": "#000000" 185 | } 186 | ], 187 | "font": { 188 | "size": 10 189 | } 190 | } 191 | ``` 192 | 193 | ### Style with Number Format 194 | 195 | ```json 196 | { 197 | "numFmt": "#,##0.00", 198 | "decimalPlaces": 2, 199 | "font": { 200 | "size": 11 201 | } 202 | } 203 | ``` 204 | 205 | ## Implementation Notes 206 | 207 | 1. **Required Fields**: Only Border's `type` field is required; all others are optional 208 | 2. **Color Format**: Hexadecimal color codes (#RRGGBB format) 209 | 3. **Numeric Limits**: 210 | - `decimalPlaces`: Range 0-30 211 | - `border.style`: String identifiers (none, continuous, dash, etc.) 212 | - `fill.pattern`: String identifiers (none, solid, mediumGray, etc.) 213 | - `fill.shading`: String identifiers (horizontal, vertical, etc.) 214 | - `font.size`: Range 1-409 215 | 4. **Testing**: After implementation, test with actual Excel files 216 | 217 | ## Correspondence with Excelize 218 | 219 | This definition is based on the style structure of `github.com/xuri/excelize/v2 v2.9.0` and maintains compatibility with Excelize's API specifications. -------------------------------------------------------------------------------- /internal/excel/pagination.go: -------------------------------------------------------------------------------- 1 | package excel 2 | 3 | import ( 4 | "fmt" 5 | "github.com/xuri/excelize/v2" 6 | ) 7 | 8 | // PagingStrategy はページング範囲の計算戦略を定義するインターフェース 9 | type PagingStrategy interface { 10 | // CalculatePagingRanges は利用可能なページング範囲のリストを返す 11 | CalculatePagingRanges() []string 12 | } 13 | 14 | // ExcelizeFixedSizePagingStrategy は固定サイズでページング範囲を計算する戦略 15 | type ExcelizeFixedSizePagingStrategy struct { 16 | pageSize int 17 | worksheet *ExcelizeWorksheet 18 | dimension string 19 | } 20 | 21 | // NewExcelizeFixedSizePagingStrategy は新しいFixedSizePagingStrategyインスタンスを生成する 22 | func NewExcelizeFixedSizePagingStrategy(pageSize int, worksheet *ExcelizeWorksheet) (*ExcelizeFixedSizePagingStrategy, error) { 23 | if pageSize <= 0 { 24 | pageSize = 5000 // デフォルト値 25 | } 26 | 27 | // シートの次元情報を取得 28 | dimension, err := worksheet.GetDimention() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return &ExcelizeFixedSizePagingStrategy{ 34 | pageSize: pageSize, 35 | worksheet: worksheet, 36 | dimension: dimension, 37 | }, nil 38 | } 39 | 40 | // CalculatePagingRanges は固定サイズに基づいてページング範囲のリストを生成する 41 | func (s *ExcelizeFixedSizePagingStrategy) CalculatePagingRanges() []string { 42 | startCol, startRow, endCol, endRow, err := ParseRange(s.dimension) 43 | if err != nil { 44 | return []string{} 45 | } 46 | 47 | totalCols := endCol - startCol + 1 48 | cellsPerPage := s.pageSize 49 | 50 | // 1ページあたりの行数を計算 51 | rowsPerPage := cellsPerPage / totalCols 52 | if rowsPerPage < 1 { 53 | rowsPerPage = 1 54 | } 55 | 56 | var ranges []string 57 | currentRow := startRow 58 | for currentRow <= endRow { 59 | pageEndRow := currentRow + rowsPerPage - 1 60 | if pageEndRow > endRow { 61 | pageEndRow = endRow 62 | } 63 | 64 | startRange, _ := excelize.CoordinatesToCellName(startCol, currentRow) 65 | endRange, _ := excelize.CoordinatesToCellName(endCol, pageEndRow) 66 | ranges = append(ranges, fmt.Sprintf("%s:%s", startRange, endRange)) 67 | 68 | currentRow = pageEndRow + 1 69 | } 70 | 71 | return ranges 72 | } 73 | 74 | 75 | func NewOlePagingStrategy(pageSize int, worksheet *OleWorksheet) (PagingStrategy, error) { 76 | if worksheet == nil { 77 | return nil, fmt.Errorf("worksheet is nil") 78 | } 79 | 80 | printAreaPagingStrategy, err := NewPrintAreaPagingStrategy(worksheet) 81 | if err != nil { 82 | return nil, err 83 | } 84 | printArea, err := printAreaPagingStrategy.getPrintArea() 85 | if err != nil { 86 | return nil, err 87 | } 88 | if printArea == "" { 89 | return NewGoxcelFixedSizePagingStrategy(pageSize, worksheet) 90 | } else { 91 | return printAreaPagingStrategy, nil 92 | } 93 | } 94 | 95 | // OleFixedSizePagingStrategy は Goxcel を使用した固定サイズでページング範囲を計算する戦略 96 | type OleFixedSizePagingStrategy struct { 97 | pageSize int 98 | worksheet *OleWorksheet 99 | dimension string 100 | } 101 | 102 | // NewGoxcelFixedSizePagingStrategy は新しい GoxcelFixedSizePagingStrategy インスタンスを生成する 103 | func NewGoxcelFixedSizePagingStrategy(pageSize int, worksheet *OleWorksheet) (*OleFixedSizePagingStrategy, error) { 104 | if pageSize <= 0 { 105 | pageSize = 5000 // デフォルト値 106 | } 107 | 108 | if worksheet == nil { 109 | return nil, fmt.Errorf("worksheet is nil") 110 | } 111 | 112 | // UsedRange を使用して使用範囲を取得 113 | dimention, err := worksheet.GetDimention() 114 | if err != nil { 115 | return nil, fmt.Errorf("failed to get dimention: %w", err) 116 | } 117 | 118 | return &OleFixedSizePagingStrategy{ 119 | pageSize: pageSize, 120 | worksheet: worksheet, 121 | dimension: dimention, 122 | }, nil 123 | } 124 | 125 | // CalculatePagingRanges は固定サイズに基づいてページング範囲のリストを生成する 126 | func (s *OleFixedSizePagingStrategy) CalculatePagingRanges() []string { 127 | startCol, startRow, endCol, endRow, err := ParseRange(s.dimension) 128 | if err != nil { 129 | return []string{} 130 | } 131 | 132 | totalCols := endCol - startCol + 1 133 | cellsPerPage := s.pageSize 134 | 135 | // 1ページあたりの行数を計算 136 | rowsPerPage := cellsPerPage / totalCols 137 | if rowsPerPage < 1 { 138 | rowsPerPage = 1 139 | } 140 | 141 | var ranges []string 142 | currentRow := startRow 143 | for currentRow <= endRow { 144 | pageEndRow := currentRow + rowsPerPage - 1 145 | if pageEndRow > endRow { 146 | pageEndRow = endRow 147 | } 148 | 149 | startRange, _ := excelize.CoordinatesToCellName(startCol, currentRow) 150 | endRange, _ := excelize.CoordinatesToCellName(endCol, pageEndRow) 151 | ranges = append(ranges, fmt.Sprintf("%s:%s", startRange, endRange)) 152 | 153 | currentRow = pageEndRow + 1 154 | } 155 | 156 | return ranges 157 | } 158 | 159 | 160 | // PrintAreaPagingStrategy は印刷範囲とページ区切りに基づいてページング範囲を計算する戦略 161 | type PrintAreaPagingStrategy struct { 162 | worksheet *OleWorksheet 163 | } 164 | 165 | // NewPrintAreaPagingStrategy は新しいPrintAreaPagingStrategyインスタンスを生成する 166 | func NewPrintAreaPagingStrategy(worksheet *OleWorksheet) (*PrintAreaPagingStrategy, error) { 167 | if worksheet == nil { 168 | return nil, fmt.Errorf("worksheet is nil") 169 | } 170 | return &PrintAreaPagingStrategy{ 171 | worksheet: worksheet, 172 | }, nil 173 | } 174 | 175 | // getPrintArea は印刷範囲を取得する 176 | func (s *PrintAreaPagingStrategy) getPrintArea() (string, error) { 177 | return s.worksheet.PrintArea() 178 | } 179 | 180 | // getHPageBreaksPositions はページ区切りの位置情報を取得する 181 | func (s *PrintAreaPagingStrategy) getHPageBreaksPositions() ([]int, error) { 182 | pageBreaks, err := s.worksheet.HPageBreaks() 183 | if err != nil { 184 | return nil, fmt.Errorf("failed to get HPageBreaks: %w", err) 185 | } 186 | return pageBreaks, nil 187 | } 188 | 189 | // calculateRangesFromBreaks は印刷範囲とページ区切りから範囲のリストを生成する 190 | func (s *PrintAreaPagingStrategy) calculateRangesFromBreaks(printArea string, breaks []int) []string { 191 | if printArea == "" { 192 | return []string{} 193 | } 194 | 195 | startCol, startRow, endCol, endRow, err := ParseRange(printArea) 196 | if err != nil { 197 | return []string{} 198 | } 199 | 200 | ranges := make([]string, 0) 201 | currentRow := startRow 202 | 203 | // ページ区切りがない場合は印刷範囲全体を1つの範囲として扱う 204 | if len(breaks) == 0 { 205 | startRange, _ := excelize.CoordinatesToCellName(startCol, startRow) 206 | endRange, _ := excelize.CoordinatesToCellName(endCol, endRow) 207 | ranges = append(ranges, fmt.Sprintf("%s:%s", startRange, endRange)) 208 | return ranges 209 | } 210 | 211 | // ページ区切りで範囲を分割 212 | for _, breakRow := range breaks { 213 | if breakRow <= startRow || breakRow > endRow { 214 | continue 215 | } 216 | 217 | startRange, _ := excelize.CoordinatesToCellName(startCol, currentRow) 218 | endRange, _ := excelize.CoordinatesToCellName(endCol, breakRow-1) 219 | ranges = append(ranges, fmt.Sprintf("%s:%s", startRange, endRange)) 220 | 221 | currentRow = breakRow 222 | } 223 | 224 | // 最後の範囲を追加 225 | if currentRow <= endRow { 226 | startRange, _ := excelize.CoordinatesToCellName(startCol, currentRow) 227 | endRange, _ := excelize.CoordinatesToCellName(endCol, endRow) 228 | ranges = append(ranges, fmt.Sprintf("%s:%s", startRange, endRange)) 229 | } 230 | 231 | return ranges 232 | } 233 | 234 | // CalculatePagingRanges は印刷範囲とページ区切りに基づいてページング範囲のリストを生成する 235 | func (s *PrintAreaPagingStrategy) CalculatePagingRanges() []string { 236 | printArea, err := s.getPrintArea() 237 | if err != nil { 238 | return []string{} 239 | } 240 | 241 | breaks, err := s.getHPageBreaksPositions() 242 | if err != nil { 243 | return []string{} 244 | } 245 | 246 | return s.calculateRangesFromBreaks(printArea, breaks) 247 | } 248 | 249 | 250 | // PagingRangeService はページング処理を提供するサービス 251 | type PagingRangeService struct { 252 | strategy PagingStrategy 253 | } 254 | 255 | // NewPagingRangeService は新しいPagingRangeServiceインスタンスを生成する 256 | func NewPagingRangeService(strategy PagingStrategy) *PagingRangeService { 257 | return &PagingRangeService{strategy: strategy} 258 | } 259 | 260 | // GetPagingRanges は利用可能なページング範囲のリストを返す 261 | func (s *PagingRangeService) GetPagingRanges() []string { 262 | return s.strategy.CalculatePagingRanges() 263 | } 264 | 265 | 266 | // FilterRemainingPagingRanges は未読の範囲のリストを返す 267 | func (s *PagingRangeService) FilterRemainingPagingRanges(allRanges []string, knownRanges []string) []string { 268 | if len(knownRanges) == 0 { 269 | return allRanges 270 | } 271 | 272 | // knownRanges をマップに変換 273 | knownMap := make(map[string]bool) 274 | for _, r := range knownRanges { 275 | knownMap[r] = true 276 | } 277 | 278 | // 未読の範囲を抽出 279 | remaining := make([]string, 0) 280 | for _, r := range allRanges { 281 | if !knownMap[r] { 282 | remaining = append(remaining, r) 283 | } 284 | } 285 | 286 | return remaining 287 | } 288 | 289 | // FindNextRange returns the next range in the sequence after the current range 290 | func (s *PagingRangeService) FindNextRange(allRanges []string, currentRange string) string { 291 | for i, r := range allRanges { 292 | if r == currentRange && i+1 < len(allRanges) { 293 | return allRanges[i+1] 294 | } 295 | } 296 | return "" 297 | } 298 | -------------------------------------------------------------------------------- /internal/tools/excel_format_range.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | 8 | z "github.com/Oudwins/zog" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | "github.com/negokaz/excel-mcp-server/internal/excel" 12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 13 | "github.com/xuri/excelize/v2" 14 | ) 15 | 16 | type ExcelFormatRangeArguments struct { 17 | FileAbsolutePath string `zog:"fileAbsolutePath"` 18 | SheetName string `zog:"sheetName"` 19 | Range string `zog:"range"` 20 | Styles [][]*excel.CellStyle `zog:"styles"` 21 | } 22 | 23 | var colorPattern, _ = regexp.Compile("^#[0-9A-Fa-f]{6}$") 24 | 25 | var excelFormatRangeArgumentsSchema = z.Struct(z.Shape{ 26 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 27 | "sheetName": z.String().Required(), 28 | "range": z.String().Required(), 29 | "styles": z.Slice(z.Slice( 30 | z.Ptr(z.Struct(z.Shape{ 31 | "border": z.Slice(z.Struct(z.Shape{ 32 | "type": z.StringLike[excel.BorderType]().OneOf(excel.BorderTypeValues()).Required(), 33 | "color": z.String().Match(colorPattern).Default("#000000"), 34 | "style": z.StringLike[excel.BorderStyle]().OneOf(excel.BorderStyleValues()).Default(excel.BorderStyleContinuous), 35 | })).Default([]excel.Border{}), 36 | "font": z.Ptr(z.Struct(z.Shape{ 37 | "bold": z.Ptr(z.Bool()), 38 | "italic": z.Ptr(z.Bool()), 39 | "underline": z.Ptr(z.StringLike[excel.FontUnderline]().OneOf(excel.FontUnderlineValues())), 40 | "size": z.Ptr(z.Int().GTE(1).LTE(409)), 41 | "strike": z.Ptr(z.Bool()), 42 | "color": z.Ptr(z.String().Match(colorPattern)), 43 | "vertAlign": z.Ptr(z.StringLike[excel.FontVertAlign]().OneOf(excel.FontVertAlignValues())), 44 | })), 45 | "fill": z.Ptr(z.Struct(z.Shape{ 46 | "type": z.StringLike[excel.FillType]().OneOf(excel.FillTypeValues()).Default(excel.FillTypePattern), 47 | "pattern": z.StringLike[excel.FillPattern]().OneOf(excel.FillPatternValues()).Default(excel.FillPatternSolid), 48 | "color": z.Slice(z.String().Match(colorPattern)).Default([]string{}), 49 | "shading": z.Ptr(z.StringLike[excel.FillShading]().OneOf(excel.FillShadingValues())), 50 | })), 51 | "numFmt": z.Ptr(z.String()), 52 | "decimalPlaces": z.Ptr(z.Int().GTE(0).LTE(30)), 53 | }), 54 | ))).Required(), 55 | }) 56 | 57 | func AddExcelFormatRangeTool(server *server.MCPServer) { 58 | server.AddTool(mcp.NewTool("excel_format_range", 59 | mcp.WithDescription("Format cells in the Excel sheet with style information"), 60 | mcp.WithString("fileAbsolutePath", 61 | mcp.Required(), 62 | mcp.Description("Absolute path to the Excel file"), 63 | ), 64 | mcp.WithString("sheetName", 65 | mcp.Required(), 66 | mcp.Description("Sheet name in the Excel file"), 67 | ), 68 | mcp.WithString("range", 69 | mcp.Required(), 70 | mcp.Description("Range of cells in the Excel sheet (e.g., \"A1:C3\")"), 71 | ), 72 | mcp.WithArray("styles", 73 | mcp.Required(), 74 | mcp.Description("2D array of style objects for each cell. If a cell does not change style, use null. The number of items of the array must match the range size."), 75 | mcp.Items(map[string]any{ 76 | "type": "array", 77 | "items": map[string]any{ 78 | "anyOf": []any{ 79 | map[string]any{ 80 | "type": "object", 81 | "description": "Style object for the cell", 82 | "properties": map[string]any{ 83 | "border": map[string]any{ 84 | "type": "array", 85 | "items": map[string]any{ 86 | "type": "object", 87 | "properties": map[string]any{ 88 | "type": map[string]any{ 89 | "type": "string", 90 | "enum": excel.BorderTypeValues(), 91 | }, 92 | "color": map[string]any{ 93 | "type": "string", 94 | "pattern": colorPattern.String(), 95 | }, 96 | "style": map[string]any{ 97 | "type": "string", 98 | "enum": excel.BorderStyleValues(), 99 | }, 100 | }, 101 | "required": []string{"type"}, 102 | }, 103 | }, 104 | "font": map[string]any{ 105 | "type": "object", 106 | "properties": map[string]any{ 107 | "bold": map[string]any{"type": "boolean"}, 108 | "italic": map[string]any{"type": "boolean"}, 109 | "underline": map[string]any{ 110 | "type": "string", 111 | "enum": excel.FontUnderlineValues(), 112 | }, 113 | "size": map[string]any{ 114 | "type": "number", 115 | "minimum": 1, 116 | "maximum": 409, 117 | }, 118 | "strike": map[string]any{"type": "boolean"}, 119 | "color": map[string]any{ 120 | "type": "string", 121 | "pattern": colorPattern.String(), 122 | }, 123 | "vertAlign": map[string]any{ 124 | "type": "string", 125 | "enum": excel.FontVertAlignValues(), 126 | }, 127 | }, 128 | }, 129 | "fill": map[string]any{ 130 | "type": "object", 131 | "properties": map[string]any{ 132 | "type": map[string]any{ 133 | "type": "string", 134 | "enum": []string{"gradient", "pattern"}, 135 | }, 136 | "pattern": map[string]any{ 137 | "type": "string", 138 | "enum": excel.FillPatternValues(), 139 | }, 140 | "color": map[string]any{ 141 | "type": "array", 142 | "items": map[string]any{ 143 | "type": "string", 144 | "pattern": colorPattern.String(), 145 | }, 146 | }, 147 | "shading": map[string]any{ 148 | "type": "string", 149 | "enum": excel.FillShadingValues(), 150 | }, 151 | }, 152 | "required": []string{"type", "pattern", "color"}, 153 | }, 154 | "numFmt": map[string]any{ 155 | "type": "string", 156 | "description": "Custom number format string", 157 | }, 158 | "decimalPlaces": map[string]any{ 159 | "type": "integer", 160 | "minimum": 0, 161 | "maximum": 30, 162 | }, 163 | }, 164 | }, 165 | map[string]any{ 166 | "type": "null", 167 | "description": "No style applied to this cell", 168 | }, 169 | }, 170 | }, 171 | }), 172 | ), 173 | ), handleFormatRange) 174 | } 175 | 176 | func handleFormatRange(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 177 | args := ExcelFormatRangeArguments{} 178 | issues := excelFormatRangeArgumentsSchema.Parse(request.Params.Arguments, &args) 179 | if len(issues) != 0 { 180 | return imcp.NewToolResultZogIssueMap(issues), nil 181 | } 182 | return formatRange(args.FileAbsolutePath, args.SheetName, args.Range, args.Styles) 183 | } 184 | 185 | func formatRange(fileAbsolutePath string, sheetName string, rangeStr string, styles [][]*excel.CellStyle) (*mcp.CallToolResult, error) { 186 | workbook, closeFn, err := excel.OpenFile(fileAbsolutePath) 187 | if err != nil { 188 | return nil, err 189 | } 190 | defer closeFn() 191 | 192 | startCol, startRow, endCol, endRow, err := excel.ParseRange(rangeStr) 193 | if err != nil { 194 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 195 | } 196 | 197 | // Check data consistency 198 | rangeRowSize := endRow - startRow + 1 199 | if len(styles) != rangeRowSize { 200 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of style rows (%d) does not match range size (%d)", len(styles), rangeRowSize)), nil 201 | } 202 | 203 | // Get worksheet 204 | worksheet, err := workbook.FindSheet(sheetName) 205 | if err != nil { 206 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 207 | } 208 | defer worksheet.Release() 209 | 210 | // Apply styles to each cell 211 | for i, styleRow := range styles { 212 | rangeColumnSize := endCol - startCol + 1 213 | if len(styleRow) != rangeColumnSize { 214 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of style columns in row %d (%d) does not match range size (%d)", i, len(styleRow), rangeColumnSize)), nil 215 | } 216 | 217 | for j, style := range styleRow { 218 | cell, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) 219 | if err != nil { 220 | return nil, err 221 | } 222 | if style != nil { 223 | if err := worksheet.SetCellStyle(cell, style); err != nil { 224 | return nil, fmt.Errorf("failed to set style for cell %s: %w", cell, err) 225 | } 226 | } 227 | } 228 | } 229 | 230 | if err := workbook.Save(); err != nil { 231 | return nil, err 232 | } 233 | 234 | // Create response HTML 235 | html := "Successfully applied styles to range %s in sheet %s
\n", rangeStr, sheetName) 237 | html += "Cell styles applied successfully.
\n" 246 | 247 | return mcp.NewToolResultText(html), nil 248 | } 249 | -------------------------------------------------------------------------------- /internal/excel/excel.go: -------------------------------------------------------------------------------- 1 | package excel 2 | 3 | import ( 4 | "github.com/xuri/excelize/v2" 5 | ) 6 | 7 | type Excel interface { 8 | // GetBackendName returns the backend used to manipulate the Excel file. 9 | GetBackendName() string 10 | // GetSheets returns a list of all worksheets in the Excel file. 11 | GetSheets() ([]Worksheet, error) 12 | // FindSheet finds a sheet by its name and returns a Worksheet. 13 | FindSheet(sheetName string) (Worksheet, error) 14 | // CreateNewSheet creates a new sheet with the specified name. 15 | CreateNewSheet(sheetName string) error 16 | // CopySheet copies a sheet from one to another. 17 | CopySheet(srcSheetName, destSheetName string) error 18 | // Save saves the Excel file. 19 | Save() error 20 | } 21 | 22 | type Worksheet interface { 23 | // Release releases the worksheet resources. 24 | Release() 25 | // Name returns the name of the worksheet. 26 | Name() (string, error) 27 | // GetTable returns a tables in this worksheet. 28 | GetTables() ([]Table, error) 29 | // GetPivotTable returns a pivot tables in this worksheet. 30 | GetPivotTables() ([]PivotTable, error) 31 | // SetValue sets a value in the specified cell. 32 | SetValue(cell string, value any) error 33 | // SetFormula sets a formula in the specified cell. 34 | SetFormula(cell string, formula string) error 35 | // GetValue gets the value from the specified cell. 36 | GetValue(cell string) (string, error) 37 | // GetFormula gets the formula from the specified cell. 38 | GetFormula(cell string) (string, error) 39 | // GetDimention gets the dimension of the worksheet. 40 | GetDimention() (string, error) 41 | // GetPagingStrategy returns the paging strategy for the worksheet. 42 | // The pageSize parameter is used to determine the max size of each page. 43 | GetPagingStrategy(pageSize int) (PagingStrategy, error) 44 | // CapturePicture returns base64 encoded image data of the specified range. 45 | CapturePicture(captureRange string) (string, error) 46 | // AddTable adds a table to this worksheet. 47 | AddTable(tableRange, tableName string) error 48 | // GetCellStyle gets style information for the specified cell. 49 | GetCellStyle(cell string) (*CellStyle, error) 50 | // SetCellStyle sets style for the specified cell. 51 | SetCellStyle(cell string, style *CellStyle) error 52 | } 53 | 54 | type Table struct { 55 | Name string 56 | Range string 57 | } 58 | 59 | type PivotTable struct { 60 | Name string 61 | Range string 62 | } 63 | 64 | type CellStyle struct { 65 | Border []Border `yaml:"border,omitempty"` 66 | Font *FontStyle `yaml:"font,omitempty"` 67 | Fill *FillStyle `yaml:"fill,omitempty"` 68 | NumFmt *string `yaml:"numFmt,omitempty"` 69 | DecimalPlaces *int `yaml:"decimalPlaces,omitempty"` 70 | } 71 | 72 | type Border struct { 73 | Type BorderType `yaml:"type"` 74 | Style BorderStyle `yaml:"style,omitempty"` 75 | Color string `yaml:"color,omitempty"` 76 | } 77 | 78 | type FontStyle struct { 79 | Bold *bool `yaml:"bold,omitempty"` 80 | Italic *bool `yaml:"italic,omitempty"` 81 | Underline *FontUnderline `yaml:"underline,omitempty"` 82 | Size *int `yaml:"size,omitempty"` 83 | Strike *bool `yaml:"strike,omitempty"` 84 | Color *string `yaml:"color,omitempty"` 85 | VertAlign *FontVertAlign `yaml:"vertAlign,omitempty"` 86 | } 87 | 88 | type FillStyle struct { 89 | Type FillType `yaml:"type,omitempty"` 90 | Pattern FillPattern `yaml:"pattern,omitempty"` 91 | Color []string `yaml:"color,omitempty"` 92 | Shading *FillShading `yaml:"shading,omitempty"` 93 | } 94 | 95 | // OpenFile opens an Excel file and returns an Excel interface. 96 | // It first tries to open the file using OLE automation, and if that fails, 97 | // it tries to using the excelize library. 98 | func OpenFile(absoluteFilePath string) (Excel, func(), error) { 99 | ole, releaseFn, err := NewExcelOle(absoluteFilePath) 100 | if err == nil { 101 | return ole, releaseFn, nil 102 | } 103 | // If OLE fails, try Excelize 104 | workbook, err := excelize.OpenFile(absoluteFilePath) 105 | if err != nil { 106 | return nil, func() {}, err 107 | } 108 | excelize := NewExcelizeExcel(workbook) 109 | return excelize, func() { 110 | workbook.Close() 111 | }, nil 112 | } 113 | 114 | // BorderType represents border direction 115 | type BorderType string 116 | 117 | const ( 118 | BorderTypeLeft BorderType = "left" 119 | BorderTypeRight BorderType = "right" 120 | BorderTypeTop BorderType = "top" 121 | BorderTypeBottom BorderType = "bottom" 122 | BorderTypeDiagonalDown BorderType = "diagonalDown" 123 | BorderTypeDiagonalUp BorderType = "diagonalUp" 124 | ) 125 | 126 | func (b BorderType) String() string { 127 | return string(b) 128 | } 129 | 130 | func (b BorderType) MarshalText() ([]byte, error) { 131 | return []byte(b.String()), nil 132 | } 133 | 134 | func BorderTypeValues() []BorderType { 135 | return []BorderType{ 136 | BorderTypeLeft, 137 | BorderTypeRight, 138 | BorderTypeTop, 139 | BorderTypeBottom, 140 | BorderTypeDiagonalDown, 141 | BorderTypeDiagonalUp, 142 | } 143 | } 144 | 145 | // BorderStyle represents border style constants 146 | type BorderStyle string 147 | 148 | const ( 149 | BorderStyleNone BorderStyle = "none" 150 | BorderStyleContinuous BorderStyle = "continuous" 151 | BorderStyleDash BorderStyle = "dash" 152 | BorderStyleDot BorderStyle = "dot" 153 | BorderStyleDouble BorderStyle = "double" 154 | BorderStyleDashDot BorderStyle = "dashDot" 155 | BorderStyleDashDotDot BorderStyle = "dashDotDot" 156 | BorderStyleSlantDashDot BorderStyle = "slantDashDot" 157 | BorderStyleMediumDashDot BorderStyle = "mediumDashDot" 158 | BorderStyleMediumDashDotDot BorderStyle = "mediumDashDotDot" 159 | ) 160 | 161 | func (b BorderStyle) String() string { 162 | return string(b) 163 | } 164 | 165 | func (b BorderStyle) MarshalText() ([]byte, error) { 166 | return []byte(b.String()), nil 167 | } 168 | 169 | func BorderStyleValues() []BorderStyle { 170 | return []BorderStyle{ 171 | BorderStyleNone, 172 | BorderStyleContinuous, 173 | BorderStyleDash, 174 | BorderStyleDot, 175 | BorderStyleDouble, 176 | BorderStyleDashDot, 177 | BorderStyleDashDotDot, 178 | BorderStyleSlantDashDot, 179 | BorderStyleMediumDashDot, 180 | BorderStyleMediumDashDotDot, 181 | } 182 | } 183 | 184 | // FontUnderline represents underline styles for font 185 | type FontUnderline string 186 | 187 | const ( 188 | FontUnderlineNone FontUnderline = "none" 189 | FontUnderlineSingle FontUnderline = "single" 190 | FontUnderlineDouble FontUnderline = "double" 191 | FontUnderlineSingleAccounting FontUnderline = "singleAccounting" 192 | FontUnderlineDoubleAccounting FontUnderline = "doubleAccounting" 193 | ) 194 | 195 | func (f FontUnderline) String() string { 196 | return string(f) 197 | } 198 | func (f FontUnderline) MarshalText() ([]byte, error) { 199 | return []byte(f.String()), nil 200 | } 201 | 202 | func FontUnderlineValues() []FontUnderline { 203 | return []FontUnderline{ 204 | FontUnderlineNone, 205 | FontUnderlineSingle, 206 | FontUnderlineDouble, 207 | FontUnderlineSingleAccounting, 208 | FontUnderlineDoubleAccounting, 209 | } 210 | } 211 | 212 | // FontVertAlign represents vertical alignment options for font styles 213 | type FontVertAlign string 214 | 215 | const ( 216 | FontVertAlignBaseline FontVertAlign = "baseline" 217 | FontVertAlignSuperscript FontVertAlign = "superscript" 218 | FontVertAlignSubscript FontVertAlign = "subscript" 219 | ) 220 | 221 | func (v FontVertAlign) String() string { 222 | return string(v) 223 | } 224 | 225 | func (v FontVertAlign) MarshalText() ([]byte, error) { 226 | return []byte(v.String()), nil 227 | } 228 | 229 | func FontVertAlignValues() []FontVertAlign { 230 | return []FontVertAlign{ 231 | FontVertAlignBaseline, 232 | FontVertAlignSuperscript, 233 | FontVertAlignSubscript, 234 | } 235 | } 236 | 237 | // FillType represents fill types for cell styles 238 | type FillType string 239 | 240 | const ( 241 | FillTypeGradient FillType = "gradient" 242 | FillTypePattern FillType = "pattern" 243 | ) 244 | 245 | func (f FillType) String() string { 246 | return string(f) 247 | } 248 | 249 | func (f FillType) MarshalText() ([]byte, error) { 250 | return []byte(f.String()), nil 251 | } 252 | 253 | func FillTypeValues() []FillType { 254 | return []FillType{ 255 | FillTypeGradient, 256 | FillTypePattern, 257 | } 258 | } 259 | 260 | // FillPattern represents fill pattern constants 261 | type FillPattern string 262 | 263 | const ( 264 | FillPatternNone FillPattern = "none" 265 | FillPatternSolid FillPattern = "solid" 266 | FillPatternMediumGray FillPattern = "mediumGray" 267 | FillPatternDarkGray FillPattern = "darkGray" 268 | FillPatternLightGray FillPattern = "lightGray" 269 | FillPatternDarkHorizontal FillPattern = "darkHorizontal" 270 | FillPatternDarkVertical FillPattern = "darkVertical" 271 | FillPatternDarkDown FillPattern = "darkDown" 272 | FillPatternDarkUp FillPattern = "darkUp" 273 | FillPatternDarkGrid FillPattern = "darkGrid" 274 | FillPatternDarkTrellis FillPattern = "darkTrellis" 275 | FillPatternLightHorizontal FillPattern = "lightHorizontal" 276 | FillPatternLightVertical FillPattern = "lightVertical" 277 | FillPatternLightDown FillPattern = "lightDown" 278 | FillPatternLightUp FillPattern = "lightUp" 279 | FillPatternLightGrid FillPattern = "lightGrid" 280 | FillPatternLightTrellis FillPattern = "lightTrellis" 281 | FillPatternGray125 FillPattern = "gray125" 282 | FillPatternGray0625 FillPattern = "gray0625" 283 | ) 284 | 285 | func (f FillPattern) String() string { 286 | return string(f) 287 | } 288 | 289 | func (f FillPattern) MarshalText() ([]byte, error) { 290 | return []byte(f.String()), nil 291 | } 292 | 293 | func FillPatternValues() []FillPattern { 294 | return []FillPattern{ 295 | FillPatternNone, 296 | FillPatternSolid, 297 | FillPatternMediumGray, 298 | FillPatternDarkGray, 299 | FillPatternLightGray, 300 | FillPatternDarkHorizontal, 301 | FillPatternDarkVertical, 302 | FillPatternDarkDown, 303 | FillPatternDarkUp, 304 | FillPatternDarkGrid, 305 | FillPatternDarkTrellis, 306 | FillPatternLightHorizontal, 307 | FillPatternLightVertical, 308 | FillPatternLightDown, 309 | FillPatternLightUp, 310 | FillPatternLightGrid, 311 | FillPatternLightTrellis, 312 | FillPatternGray125, 313 | FillPatternGray0625, 314 | } 315 | } 316 | 317 | // FillShading represents fill shading constants 318 | type FillShading string 319 | 320 | const ( 321 | FillShadingHorizontal FillShading = "horizontal" 322 | FillShadingVertical FillShading = "vertical" 323 | FillShadingDiagonalDown FillShading = "diagonalDown" 324 | FillShadingDiagonalUp FillShading = "diagonalUp" 325 | FillShadingFromCenter FillShading = "fromCenter" 326 | FillShadingFromCorner FillShading = "fromCorner" 327 | ) 328 | 329 | func (f FillShading) String() string { 330 | return string(f) 331 | } 332 | 333 | func (f FillShading) MarshalText() ([]byte, error) { 334 | return []byte(f.String()), nil 335 | } 336 | 337 | func FillShadingValues() []FillShading { 338 | return []FillShading{ 339 | FillShadingHorizontal, 340 | FillShadingVertical, 341 | FillShadingDiagonalDown, 342 | FillShadingDiagonalUp, 343 | FillShadingFromCenter, 344 | FillShadingFromCorner, 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /internal/tools/common.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "html" 7 | "path/filepath" 8 | "slices" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/goccy/go-yaml" 13 | "github.com/xuri/excelize/v2" 14 | 15 | "github.com/negokaz/excel-mcp-server/internal/excel" 16 | 17 | z "github.com/Oudwins/zog" 18 | ) 19 | 20 | type StyleRegistry struct { 21 | // Border styles 22 | borderStyles map[string]string // styleID -> YAML string 23 | borderHashToID map[string]string // styleHash -> styleID 24 | borderCounter int 25 | 26 | // Font styles 27 | fontStyles map[string]string // styleID -> YAML string 28 | fontHashToID map[string]string // styleHash -> styleID 29 | fontCounter int 30 | 31 | // Fill styles 32 | fillStyles map[string]string // styleID -> YAML string 33 | fillHashToID map[string]string // styleHash -> styleID 34 | fillCounter int 35 | 36 | // Number format styles 37 | numFmtStyles map[string]string // styleID -> NumFmt 38 | numFmtHashToID map[string]string // styleHash -> styleID 39 | numFmtCounter int 40 | 41 | // Decimal places styles 42 | decimalStyles map[string]string // styleID -> YAML string 43 | decimalHashToID map[string]string // styleHash -> styleID 44 | decimalCounter int 45 | } 46 | 47 | func NewStyleRegistry() *StyleRegistry { 48 | return &StyleRegistry{ 49 | borderStyles: make(map[string]string), 50 | borderHashToID: make(map[string]string), 51 | borderCounter: 0, 52 | fontStyles: make(map[string]string), 53 | fontHashToID: make(map[string]string), 54 | fontCounter: 0, 55 | fillStyles: make(map[string]string), 56 | fillHashToID: make(map[string]string), 57 | fillCounter: 0, 58 | numFmtStyles: make(map[string]string), 59 | numFmtHashToID: make(map[string]string), 60 | numFmtCounter: 0, 61 | decimalStyles: make(map[string]string), 62 | decimalHashToID: make(map[string]string), 63 | decimalCounter: 0, 64 | } 65 | } 66 | 67 | func (sr *StyleRegistry) RegisterStyle(cellStyle *excel.CellStyle) []string { 68 | if cellStyle == nil || sr.isEmptyStyle(cellStyle) { 69 | return []string{} 70 | } 71 | 72 | var styleIDs []string 73 | 74 | // Register border style 75 | if len(cellStyle.Border) > 0 { 76 | if borderID := sr.RegisterBorderStyle(cellStyle.Border); borderID != "" { 77 | styleIDs = append(styleIDs, borderID) 78 | } 79 | } 80 | 81 | // Register font style 82 | if cellStyle.Font != nil { 83 | if fontID := sr.RegisterFontStyle(cellStyle.Font); fontID != "" { 84 | styleIDs = append(styleIDs, fontID) 85 | } 86 | } 87 | 88 | // Register fill style 89 | if cellStyle.Fill != nil && cellStyle.Fill.Type != "" { 90 | if fillID := sr.RegisterFillStyle(cellStyle.Fill); fillID != "" { 91 | styleIDs = append(styleIDs, fillID) 92 | } 93 | } 94 | 95 | // Register number format style 96 | if cellStyle.NumFmt != nil && *cellStyle.NumFmt != "" { 97 | if numFmtID := sr.RegisterNumFmtStyle(*cellStyle.NumFmt); numFmtID != "" { 98 | styleIDs = append(styleIDs, numFmtID) 99 | } 100 | } 101 | 102 | // Register decimal places style 103 | if cellStyle.DecimalPlaces != nil && *cellStyle.DecimalPlaces != 0 { 104 | if decimalID := sr.RegisterDecimalStyle(*cellStyle.DecimalPlaces); decimalID != "" { 105 | styleIDs = append(styleIDs, decimalID) 106 | } 107 | } 108 | 109 | return styleIDs 110 | } 111 | 112 | func (sr *StyleRegistry) isEmptyStyle(style *excel.CellStyle) bool { 113 | if len(style.Border) > 0 || style.Font != nil || (style.NumFmt != nil && *style.NumFmt != "") || (style.DecimalPlaces != nil && *style.DecimalPlaces != 0) { 114 | return false 115 | } 116 | if style.Fill != nil && style.Fill.Type != "" { 117 | return false 118 | } 119 | return true 120 | } 121 | 122 | // calculateYamlHash calculates a hash for a YAML string 123 | func calculateYamlHash(yaml string) string { 124 | if yaml == "" { 125 | return "" 126 | } 127 | hash := md5.Sum([]byte(yaml)) 128 | return fmt.Sprintf("%x", hash)[:8] 129 | } 130 | 131 | // Individual style element registration methods 132 | func (sr *StyleRegistry) RegisterBorderStyle(borders []excel.Border) string { 133 | if len(borders) == 0 { 134 | return "" 135 | } 136 | 137 | yamlStr := convertToYAMLFlow(borders) 138 | if yamlStr == "" { 139 | return "" 140 | } 141 | 142 | styleHash := calculateYamlHash(yamlStr) 143 | if styleHash == "" { 144 | return "" 145 | } 146 | 147 | if existingID, exists := sr.borderHashToID[styleHash]; exists { 148 | return existingID 149 | } 150 | 151 | sr.borderCounter++ 152 | styleID := fmt.Sprintf("b%d", sr.borderCounter) 153 | sr.borderStyles[styleID] = yamlStr 154 | sr.borderHashToID[styleHash] = styleID 155 | 156 | return styleID 157 | } 158 | 159 | func (sr *StyleRegistry) RegisterFontStyle(font *excel.FontStyle) string { 160 | if font == nil { 161 | return "" 162 | } 163 | 164 | yamlStr := convertToYAMLFlow(font) 165 | if yamlStr == "" { 166 | return "" 167 | } 168 | 169 | styleHash := calculateYamlHash(yamlStr) 170 | if styleHash == "" { 171 | return "" 172 | } 173 | 174 | if existingID, exists := sr.fontHashToID[styleHash]; exists { 175 | return existingID 176 | } 177 | 178 | sr.fontCounter++ 179 | styleID := fmt.Sprintf("f%d", sr.fontCounter) 180 | sr.fontStyles[styleID] = yamlStr 181 | sr.fontHashToID[styleHash] = styleID 182 | 183 | return styleID 184 | } 185 | 186 | func (sr *StyleRegistry) RegisterFillStyle(fill *excel.FillStyle) string { 187 | if fill == nil || fill.Type == "" { 188 | return "" 189 | } 190 | 191 | yamlStr := convertToYAMLFlow(fill) 192 | if yamlStr == "" { 193 | return "" 194 | } 195 | 196 | styleHash := calculateYamlHash(yamlStr) 197 | if styleHash == "" { 198 | return "" 199 | } 200 | 201 | if existingID, exists := sr.fillHashToID[styleHash]; exists { 202 | return existingID 203 | } 204 | 205 | sr.fillCounter++ 206 | styleID := fmt.Sprintf("l%d", sr.fillCounter) 207 | sr.fillStyles[styleID] = yamlStr 208 | sr.fillHashToID[styleHash] = styleID 209 | 210 | return styleID 211 | } 212 | 213 | func (sr *StyleRegistry) RegisterNumFmtStyle(numFmt string) string { 214 | if numFmt == "" { 215 | return "" 216 | } 217 | 218 | styleHash := calculateYamlHash(numFmt) 219 | if styleHash == "" { 220 | return "" 221 | } 222 | 223 | if existingID, exists := sr.numFmtHashToID[styleHash]; exists { 224 | return existingID 225 | } 226 | 227 | sr.numFmtCounter++ 228 | styleID := fmt.Sprintf("n%d", sr.numFmtCounter) 229 | sr.numFmtStyles[styleID] = numFmt 230 | sr.numFmtHashToID[styleHash] = styleID 231 | 232 | return styleID 233 | } 234 | 235 | func (sr *StyleRegistry) RegisterDecimalStyle(decimal int) string { 236 | if decimal == 0 { 237 | return "" 238 | } 239 | 240 | yamlStr := convertToYAMLFlow(decimal) 241 | if yamlStr == "" { 242 | return "" 243 | } 244 | 245 | styleHash := calculateYamlHash(yamlStr) 246 | if styleHash == "" { 247 | return "" 248 | } 249 | 250 | if existingID, exists := sr.decimalHashToID[styleHash]; exists { 251 | return existingID 252 | } 253 | 254 | sr.decimalCounter++ 255 | styleID := fmt.Sprintf("d%d", sr.decimalCounter) 256 | sr.decimalStyles[styleID] = yamlStr 257 | sr.decimalHashToID[styleHash] = styleID 258 | 259 | return styleID 260 | } 261 | 262 | func (sr *StyleRegistry) GenerateStyleDefinitions() string { 263 | totalCount := len(sr.borderStyles) + len(sr.fontStyles) + len(sr.fillStyles) + len(sr.numFmtStyles) + len(sr.decimalStyles) 264 | if totalCount == 0 { 265 | return "" 266 | } 267 | 268 | var result strings.Builder 269 | result.WriteString("%s: %s\n", styleID, styleLabel, html.EscapeString(yamlStr)))
307 | }
308 | }
309 | return result.String()
310 | }
311 |
312 | func sortStyleIDs(styleIDs []string) {
313 | slices.SortFunc(styleIDs, func(a, b string) int {
314 | // styleID must have number suffix after prefix
315 | ai, _ := strconv.Atoi(a[1:])
316 | bi, _ := strconv.Atoi(b[1:])
317 | return ai - bi
318 | })
319 | }
320 |
321 | // Common function to convert any value to YAML flow format
322 | func convertToYAMLFlow(value any) string {
323 | if value == nil {
324 | return ""
325 | }
326 | yamlBytes, err := yaml.MarshalWithOptions(value, yaml.Flow(true), yaml.OmitEmpty())
327 | if err != nil {
328 | return ""
329 | }
330 | yamlStr := strings.TrimSpace(strings.ReplaceAll(string(yamlBytes), "\"", ""))
331 | return yamlStr
332 | }
333 |
334 | func CreateHTMLTableOfValues(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) {
335 | return createHTMLTable(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) {
336 | return worksheet.GetValue(cellRange)
337 | })
338 | }
339 |
340 | func CreateHTMLTableOfFormula(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) {
341 | return createHTMLTable(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) {
342 | return worksheet.GetFormula(cellRange)
343 | })
344 | }
345 |
346 | // CreateHTMLTable creates a table data in HTML format
347 | func createHTMLTable(startCol int, startRow int, endCol int, endRow int, extractor func(cellRange string) (string, error)) (*string, error) {
348 | return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, extractor, nil)
349 | }
350 |
351 | func CreateHTMLTableOfValuesWithStyle(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) {
352 | return createHTMLTableWithStyle(startCol, startRow, endCol, endRow,
353 | func(cellRange string) (string, error) {
354 | return worksheet.GetValue(cellRange)
355 | },
356 | func(cellRange string) (*excel.CellStyle, error) {
357 | return worksheet.GetCellStyle(cellRange)
358 | })
359 | }
360 |
361 | func CreateHTMLTableOfFormulaWithStyle(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) {
362 | return createHTMLTableWithStyle(startCol, startRow, endCol, endRow,
363 | func(cellRange string) (string, error) {
364 | return worksheet.GetFormula(cellRange)
365 | },
366 | func(cellRange string) (*excel.CellStyle, error) {
367 | return worksheet.GetCellStyle(cellRange)
368 | })
369 | }
370 |
371 | func createHTMLTableWithStyle(startCol int, startRow int, endCol int, endRow int, extractor func(cellRange string) (string, error), styleExtractor func(cellRange string) (*excel.CellStyle, error)) (*string, error) {
372 | registry := NewStyleRegistry()
373 |
374 | // データとスタイルを収集
375 | var result strings.Builder
376 | result.WriteString("| ") 377 | 378 | // 列アドレスの出力 379 | for col := startCol; col <= endCol; col++ { 380 | name, _ := excelize.ColumnNumberToName(col) 381 | result.WriteString(fmt.Sprintf(" | %s | ", name)) 382 | } 383 | result.WriteString("|||
|---|---|---|---|---|
| %d | ", row)) 389 | 390 | for col := startCol; col <= endCol; col++ { 391 | axis, _ := excelize.CoordinatesToCellName(col, row) 392 | value, _ := extractor(axis) 393 | 394 | var tdTag string 395 | if styleExtractor != nil { 396 | cellStyle, err := styleExtractor(axis) 397 | if err == nil && cellStyle != nil { 398 | styleIDs := registry.RegisterStyle(cellStyle) 399 | if len(styleIDs) > 0 { 400 | tdTag = fmt.Sprintf("", strings.Join(styleIDs, " ")) 401 | } else { 402 | tdTag = " | " 403 | } 404 | } else { 405 | tdTag = " | " 406 | } 407 | } else { 408 | tdTag = " | " 409 | } 410 | 411 | result.WriteString(fmt.Sprintf("%s%s | ", tdTag, strings.ReplaceAll(html.EscapeString(value), "\n", "