ExcelTamer 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ExcelTamer/__init__.py +1 -0
- ExcelTamer/mcp/__init__.py +0 -0
- ExcelTamer/mcp/audit.py +37 -0
- ExcelTamer/mcp/config.py +25 -0
- ExcelTamer/mcp/engine/__init__.py +0 -0
- ExcelTamer/mcp/engine/diff.py +131 -0
- ExcelTamer/mcp/engine/read.py +109 -0
- ExcelTamer/mcp/engine/search.py +101 -0
- ExcelTamer/mcp/engine/workbook.py +63 -0
- ExcelTamer/mcp/engine/write.py +97 -0
- ExcelTamer/mcp/excel.py +110 -0
- ExcelTamer/mcp/main.py +18 -0
- ExcelTamer/mcp/prompts/README.md +0 -0
- ExcelTamer/mcp/prompts/financial_metric_extract.md +23 -0
- ExcelTamer/mcp/prompts/safe_edit.md +26 -0
- ExcelTamer/mcp/safety.py +39 -0
- ExcelTamer/mcp/schemas/__init__.py +0 -0
- ExcelTamer/mcp/server.py +541 -0
- ExcelTamer/mcp/sessions.py +35 -0
- exceltamer-0.2.0.dist-info/METADATA +170 -0
- exceltamer-0.2.0.dist-info/RECORD +25 -0
- exceltamer-0.2.0.dist-info/WHEEL +5 -0
- exceltamer-0.2.0.dist-info/entry_points.txt +2 -0
- exceltamer-0.2.0.dist-info/licenses/LICENSE +674 -0
- exceltamer-0.2.0.dist-info/top_level.txt +1 -0
ExcelTamer/mcp/main.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
|
|
2
|
+
import asyncio
|
|
3
|
+
import sys
|
|
4
|
+
import argparse
|
|
5
|
+
from .server import run
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(description="ExcelTamer MCP Server")
|
|
9
|
+
parser.add_argument("--port", type=int, help="Port to run the SSE server on (default: stdio mode)")
|
|
10
|
+
args = parser.parse_args()
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
asyncio.run(run(port=args.port))
|
|
14
|
+
except KeyboardInterrupt:
|
|
15
|
+
sys.exit(0)
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
You are an expert financial analyst. Your goal is to extract a specific financial metric (e.g., "Net Income", "Revenue") from an Excel workbook reliably.
|
|
3
|
+
|
|
4
|
+
Follow these steps:
|
|
5
|
+
|
|
6
|
+
1. **Locate Data**:
|
|
7
|
+
- Open the workbook.
|
|
8
|
+
- Use `excel.search` to find the metric name (e.g., "Net Income") in the workbook.
|
|
9
|
+
- Note the sheet name and row number.
|
|
10
|
+
|
|
11
|
+
2. **Identify Time Axis**:
|
|
12
|
+
- Look for date headers (years like "2023", "2024" or quarters "Q1", "Q2") in the rows above the metric or columns to the left.
|
|
13
|
+
- Use `excel.read_sheet_preview` or `excel.read_range` around the found metric cell to understand the table layout.
|
|
14
|
+
|
|
15
|
+
3. **Extract Series**:
|
|
16
|
+
- Once you identified the metric row and the time columns, read the specific intersection cells using `excel.query_cell` or `excel.read_range`.
|
|
17
|
+
|
|
18
|
+
4. **Validate**:
|
|
19
|
+
- Ensure the extracted values are numbers. If they are formulas, note that.
|
|
20
|
+
|
|
21
|
+
5. **Report**:
|
|
22
|
+
- Present the extracted time-series data clearly.
|
|
23
|
+
- Close the workbook.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
You are tasked with safely editing an Excel workbook using the `ExcelTamer` tools.
|
|
3
|
+
Follow this workflow strictly to ensure data integrity:
|
|
4
|
+
|
|
5
|
+
1. **Open & Inspect**:
|
|
6
|
+
- Open the workbook (`excel.open_workbook`).
|
|
7
|
+
- Read the structure (`excel.get_structure`) and preview relevant sheets (`excel.read_sheet_preview`).
|
|
8
|
+
|
|
9
|
+
2. **Plan**:
|
|
10
|
+
- Identify the specific cells or ranges that need modification.
|
|
11
|
+
- If searching for data, use `excel.search`.
|
|
12
|
+
|
|
13
|
+
3. **Checkpoint (Critical)**:
|
|
14
|
+
- Before making ANY changes, create a checkpoint (`excel.checkpoint_create` with name "pre_edit").
|
|
15
|
+
|
|
16
|
+
4. **Edit**:
|
|
17
|
+
- Apply changes using `excel.change_cell_value` or `excel.batch_update_cells` or `excel.write_range`.
|
|
18
|
+
|
|
19
|
+
5. **Verify**:
|
|
20
|
+
- Read back the changed cells (`excel.read_range` or `excel.query_cell`) to confirm they match expectations.
|
|
21
|
+
- Check the operation history (`excel.preview_diff`).
|
|
22
|
+
|
|
23
|
+
6. **Finalize**:
|
|
24
|
+
- If successful, save the workbook (`excel.save` or `excel.save_as`).
|
|
25
|
+
- If something went wrong, rollback immediately (`excel.checkpoint_rollback` name="pre_edit").
|
|
26
|
+
- Close the workbook (`excel.close`).
|
ExcelTamer/mcp/safety.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from .config import ALLOWED_ROOTS
|
|
5
|
+
|
|
6
|
+
class SecurityError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
def validate_path(request_path: str, allow_write: bool = False) -> Path:
|
|
10
|
+
"""
|
|
11
|
+
Validates that the given path is within ALLOWED_ROOTS.
|
|
12
|
+
Resolves symlinks and checks for traversal.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
# Resolve user path to absolute
|
|
16
|
+
abs_path = Path(request_path).resolve()
|
|
17
|
+
|
|
18
|
+
# Check if it matches any allowed root
|
|
19
|
+
is_allowed = False
|
|
20
|
+
for root in ALLOWED_ROOTS:
|
|
21
|
+
allowed_root = Path(root).resolve()
|
|
22
|
+
# method 1: check if allowed_root is a parent of abs_path
|
|
23
|
+
# calculate relative path; if it starts with '..', it's outside
|
|
24
|
+
try:
|
|
25
|
+
abs_path.relative_to(allowed_root)
|
|
26
|
+
is_allowed = True
|
|
27
|
+
break
|
|
28
|
+
except ValueError:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
if not is_allowed:
|
|
32
|
+
raise SecurityError(f"Path '{request_path}' is not within allowed roots: {ALLOWED_ROOTS}")
|
|
33
|
+
|
|
34
|
+
return abs_path
|
|
35
|
+
|
|
36
|
+
except Exception as e:
|
|
37
|
+
if isinstance(e, SecurityError):
|
|
38
|
+
raise
|
|
39
|
+
raise SecurityError(f"Invalid path: {e}")
|
|
File without changes
|
ExcelTamer/mcp/server.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcp.server import Server
|
|
7
|
+
from mcp.types import (
|
|
8
|
+
Tool,
|
|
9
|
+
TextContent,
|
|
10
|
+
ImageContent,
|
|
11
|
+
EmbeddedResource
|
|
12
|
+
)
|
|
13
|
+
import mcp.types as types
|
|
14
|
+
|
|
15
|
+
from .engine import workbook as workbook_engine
|
|
16
|
+
from .engine import read as read_engine
|
|
17
|
+
from .engine import write as write_engine
|
|
18
|
+
from .engine import search as search_engine
|
|
19
|
+
from .engine import diff as diff_engine
|
|
20
|
+
from .sessions import session
|
|
21
|
+
from mcp.types import Resource, Prompt, PromptMessage, PromptArgument
|
|
22
|
+
|
|
23
|
+
# Configure logging (stderr so it doesn't break json-rpc on stdout)
|
|
24
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
25
|
+
logger = logging.getLogger("ExcelTamerMCP")
|
|
26
|
+
|
|
27
|
+
server = Server("exceltamer-mcp")
|
|
28
|
+
PROMPT_DIR = Path(__file__).with_name("prompts")
|
|
29
|
+
|
|
30
|
+
@server.list_tools()
|
|
31
|
+
async def handle_list_tools() -> list[Tool]:
|
|
32
|
+
return [
|
|
33
|
+
Tool(
|
|
34
|
+
name="excel.open_workbook",
|
|
35
|
+
description="Open an Excel workbook and get a workbook_id. required for other operations.",
|
|
36
|
+
inputSchema={
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"path": {"type": "string", "description": "Absolute path to the Excel file"},
|
|
40
|
+
"mode": {"type": "string", "enum": ["ro", "rw"], "default": "ro", "description": "Open mode: ro=read-only, rw=read-write"}
|
|
41
|
+
},
|
|
42
|
+
"required": ["path"]
|
|
43
|
+
}
|
|
44
|
+
),
|
|
45
|
+
Tool(
|
|
46
|
+
name="excel.close",
|
|
47
|
+
description="Close an open workbook by ID.",
|
|
48
|
+
inputSchema={
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"workbook_id": {"type": "string"}
|
|
52
|
+
},
|
|
53
|
+
"required": ["workbook_id"]
|
|
54
|
+
}
|
|
55
|
+
),
|
|
56
|
+
Tool(
|
|
57
|
+
name="excel.save",
|
|
58
|
+
description="Save the workbook (overwriting file).",
|
|
59
|
+
inputSchema={
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"workbook_id": {"type": "string"}
|
|
63
|
+
},
|
|
64
|
+
"required": ["workbook_id"]
|
|
65
|
+
}
|
|
66
|
+
),
|
|
67
|
+
Tool(
|
|
68
|
+
name="excel.save_as",
|
|
69
|
+
description="Save the workbook to a new path.",
|
|
70
|
+
inputSchema={
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"workbook_id": {"type": "string"},
|
|
74
|
+
"output_path": {"type": "string"}
|
|
75
|
+
},
|
|
76
|
+
"required": ["workbook_id", "output_path"]
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
Tool(
|
|
80
|
+
name="excel.get_structure",
|
|
81
|
+
description="Get structure of the workbook (sheets, named ranges).",
|
|
82
|
+
inputSchema={
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {
|
|
85
|
+
"workbook_id": {"type": "string"}
|
|
86
|
+
},
|
|
87
|
+
"required": ["workbook_id"]
|
|
88
|
+
}
|
|
89
|
+
),
|
|
90
|
+
Tool(
|
|
91
|
+
name="excel.query_cell",
|
|
92
|
+
description="Get value, formula, and text of a specific cell.",
|
|
93
|
+
inputSchema={
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {
|
|
96
|
+
"workbook_id": {"type": "string"},
|
|
97
|
+
"sheet": {"type": "string"},
|
|
98
|
+
"cell": {"type": "string"}
|
|
99
|
+
},
|
|
100
|
+
"required": ["workbook_id", "sheet", "cell"]
|
|
101
|
+
}
|
|
102
|
+
),
|
|
103
|
+
Tool(
|
|
104
|
+
name="excel.read_range",
|
|
105
|
+
description="Read a range of cells as a 2D array.",
|
|
106
|
+
inputSchema={
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {
|
|
109
|
+
"workbook_id": {"type": "string"},
|
|
110
|
+
"sheet": {"type": "string"},
|
|
111
|
+
"range_a1": {"type": "string", "description": "Range address (e.g. A1:C10). If omitted, uses used range."},
|
|
112
|
+
"max_rows": {"type": "integer", "default": 1000},
|
|
113
|
+
"max_cols": {"type": "integer", "default": 100}
|
|
114
|
+
},
|
|
115
|
+
"required": ["workbook_id", "sheet"]
|
|
116
|
+
}
|
|
117
|
+
),
|
|
118
|
+
Tool(
|
|
119
|
+
name="excel.read_sheet_preview",
|
|
120
|
+
description="Quick preview of a sheet's content (top-left).",
|
|
121
|
+
inputSchema={
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {
|
|
124
|
+
"workbook_id": {"type": "string"},
|
|
125
|
+
"sheet": {"type": "string"},
|
|
126
|
+
"rows": {"type": "integer", "default": 50},
|
|
127
|
+
"cols": {"type": "integer", "default": 20}
|
|
128
|
+
},
|
|
129
|
+
"required": ["workbook_id", "sheet"]
|
|
130
|
+
}
|
|
131
|
+
),
|
|
132
|
+
Tool(
|
|
133
|
+
name="excel.change_cell_value",
|
|
134
|
+
description="Write a value/formula to a single cell.",
|
|
135
|
+
inputSchema={
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"workbook_id": {"type": "string"},
|
|
139
|
+
"sheet": {"type": "string"},
|
|
140
|
+
"cell": {"type": "string"},
|
|
141
|
+
"value": {"type": ["string", "number", "boolean", "null"]}
|
|
142
|
+
},
|
|
143
|
+
"required": ["workbook_id", "sheet", "cell", "value"]
|
|
144
|
+
}
|
|
145
|
+
),
|
|
146
|
+
Tool(
|
|
147
|
+
name="excel.batch_update_cells",
|
|
148
|
+
description="Write multiple non-contiguous cells in one go.",
|
|
149
|
+
inputSchema={
|
|
150
|
+
"type": "object",
|
|
151
|
+
"properties": {
|
|
152
|
+
"workbook_id": {"type": "string"},
|
|
153
|
+
"updates": {
|
|
154
|
+
"type": "array",
|
|
155
|
+
"items": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"properties": {
|
|
158
|
+
"sheet": {"type": "string"},
|
|
159
|
+
"cell": {"type": "string"},
|
|
160
|
+
"value": {"type": ["string", "number", "boolean", "null"]}
|
|
161
|
+
},
|
|
162
|
+
"required": ["sheet", "cell", "value"]
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
"required": ["workbook_id", "updates"]
|
|
167
|
+
}
|
|
168
|
+
),
|
|
169
|
+
Tool(
|
|
170
|
+
name="excel.write_range",
|
|
171
|
+
description="Write a 2D array of values starting at a specific cell.",
|
|
172
|
+
inputSchema={
|
|
173
|
+
"type": "object",
|
|
174
|
+
"properties": {
|
|
175
|
+
"workbook_id": {"type": "string"},
|
|
176
|
+
"sheet": {"type": "string"},
|
|
177
|
+
"start_cell": {"type": "string"},
|
|
178
|
+
"values": {
|
|
179
|
+
"type": "array",
|
|
180
|
+
"items": {"type": "array", "items": {"type": ["string", "number", "boolean", "null"]}}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
"required": ["workbook_id", "sheet", "start_cell", "values"]
|
|
184
|
+
}
|
|
185
|
+
),
|
|
186
|
+
Tool(
|
|
187
|
+
name="excel.search",
|
|
188
|
+
description="Search for a value across a sheet or the entire workbook.",
|
|
189
|
+
inputSchema={
|
|
190
|
+
"type": "object",
|
|
191
|
+
"properties": {
|
|
192
|
+
"workbook_id": {"type": "string"},
|
|
193
|
+
"query": {"type": "string"},
|
|
194
|
+
"sheet": {"type": "string", "description": "Optional. If omitted, searches all sheets."},
|
|
195
|
+
"scope": {"type": "string", "enum": ["values", "formulas", "both"], "default": "both"},
|
|
196
|
+
"match_mode": {"type": "string", "enum": ["contains", "exact", "regex"], "default": "contains"},
|
|
197
|
+
"max_hits": {"type": "integer", "default": 200}
|
|
198
|
+
},
|
|
199
|
+
"required": ["workbook_id", "query"]
|
|
200
|
+
}
|
|
201
|
+
),
|
|
202
|
+
Tool(
|
|
203
|
+
name="excel.checkpoint_create",
|
|
204
|
+
description="Create a named checkpoint of the current workbook state.",
|
|
205
|
+
inputSchema={
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"workbook_id": {"type": "string"},
|
|
209
|
+
"name": {"type": "string"}
|
|
210
|
+
},
|
|
211
|
+
"required": ["workbook_id", "name"]
|
|
212
|
+
}
|
|
213
|
+
),
|
|
214
|
+
Tool(
|
|
215
|
+
name="excel.checkpoint_rollback",
|
|
216
|
+
description="Rollback workbook to a named checkpoint (reloads file).",
|
|
217
|
+
inputSchema={
|
|
218
|
+
"type": "object",
|
|
219
|
+
"properties": {
|
|
220
|
+
"workbook_id": {"type": "string"},
|
|
221
|
+
"name": {"type": "string"}
|
|
222
|
+
},
|
|
223
|
+
"required": ["workbook_id", "name"]
|
|
224
|
+
}
|
|
225
|
+
),
|
|
226
|
+
Tool(
|
|
227
|
+
name="excel.preview_diff",
|
|
228
|
+
description="Show recent changes/audit history for this workbook.",
|
|
229
|
+
inputSchema={
|
|
230
|
+
"type": "object",
|
|
231
|
+
"properties": {
|
|
232
|
+
"workbook_id": {"type": "string"},
|
|
233
|
+
"max_changes": {"type": "integer", "default": 20}
|
|
234
|
+
},
|
|
235
|
+
"required": ["workbook_id"]
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
@server.list_resources()
|
|
241
|
+
async def handle_list_resources() -> list[Resource]:
|
|
242
|
+
# Resource: List of open workbooks
|
|
243
|
+
# URI: excel://workbooks
|
|
244
|
+
wb_list_resource = Resource(
|
|
245
|
+
uri="excel://workbooks",
|
|
246
|
+
name="Open Workbooks",
|
|
247
|
+
description="List of currently open workbook IDs and filenames",
|
|
248
|
+
mimeType="application/json"
|
|
249
|
+
)
|
|
250
|
+
return [wb_list_resource]
|
|
251
|
+
|
|
252
|
+
@server.read_resource()
|
|
253
|
+
async def handle_read_resource(uri: str) -> str | bytes:
|
|
254
|
+
if uri == "excel://workbooks":
|
|
255
|
+
workbooks = []
|
|
256
|
+
for wb_id, automation in session.open_workbooks.items():
|
|
257
|
+
workbooks.append({
|
|
258
|
+
"id": wb_id,
|
|
259
|
+
"name": automation.wb.name
|
|
260
|
+
})
|
|
261
|
+
return str(workbooks)
|
|
262
|
+
|
|
263
|
+
# Pattern: excel://workbooks/{id}/summary
|
|
264
|
+
# Simple manual parsing since we don't have pattern matching in this basic skeleton
|
|
265
|
+
import re
|
|
266
|
+
match = re.match(r"excel://workbooks/([^/]+)/summary", uri)
|
|
267
|
+
if match:
|
|
268
|
+
wb_id = match.group(1)
|
|
269
|
+
automation = session.get_workbook(wb_id)
|
|
270
|
+
if automation:
|
|
271
|
+
return str(read_engine.get_structure(wb_id))
|
|
272
|
+
else:
|
|
273
|
+
raise ValueError(f"Workbook {wb_id} not found")
|
|
274
|
+
|
|
275
|
+
raise ValueError(f"Resource not found: {uri}")
|
|
276
|
+
|
|
277
|
+
@server.list_prompts()
|
|
278
|
+
async def handle_list_prompts() -> list[Prompt]:
|
|
279
|
+
return [
|
|
280
|
+
Prompt(
|
|
281
|
+
name="safe-edit",
|
|
282
|
+
description="Workflow for safely editing an Excel file with checkpoints.",
|
|
283
|
+
arguments=[]
|
|
284
|
+
),
|
|
285
|
+
Prompt(
|
|
286
|
+
name="financial-extract",
|
|
287
|
+
description="Workflow for extracting financial metrics.",
|
|
288
|
+
arguments=[]
|
|
289
|
+
)
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
@server.get_prompt()
|
|
293
|
+
async def handle_get_prompt(name: str, arguments: dict | None) -> types.GetPromptResult:
|
|
294
|
+
if name == "safe-edit":
|
|
295
|
+
content = (PROMPT_DIR / "safe_edit.md").read_text(encoding="utf-8")
|
|
296
|
+
return types.GetPromptResult(
|
|
297
|
+
messages=[
|
|
298
|
+
PromptMessage(
|
|
299
|
+
role="user",
|
|
300
|
+
content=TextContent(type="text", text=content)
|
|
301
|
+
)
|
|
302
|
+
]
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
elif name == "financial-extract":
|
|
306
|
+
content = (PROMPT_DIR / "financial_metric_extract.md").read_text(
|
|
307
|
+
encoding="utf-8"
|
|
308
|
+
)
|
|
309
|
+
return types.GetPromptResult(
|
|
310
|
+
messages=[
|
|
311
|
+
PromptMessage(
|
|
312
|
+
role="user",
|
|
313
|
+
content=TextContent(type="text", text=content)
|
|
314
|
+
)
|
|
315
|
+
]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
raise ValueError(f"Prompt not found: {name}")
|
|
319
|
+
|
|
320
|
+
@server.call_tool()
|
|
321
|
+
async def handle_call_tool(
|
|
322
|
+
name: str, arguments: dict | None
|
|
323
|
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
324
|
+
if not arguments:
|
|
325
|
+
arguments = {}
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
if name == "excel.open_workbook":
|
|
329
|
+
path = arguments.get("path")
|
|
330
|
+
mode = arguments.get("mode", "ro")
|
|
331
|
+
if not path:
|
|
332
|
+
raise ValueError("path is required")
|
|
333
|
+
result = workbook_engine.open_workbook(path, mode)
|
|
334
|
+
return [TextContent(type="text", text=str(result))]
|
|
335
|
+
|
|
336
|
+
elif name == "excel.close":
|
|
337
|
+
workbook_id = arguments.get("workbook_id")
|
|
338
|
+
if not workbook_id:
|
|
339
|
+
raise ValueError("workbook_id is required")
|
|
340
|
+
result = workbook_engine.close_workbook(workbook_id)
|
|
341
|
+
return [TextContent(type="text", text=str(result))]
|
|
342
|
+
|
|
343
|
+
elif name == "excel.save":
|
|
344
|
+
workbook_id = arguments.get("workbook_id")
|
|
345
|
+
if not workbook_id:
|
|
346
|
+
raise ValueError("workbook_id is required")
|
|
347
|
+
result = workbook_engine.save_workbook(workbook_id)
|
|
348
|
+
return [TextContent(type="text", text=str(result))]
|
|
349
|
+
|
|
350
|
+
elif name == "excel.save_as":
|
|
351
|
+
workbook_id = arguments.get("workbook_id")
|
|
352
|
+
output_path = arguments.get("output_path")
|
|
353
|
+
if not workbook_id or not output_path:
|
|
354
|
+
raise ValueError("workbook_id and output_path are required")
|
|
355
|
+
result = workbook_engine.save_as_workbook(workbook_id, output_path)
|
|
356
|
+
return [TextContent(type="text", text=str(result))]
|
|
357
|
+
|
|
358
|
+
elif name == "excel.get_structure":
|
|
359
|
+
workbook_id = arguments.get("workbook_id")
|
|
360
|
+
if not workbook_id:
|
|
361
|
+
raise ValueError("workbook_id is required")
|
|
362
|
+
result = read_engine.get_structure(workbook_id)
|
|
363
|
+
return [TextContent(type="text", text=str(result))]
|
|
364
|
+
|
|
365
|
+
elif name == "excel.query_cell":
|
|
366
|
+
workbook_id = arguments.get("workbook_id")
|
|
367
|
+
sheet = arguments.get("sheet")
|
|
368
|
+
cell = arguments.get("cell")
|
|
369
|
+
if not workbook_id or not sheet or not cell:
|
|
370
|
+
raise ValueError("workbook_id, sheet, and cell are required")
|
|
371
|
+
result = read_engine.query_cell(workbook_id, sheet, cell)
|
|
372
|
+
return [TextContent(type="text", text=str(result))]
|
|
373
|
+
|
|
374
|
+
elif name == "excel.read_range":
|
|
375
|
+
workbook_id = arguments.get("workbook_id")
|
|
376
|
+
sheet = arguments.get("sheet")
|
|
377
|
+
range_a1 = arguments.get("range_a1")
|
|
378
|
+
max_rows = arguments.get("max_rows", 1000)
|
|
379
|
+
max_cols = arguments.get("max_cols", 100)
|
|
380
|
+
|
|
381
|
+
if not workbook_id or not sheet:
|
|
382
|
+
raise ValueError("workbook_id and sheet are required")
|
|
383
|
+
|
|
384
|
+
result = read_engine.read_range(
|
|
385
|
+
workbook_id,
|
|
386
|
+
sheet,
|
|
387
|
+
range_a1=range_a1,
|
|
388
|
+
max_rows=max_rows,
|
|
389
|
+
max_cols=max_cols
|
|
390
|
+
)
|
|
391
|
+
return [TextContent(type="text", text=str(result))]
|
|
392
|
+
|
|
393
|
+
elif name == "excel.read_sheet_preview":
|
|
394
|
+
workbook_id = arguments.get("workbook_id")
|
|
395
|
+
sheet = arguments.get("sheet")
|
|
396
|
+
rows = arguments.get("rows", 50)
|
|
397
|
+
cols = arguments.get("cols", 20)
|
|
398
|
+
|
|
399
|
+
if not workbook_id or not sheet:
|
|
400
|
+
raise ValueError("workbook_id and sheet are required")
|
|
401
|
+
|
|
402
|
+
result = read_engine.read_sheet_preview(workbook_id, sheet, rows=rows, cols=cols)
|
|
403
|
+
return [TextContent(type="text", text=str(result))]
|
|
404
|
+
|
|
405
|
+
elif name == "excel.change_cell_value":
|
|
406
|
+
workbook_id = arguments.get("workbook_id")
|
|
407
|
+
sheet = arguments.get("sheet")
|
|
408
|
+
cell = arguments.get("cell")
|
|
409
|
+
value = arguments.get("value")
|
|
410
|
+
|
|
411
|
+
if not workbook_id or not sheet or not cell:
|
|
412
|
+
raise ValueError("workbook_id, sheet, and cell are required")
|
|
413
|
+
|
|
414
|
+
result = write_engine.change_cell_value(workbook_id, sheet, cell, value)
|
|
415
|
+
return [TextContent(type="text", text=str(result))]
|
|
416
|
+
|
|
417
|
+
elif name == "excel.batch_update_cells":
|
|
418
|
+
workbook_id = arguments.get("workbook_id")
|
|
419
|
+
updates = arguments.get("updates")
|
|
420
|
+
|
|
421
|
+
if not workbook_id or not updates:
|
|
422
|
+
raise ValueError("workbook_id and updates are required")
|
|
423
|
+
|
|
424
|
+
result = write_engine.batch_update_cells(workbook_id, updates)
|
|
425
|
+
return [TextContent(type="text", text=str(result))]
|
|
426
|
+
|
|
427
|
+
elif name == "excel.write_range":
|
|
428
|
+
workbook_id = arguments.get("workbook_id")
|
|
429
|
+
sheet = arguments.get("sheet")
|
|
430
|
+
start_cell = arguments.get("start_cell")
|
|
431
|
+
values = arguments.get("values")
|
|
432
|
+
|
|
433
|
+
if not workbook_id or not sheet or not start_cell or values is None:
|
|
434
|
+
raise ValueError("workbook_id, sheet, start_cell, and values are required")
|
|
435
|
+
|
|
436
|
+
result = write_engine.write_range(workbook_id, sheet, start_cell, values)
|
|
437
|
+
return [TextContent(type="text", text=str(result))]
|
|
438
|
+
|
|
439
|
+
elif name == "excel.search":
|
|
440
|
+
workbook_id = arguments.get("workbook_id")
|
|
441
|
+
query = arguments.get("query")
|
|
442
|
+
sheet = arguments.get("sheet")
|
|
443
|
+
scope = arguments.get("scope", "both")
|
|
444
|
+
match_mode = arguments.get("match_mode", "contains")
|
|
445
|
+
max_hits = arguments.get("max_hits", 200)
|
|
446
|
+
|
|
447
|
+
if not workbook_id or not query:
|
|
448
|
+
raise ValueError("workbook_id and query are required")
|
|
449
|
+
|
|
450
|
+
result = search_engine.search(
|
|
451
|
+
workbook_id, query,
|
|
452
|
+
sheet=sheet,
|
|
453
|
+
scope=scope,
|
|
454
|
+
match_mode=match_mode,
|
|
455
|
+
max_hits=max_hits
|
|
456
|
+
)
|
|
457
|
+
return [TextContent(type="text", text=str(result))]
|
|
458
|
+
|
|
459
|
+
elif name == "excel.checkpoint_create":
|
|
460
|
+
workbook_id = arguments.get("workbook_id")
|
|
461
|
+
name = arguments.get("name")
|
|
462
|
+
if not workbook_id or not name:
|
|
463
|
+
raise ValueError("workbook_id and name required")
|
|
464
|
+
result = diff_engine.checkpoint_create(workbook_id, name)
|
|
465
|
+
return [TextContent(type="text", text=str(result))]
|
|
466
|
+
|
|
467
|
+
elif name == "excel.checkpoint_rollback":
|
|
468
|
+
workbook_id = arguments.get("workbook_id")
|
|
469
|
+
name = arguments.get("name")
|
|
470
|
+
if not workbook_id or not name:
|
|
471
|
+
raise ValueError("workbook_id and name required")
|
|
472
|
+
result = diff_engine.checkpoint_rollback(workbook_id, name)
|
|
473
|
+
return [TextContent(type="text", text=str(result))]
|
|
474
|
+
|
|
475
|
+
elif name == "excel.preview_diff":
|
|
476
|
+
workbook_id = arguments.get("workbook_id")
|
|
477
|
+
max_changes = arguments.get("max_changes", 20)
|
|
478
|
+
if not workbook_id:
|
|
479
|
+
raise ValueError("workbook_id required")
|
|
480
|
+
result = diff_engine.preview_diff(workbook_id, max_changes)
|
|
481
|
+
return [TextContent(type="text", text=str(result))]
|
|
482
|
+
|
|
483
|
+
else:
|
|
484
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
485
|
+
|
|
486
|
+
except Exception as e:
|
|
487
|
+
logger.error(f"Error executing {name}: {e}")
|
|
488
|
+
return [TextContent(type="text", text=f"Error: {str(e)}")]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
async def run_stdio():
|
|
492
|
+
from mcp.server.stdio import stdio_server
|
|
493
|
+
|
|
494
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
495
|
+
await server.run(
|
|
496
|
+
read_stream,
|
|
497
|
+
write_stream,
|
|
498
|
+
server.create_initialization_options()
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
async def run_sse(port: int):
|
|
502
|
+
from mcp.server.sse import SseServerTransport
|
|
503
|
+
from starlette.applications import Starlette
|
|
504
|
+
from starlette.routing import Route
|
|
505
|
+
import uvicorn
|
|
506
|
+
|
|
507
|
+
sse = SseServerTransport("/messages")
|
|
508
|
+
|
|
509
|
+
async def handle_sse(request):
|
|
510
|
+
async with sse.connect_sse(
|
|
511
|
+
request.scope,
|
|
512
|
+
request.receive,
|
|
513
|
+
request._send
|
|
514
|
+
) as streams:
|
|
515
|
+
await server.run(
|
|
516
|
+
streams[0],
|
|
517
|
+
streams[1],
|
|
518
|
+
server.create_initialization_options()
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
async def handle_messages(request):
|
|
522
|
+
await sse.handle_post_message(request.scope, request.receive, request._send)
|
|
523
|
+
|
|
524
|
+
app = Starlette(
|
|
525
|
+
debug=True,
|
|
526
|
+
routes=[
|
|
527
|
+
Route("/sse", endpoint=handle_sse),
|
|
528
|
+
Route("/messages", endpoint=handle_messages, methods=["POST"])
|
|
529
|
+
]
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
|
|
533
|
+
server_instance = uvicorn.Server(config)
|
|
534
|
+
await server_instance.serve()
|
|
535
|
+
|
|
536
|
+
async def run(port: int | None = None):
|
|
537
|
+
if port is not None:
|
|
538
|
+
await run_sse(port)
|
|
539
|
+
else:
|
|
540
|
+
await run_stdio()
|
|
541
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from .excel import ExcelAutomation
|
|
5
|
+
|
|
6
|
+
class SessionState:
|
|
7
|
+
_instance = None
|
|
8
|
+
|
|
9
|
+
def __new__(cls):
|
|
10
|
+
if cls._instance is None:
|
|
11
|
+
cls._instance = super(SessionState, cls).__new__(cls)
|
|
12
|
+
cls._instance.open_workbooks = {}
|
|
13
|
+
return cls._instance
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
# type hints
|
|
17
|
+
self.open_workbooks: Dict[str, ExcelAutomation]
|
|
18
|
+
|
|
19
|
+
def add_workbook(self, automation: ExcelAutomation) -> str:
|
|
20
|
+
wb_id = str(uuid.uuid4())
|
|
21
|
+
self.open_workbooks[wb_id] = automation
|
|
22
|
+
return wb_id
|
|
23
|
+
|
|
24
|
+
def get_workbook(self, wb_id: str) -> Optional[ExcelAutomation]:
|
|
25
|
+
return self.open_workbooks.get(wb_id)
|
|
26
|
+
|
|
27
|
+
def remove_workbook(self, wb_id: str):
|
|
28
|
+
if wb_id in self.open_workbooks:
|
|
29
|
+
del self.open_workbooks[wb_id]
|
|
30
|
+
|
|
31
|
+
def clear(self):
|
|
32
|
+
self.open_workbooks.clear()
|
|
33
|
+
|
|
34
|
+
# Global session singleton
|
|
35
|
+
session = SessionState()
|