ExcelTamer 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/diff.py +12 -6
- exceltamer-0.3.0/ExcelTamer/mcp/engine/workbook.py +240 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/excel.py +34 -8
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/server.py +92 -53
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/sessions.py +21 -6
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/PKG-INFO +70 -5
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/SOURCES.txt +1 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/MANIFEST.in +1 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/PKG-INFO +70 -5
- {exceltamer-0.2.0 → exceltamer-0.3.0}/README.md +69 -4
- exceltamer-0.3.0/docs/developer_guide.md +212 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/pyproject.toml +9 -9
- exceltamer-0.3.0/test/test_mcp_smoke.py +451 -0
- exceltamer-0.2.0/ExcelTamer/mcp/engine/workbook.py +0 -63
- exceltamer-0.2.0/test/test_mcp_smoke.py +0 -195
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/__init__.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/__init__.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/audit.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/config.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/__init__.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/read.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/search.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/write.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/main.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/README.md +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/financial_metric_extract.md +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/safe_edit.md +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/safety.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/schemas/__init__.py +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/dependency_links.txt +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/entry_points.txt +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/requires.txt +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/top_level.txt +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/LICENSE +0 -0
- {exceltamer-0.2.0 → exceltamer-0.3.0}/setup.cfg +0 -0
|
@@ -53,12 +53,18 @@ def checkpoint_create(workbook_id: str, name: str) -> dict:
|
|
|
53
53
|
|
|
54
54
|
return {"status": "created", "name": name, "path": str(cp_path)}
|
|
55
55
|
|
|
56
|
-
def checkpoint_rollback(workbook_id: str, name: str) -> dict:
|
|
57
|
-
automation = session.get_workbook(workbook_id)
|
|
58
|
-
if not automation:
|
|
59
|
-
raise ValueError(f"Workbook {workbook_id} not found")
|
|
60
|
-
|
|
61
|
-
if
|
|
56
|
+
def checkpoint_rollback(workbook_id: str, name: str) -> dict:
|
|
57
|
+
automation = session.get_workbook(workbook_id)
|
|
58
|
+
if not automation:
|
|
59
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
60
|
+
|
|
61
|
+
if getattr(automation, "attached", False):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"Checkpoint rollback is not supported for attached workbooks "
|
|
64
|
+
"because rollback must close and reopen the workbook"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if workbook_id not in checkpoints or name not in checkpoints[workbook_id]:
|
|
62
68
|
raise ValueError(f"Checkpoint '{name}' not found for this workbook")
|
|
63
69
|
|
|
64
70
|
cp_path = checkpoints[workbook_id][name]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import xlwings as xw
|
|
5
|
+
|
|
6
|
+
from ..config import DEFAULT_MODE
|
|
7
|
+
from ..excel import ExcelAutomation
|
|
8
|
+
from ..safety import validate_path
|
|
9
|
+
from ..sessions import session
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _workbook_path(workbook: Any) -> str | None:
|
|
13
|
+
"""Return a saved workbook path, or None for an unsaved workbook."""
|
|
14
|
+
try:
|
|
15
|
+
if not workbook.api.Path:
|
|
16
|
+
return None
|
|
17
|
+
return str(workbook.fullname)
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _api_boolean(workbook: Any, property_name: str) -> bool | None:
|
|
23
|
+
try:
|
|
24
|
+
return bool(getattr(workbook.api, property_name))
|
|
25
|
+
except Exception:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def workbook_metadata(automation: ExcelAutomation) -> dict:
|
|
30
|
+
"""Return live metadata for a registered workbook."""
|
|
31
|
+
saved = _api_boolean(automation.wb, "Saved")
|
|
32
|
+
return {
|
|
33
|
+
"name": automation.wb.name,
|
|
34
|
+
"path": _workbook_path(automation.wb),
|
|
35
|
+
"app_pid": int(automation.app.pid),
|
|
36
|
+
"attached": bool(getattr(automation, "attached", False)),
|
|
37
|
+
"access_mode": getattr(automation, "access_mode", DEFAULT_MODE),
|
|
38
|
+
"read_only": _api_boolean(automation.wb, "ReadOnly"),
|
|
39
|
+
"has_unsaved_changes": None if saved is None else not saved,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def list_open_workbooks() -> dict:
|
|
44
|
+
"""Enumerate workbooks visible to xlwings in the current desktop session."""
|
|
45
|
+
workbooks = []
|
|
46
|
+
warnings = []
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
apps = list(xw.apps)
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
return {
|
|
52
|
+
"count": 0,
|
|
53
|
+
"workbooks": [],
|
|
54
|
+
"warnings": [{"app_pid": None, "error": str(exc)}],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
active_app = xw.apps.active
|
|
59
|
+
active_pid = int(active_app.pid) if active_app is not None else None
|
|
60
|
+
except Exception:
|
|
61
|
+
active_pid = None
|
|
62
|
+
|
|
63
|
+
for app in apps:
|
|
64
|
+
try:
|
|
65
|
+
app_pid = int(app.pid)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
warnings.append({"app_pid": None, "error": str(exc)})
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
books = list(app.books)
|
|
72
|
+
active_book = app.books.active if app.books else None
|
|
73
|
+
active_name = active_book.name if active_book is not None else None
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
warnings.append({"app_pid": app_pid, "error": str(exc)})
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
for book in books:
|
|
79
|
+
try:
|
|
80
|
+
name = book.name
|
|
81
|
+
saved = _api_boolean(book, "Saved")
|
|
82
|
+
workbooks.append(
|
|
83
|
+
{
|
|
84
|
+
"app_pid": app_pid,
|
|
85
|
+
"name": name,
|
|
86
|
+
"path": _workbook_path(book),
|
|
87
|
+
"active": (
|
|
88
|
+
app_pid == active_pid and name == active_name
|
|
89
|
+
),
|
|
90
|
+
"read_only": _api_boolean(book, "ReadOnly"),
|
|
91
|
+
"has_unsaved_changes": (
|
|
92
|
+
None if saved is None else not saved
|
|
93
|
+
),
|
|
94
|
+
"workbook_id": session.find_workbook_id(
|
|
95
|
+
app_pid,
|
|
96
|
+
name,
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
try:
|
|
102
|
+
workbook_name = book.name
|
|
103
|
+
except Exception:
|
|
104
|
+
workbook_name = None
|
|
105
|
+
warnings.append(
|
|
106
|
+
{
|
|
107
|
+
"app_pid": app_pid,
|
|
108
|
+
"workbook": workbook_name,
|
|
109
|
+
"error": str(exc),
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"count": len(workbooks),
|
|
115
|
+
"workbooks": workbooks,
|
|
116
|
+
"warnings": warnings,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def attach_workbook() -> dict:
|
|
121
|
+
"""Attach the active workbook without opening or taking ownership of it."""
|
|
122
|
+
try:
|
|
123
|
+
apps = list(xw.apps)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise ValueError("No running Excel application found") from exc
|
|
126
|
+
if not apps:
|
|
127
|
+
raise ValueError("No running Excel application found")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
app = xw.apps.active
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
raise ValueError("No active Excel application found") from exc
|
|
133
|
+
if app is None:
|
|
134
|
+
raise ValueError("No active Excel application found")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
if not app.books:
|
|
138
|
+
raise ValueError("The active Excel application has no open workbook")
|
|
139
|
+
workbook = app.books.active
|
|
140
|
+
except ValueError:
|
|
141
|
+
raise
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"The active Excel application has no active workbook"
|
|
145
|
+
) from exc
|
|
146
|
+
if workbook is None:
|
|
147
|
+
raise ValueError("The active Excel application has no active workbook")
|
|
148
|
+
|
|
149
|
+
app_pid = int(app.pid)
|
|
150
|
+
name = workbook.name
|
|
151
|
+
existing_id = session.find_workbook_id(app_pid, name)
|
|
152
|
+
if existing_id:
|
|
153
|
+
automation = session.get_workbook(existing_id)
|
|
154
|
+
return {
|
|
155
|
+
"workbook_id": existing_id,
|
|
156
|
+
"app_pid": app_pid,
|
|
157
|
+
"name": name,
|
|
158
|
+
"path": _workbook_path(workbook),
|
|
159
|
+
"sheets": automation.list_sheets(),
|
|
160
|
+
"attached": bool(getattr(automation, "attached", False)),
|
|
161
|
+
"already_attached": True,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
automation = ExcelAutomation.attach(app, workbook)
|
|
165
|
+
workbook_id = session.add_workbook(automation)
|
|
166
|
+
return {
|
|
167
|
+
"workbook_id": workbook_id,
|
|
168
|
+
"app_pid": app_pid,
|
|
169
|
+
"name": name,
|
|
170
|
+
"path": _workbook_path(workbook),
|
|
171
|
+
"sheets": automation.list_sheets(),
|
|
172
|
+
"attached": True,
|
|
173
|
+
"already_attached": False,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def open_workbook(path: str, mode: str = DEFAULT_MODE) -> dict:
|
|
178
|
+
"""
|
|
179
|
+
Opens a workbook and returns its ID and metadata.
|
|
180
|
+
mode: 'ro' (read-only) or 'rw' (read-write)
|
|
181
|
+
"""
|
|
182
|
+
# 1. Validate path
|
|
183
|
+
abs_path = validate_path(path)
|
|
184
|
+
|
|
185
|
+
# 2. Open workbook
|
|
186
|
+
# Note: Xlwings doesn't strictly support 'ro' at open() level easily without API flags,
|
|
187
|
+
# but we will enforce it at the Write tool level.
|
|
188
|
+
# We pass the validated absolute path string.
|
|
189
|
+
automation = ExcelAutomation(
|
|
190
|
+
file_path=str(abs_path),
|
|
191
|
+
access_mode=mode,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# 3. Store in session
|
|
195
|
+
wb_id = session.add_workbook(automation)
|
|
196
|
+
|
|
197
|
+
# 4. Gather metadata
|
|
198
|
+
sheets = automation.list_sheets()
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"workbook_id": wb_id,
|
|
202
|
+
"filename": abs_path.name,
|
|
203
|
+
"sheets": sheets,
|
|
204
|
+
"mode": mode
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
def close_workbook(workbook_id: str) -> dict:
|
|
208
|
+
automation = session.get_workbook(workbook_id)
|
|
209
|
+
if not automation:
|
|
210
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
211
|
+
|
|
212
|
+
if getattr(automation, "attached", False):
|
|
213
|
+
session.remove_workbook(workbook_id)
|
|
214
|
+
return {"status": "detached", "workbook_id": workbook_id}
|
|
215
|
+
|
|
216
|
+
# Close without quitting app to support multiple workbooks
|
|
217
|
+
automation.close(quit_app=False)
|
|
218
|
+
session.remove_workbook(workbook_id)
|
|
219
|
+
|
|
220
|
+
return {"status": "closed", "workbook_id": workbook_id}
|
|
221
|
+
|
|
222
|
+
def save_workbook(workbook_id: str) -> dict:
|
|
223
|
+
automation = session.get_workbook(workbook_id)
|
|
224
|
+
if not automation:
|
|
225
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
226
|
+
|
|
227
|
+
automation.save()
|
|
228
|
+
return {"status": "saved", "workbook_id": workbook_id}
|
|
229
|
+
|
|
230
|
+
def save_as_workbook(workbook_id: str, output_path: str) -> dict:
|
|
231
|
+
# 1. Validate output path
|
|
232
|
+
abs_path = validate_path(output_path, allow_write=True)
|
|
233
|
+
|
|
234
|
+
automation = session.get_workbook(workbook_id)
|
|
235
|
+
if not automation:
|
|
236
|
+
raise ValueError(f"Workbook {workbook_id} not found")
|
|
237
|
+
|
|
238
|
+
automation.save(str(abs_path))
|
|
239
|
+
|
|
240
|
+
return {"status": "saved_as", "path": str(abs_path), "workbook_id": workbook_id}
|
|
@@ -10,14 +10,40 @@ logger = logging.getLogger(__name__)
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ExcelAutomation:
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
def __init__(
|
|
16
|
-
self
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
"""Manage an Excel application/workbook pair for one MCP session entry."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
file_path: str | None = None,
|
|
18
|
+
*,
|
|
19
|
+
app: Any | None = None,
|
|
20
|
+
workbook: Any | None = None,
|
|
21
|
+
attached: bool = False,
|
|
22
|
+
access_mode: str = "rw",
|
|
23
|
+
):
|
|
24
|
+
if workbook is not None:
|
|
25
|
+
self.app = app if app is not None else workbook.app
|
|
26
|
+
self.wb = workbook
|
|
27
|
+
else:
|
|
28
|
+
self.app = xw.apps.active if xw.apps else xw.App(visible=True)
|
|
29
|
+
self.wb = (
|
|
30
|
+
self.app.books.open(file_path)
|
|
31
|
+
if file_path
|
|
32
|
+
else self.app.books.active
|
|
33
|
+
if self.app.books
|
|
34
|
+
else self.app.books.add()
|
|
35
|
+
)
|
|
36
|
+
self.attached = attached
|
|
37
|
+
self.access_mode = access_mode
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def attach(cls, app: Any, workbook: Any) -> "ExcelAutomation":
|
|
41
|
+
"""Create a non-owning handle for a workbook already open in Excel."""
|
|
42
|
+
return cls(
|
|
43
|
+
app=app,
|
|
44
|
+
workbook=workbook,
|
|
45
|
+
attached=True,
|
|
46
|
+
access_mode="rw",
|
|
21
47
|
)
|
|
22
48
|
|
|
23
49
|
def save(self, file_path: str | None = None) -> None:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import asyncio
|
|
3
|
+
import json
|
|
3
4
|
import logging
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -30,21 +31,46 @@ PROMPT_DIR = Path(__file__).with_name("prompts")
|
|
|
30
31
|
@server.list_tools()
|
|
31
32
|
async def handle_list_tools() -> list[Tool]:
|
|
32
33
|
return [
|
|
33
|
-
Tool(
|
|
34
|
-
name="excel.open_workbook",
|
|
35
|
-
description="Open an Excel workbook and get a workbook_id. required for other operations.",
|
|
34
|
+
Tool(
|
|
35
|
+
name="excel.open_workbook",
|
|
36
|
+
description="Open an Excel workbook and get a workbook_id. required for other operations.",
|
|
36
37
|
inputSchema={
|
|
37
38
|
"type": "object",
|
|
38
39
|
"properties": {
|
|
39
40
|
"path": {"type": "string", "description": "Absolute path to the Excel file"},
|
|
40
41
|
"mode": {"type": "string", "enum": ["ro", "rw"], "default": "ro", "description": "Open mode: ro=read-only, rw=read-write"}
|
|
41
42
|
},
|
|
42
|
-
"required": ["path"]
|
|
43
|
-
}
|
|
44
|
-
),
|
|
45
|
-
Tool(
|
|
46
|
-
name="excel.
|
|
47
|
-
description=
|
|
43
|
+
"required": ["path"]
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
Tool(
|
|
47
|
+
name="excel.list_open_workbooks",
|
|
48
|
+
description=(
|
|
49
|
+
"List all Excel workbooks already open in the current Windows "
|
|
50
|
+
"desktop session. This deliberately bypasses allowed-root filtering."
|
|
51
|
+
),
|
|
52
|
+
inputSchema={
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {}
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
Tool(
|
|
58
|
+
name="excel.attach_workbook",
|
|
59
|
+
description=(
|
|
60
|
+
"Attach the active Excel workbook without reopening or taking "
|
|
61
|
+
"ownership of it."
|
|
62
|
+
),
|
|
63
|
+
inputSchema={
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {}
|
|
66
|
+
}
|
|
67
|
+
),
|
|
68
|
+
Tool(
|
|
69
|
+
name="excel.close",
|
|
70
|
+
description=(
|
|
71
|
+
"Close an MCP-opened workbook, or detach a workbook that was "
|
|
72
|
+
"already open in Excel."
|
|
73
|
+
),
|
|
48
74
|
inputSchema={
|
|
49
75
|
"type": "object",
|
|
50
76
|
"properties": {
|
|
@@ -211,9 +237,12 @@ async def handle_list_tools() -> list[Tool]:
|
|
|
211
237
|
"required": ["workbook_id", "name"]
|
|
212
238
|
}
|
|
213
239
|
),
|
|
214
|
-
Tool(
|
|
215
|
-
name="excel.checkpoint_rollback",
|
|
216
|
-
description=
|
|
240
|
+
Tool(
|
|
241
|
+
name="excel.checkpoint_rollback",
|
|
242
|
+
description=(
|
|
243
|
+
"Restore a named checkpoint for an MCP-opened workbook. "
|
|
244
|
+
"Rollback is not available for attached workbooks."
|
|
245
|
+
),
|
|
217
246
|
inputSchema={
|
|
218
247
|
"type": "object",
|
|
219
248
|
"properties": {
|
|
@@ -241,24 +270,27 @@ async def handle_list_tools() -> list[Tool]:
|
|
|
241
270
|
async def handle_list_resources() -> list[Resource]:
|
|
242
271
|
# Resource: List of open workbooks
|
|
243
272
|
# URI: excel://workbooks
|
|
244
|
-
wb_list_resource = Resource(
|
|
245
|
-
uri="excel://workbooks",
|
|
246
|
-
name="Open Workbooks",
|
|
247
|
-
description="
|
|
248
|
-
mimeType="application/json"
|
|
273
|
+
wb_list_resource = Resource(
|
|
274
|
+
uri="excel://workbooks",
|
|
275
|
+
name="Open Workbooks",
|
|
276
|
+
description="Metadata for workbooks registered in the MCP session",
|
|
277
|
+
mimeType="application/json"
|
|
249
278
|
)
|
|
250
279
|
return [wb_list_resource]
|
|
251
280
|
|
|
252
281
|
@server.read_resource()
|
|
253
282
|
async def handle_read_resource(uri: str) -> str | bytes:
|
|
254
283
|
if uri == "excel://workbooks":
|
|
255
|
-
workbooks = []
|
|
256
|
-
for wb_id, automation in session.open_workbooks.items():
|
|
257
|
-
workbooks.append(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
284
|
+
workbooks = []
|
|
285
|
+
for wb_id, automation in session.open_workbooks.items():
|
|
286
|
+
workbooks.append(
|
|
287
|
+
{
|
|
288
|
+
"id": wb_id,
|
|
289
|
+
"workbook_id": wb_id,
|
|
290
|
+
**workbook_engine.workbook_metadata(automation),
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
return json.dumps(workbooks)
|
|
262
294
|
|
|
263
295
|
# Pattern: excel://workbooks/{id}/summary
|
|
264
296
|
# Simple manual parsing since we don't have pattern matching in this basic skeleton
|
|
@@ -325,15 +357,23 @@ async def handle_call_tool(
|
|
|
325
357
|
arguments = {}
|
|
326
358
|
|
|
327
359
|
try:
|
|
328
|
-
if name == "excel.open_workbook":
|
|
360
|
+
if name == "excel.open_workbook":
|
|
329
361
|
path = arguments.get("path")
|
|
330
362
|
mode = arguments.get("mode", "ro")
|
|
331
363
|
if not path:
|
|
332
364
|
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.
|
|
365
|
+
result = workbook_engine.open_workbook(path, mode)
|
|
366
|
+
return [TextContent(type="text", text=str(result))]
|
|
367
|
+
|
|
368
|
+
elif name == "excel.list_open_workbooks":
|
|
369
|
+
result = workbook_engine.list_open_workbooks()
|
|
370
|
+
return [TextContent(type="text", text=str(result))]
|
|
371
|
+
|
|
372
|
+
elif name == "excel.attach_workbook":
|
|
373
|
+
result = workbook_engine.attach_workbook()
|
|
374
|
+
return [TextContent(type="text", text=str(result))]
|
|
375
|
+
|
|
376
|
+
elif name == "excel.close":
|
|
337
377
|
workbook_id = arguments.get("workbook_id")
|
|
338
378
|
if not workbook_id:
|
|
339
379
|
raise ValueError("workbook_id is required")
|
|
@@ -498,36 +538,35 @@ async def run_stdio():
|
|
|
498
538
|
server.create_initialization_options()
|
|
499
539
|
)
|
|
500
540
|
|
|
501
|
-
async def run_sse(port: int):
|
|
502
|
-
from mcp.server.sse import SseServerTransport
|
|
503
|
-
from starlette.applications import Starlette
|
|
504
|
-
from starlette.
|
|
505
|
-
import
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
541
|
+
async def run_sse(port: int):
|
|
542
|
+
from mcp.server.sse import SseServerTransport
|
|
543
|
+
from starlette.applications import Starlette
|
|
544
|
+
from starlette.responses import Response
|
|
545
|
+
from starlette.routing import Mount, Route
|
|
546
|
+
import uvicorn
|
|
547
|
+
|
|
548
|
+
sse = SseServerTransport("/messages/")
|
|
549
|
+
|
|
550
|
+
async def handle_sse(request):
|
|
551
|
+
async with sse.connect_sse(
|
|
511
552
|
request.scope,
|
|
512
553
|
request.receive,
|
|
513
554
|
request._send
|
|
514
555
|
) as streams:
|
|
515
556
|
await server.run(
|
|
516
557
|
streams[0],
|
|
517
|
-
streams[1],
|
|
518
|
-
server.create_initialization_options()
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
]
|
|
530
|
-
)
|
|
558
|
+
streams[1],
|
|
559
|
+
server.create_initialization_options()
|
|
560
|
+
)
|
|
561
|
+
return Response()
|
|
562
|
+
|
|
563
|
+
app = Starlette(
|
|
564
|
+
debug=True,
|
|
565
|
+
routes=[
|
|
566
|
+
Route("/sse", endpoint=handle_sse, methods=["GET"]),
|
|
567
|
+
Mount("/messages/", app=sse.handle_post_message),
|
|
568
|
+
]
|
|
569
|
+
)
|
|
531
570
|
|
|
532
571
|
config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info")
|
|
533
572
|
server_instance = uvicorn.Server(config)
|
|
@@ -21,12 +21,27 @@ class SessionState:
|
|
|
21
21
|
self.open_workbooks[wb_id] = automation
|
|
22
22
|
return wb_id
|
|
23
23
|
|
|
24
|
-
def get_workbook(self, wb_id: str) -> Optional[ExcelAutomation]:
|
|
25
|
-
return self.open_workbooks.get(wb_id)
|
|
26
|
-
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
def get_workbook(self, wb_id: str) -> Optional[ExcelAutomation]:
|
|
25
|
+
return self.open_workbooks.get(wb_id)
|
|
26
|
+
|
|
27
|
+
def find_workbook_id(self, app_pid: int, workbook_name: str) -> Optional[str]:
|
|
28
|
+
"""Find an existing session entry by its live Excel identity."""
|
|
29
|
+
for wb_id, automation in self.open_workbooks.items():
|
|
30
|
+
try:
|
|
31
|
+
if (
|
|
32
|
+
int(automation.app.pid) == int(app_pid)
|
|
33
|
+
and automation.wb.name == workbook_name
|
|
34
|
+
):
|
|
35
|
+
return wb_id
|
|
36
|
+
except Exception:
|
|
37
|
+
# A stale or inaccessible Excel handle must not prevent discovery
|
|
38
|
+
# of other workbooks.
|
|
39
|
+
continue
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def remove_workbook(self, wb_id: str):
|
|
43
|
+
if wb_id in self.open_workbooks:
|
|
44
|
+
del self.open_workbooks[wb_id]
|
|
30
45
|
|
|
31
46
|
def clear(self):
|
|
32
47
|
self.open_workbooks.clear()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ExcelTamer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Model Context Protocol server for safe Microsoft Excel automation
|
|
5
5
|
Author: Shamit
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -33,6 +33,9 @@ client.
|
|
|
33
33
|
below or read the
|
|
34
34
|
[complete MCP usage guide](https://github.com/shamitv/ExcelTamer/blob/main/docs/mcp_server.md).
|
|
35
35
|
|
|
36
|
+
**Building or contributing?** Read the
|
|
37
|
+
[developer guide](https://github.com/shamitv/ExcelTamer/blob/main/docs/developer_guide.md).
|
|
38
|
+
|
|
36
39
|
## Quick start: use ExcelTamer with an MCP client
|
|
37
40
|
|
|
38
41
|
### 1. Install ExcelTamer
|
|
@@ -41,13 +44,19 @@ ExcelTamer requires Windows with Microsoft Excel installed and Python 3.11 or
|
|
|
41
44
|
newer.
|
|
42
45
|
|
|
43
46
|
```powershell
|
|
44
|
-
pip install
|
|
47
|
+
pip install ExcelTamer
|
|
45
48
|
```
|
|
46
49
|
|
|
47
50
|
### 2. Add ExcelTamer to your MCP client
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
Choose either stdio or SSE, depending on the transports supported by your MCP
|
|
53
|
+
client.
|
|
54
|
+
|
|
55
|
+
#### Stdio
|
|
56
|
+
|
|
57
|
+
For the default stdio transport, add this server definition to your MCP
|
|
58
|
+
client's configuration, replacing the allowed root with the directory
|
|
59
|
+
containing your workbooks:
|
|
51
60
|
|
|
52
61
|
```json
|
|
53
62
|
{
|
|
@@ -66,6 +75,32 @@ replacing the allowed root with the directory containing your workbooks:
|
|
|
66
75
|
access. The server defaults to read-only workbook mode and records write
|
|
67
76
|
operations in an audit log.
|
|
68
77
|
|
|
78
|
+
#### SSE
|
|
79
|
+
|
|
80
|
+
To use SSE, start ExcelTamer separately in PowerShell. Set server environment
|
|
81
|
+
variables in the same shell:
|
|
82
|
+
|
|
83
|
+
```powershell
|
|
84
|
+
$env:EXCELTAMER_MCP_ALLOWED_ROOTS = "C:\Users\you\Documents\Excel"
|
|
85
|
+
exceltamer-mcp --port 8123
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then configure an SSE-capable MCP client to connect to the stream endpoint:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"exceltamer": {
|
|
94
|
+
"url": "http://127.0.0.1:8123/sse"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Client configuration field names can vary; use
|
|
101
|
+
`http://127.0.0.1:8123/sse` as the server URL. ExcelTamer receives client
|
|
102
|
+
messages at `http://127.0.0.1:8123/messages/`.
|
|
103
|
+
|
|
69
104
|
### 3. Restart the client and ask it to use Excel
|
|
70
105
|
|
|
71
106
|
For inspection, try:
|
|
@@ -88,6 +123,32 @@ the checkpoint.
|
|
|
88
123
|
Your MCP client handles the `excel.*` tool calls and carries the returned
|
|
89
124
|
`workbook_id` through the workflow.
|
|
90
125
|
|
|
126
|
+
If the workbook is already open in Excel, focus its Excel window and try:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
List the workbooks currently open in Excel, attach the active workbook,
|
|
130
|
+
describe its worksheets and used ranges, and then detach from it without
|
|
131
|
+
closing the workbook or Excel.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
That follows the `list → attach → operate → detach` workflow:
|
|
135
|
+
|
|
136
|
+
1. `excel.list_open_workbooks` discovers workbooks in all visible Excel
|
|
137
|
+
applications.
|
|
138
|
+
2. `excel.attach_workbook` registers the active workbook and returns its
|
|
139
|
+
`workbook_id`.
|
|
140
|
+
3. Read, write, checkpoint, and save tools operate on that identifier.
|
|
141
|
+
4. `excel.close` removes an attached workbook from the MCP session but leaves
|
|
142
|
+
both the workbook and Excel open.
|
|
143
|
+
|
|
144
|
+
> **Security warning:** Discovery and attachment deliberately bypass
|
|
145
|
+
> `EXCELTAMER_MCP_ALLOWED_ROOTS`. They can expose every workbook open in the
|
|
146
|
+
> same Windows user session, including unsaved workbooks and files outside the
|
|
147
|
+
> configured roots. Use these tools only with a trusted local MCP client.
|
|
148
|
+
> Checkpoint creation is supported for attached workbooks, but checkpoint
|
|
149
|
+
> rollback is rejected because rollback would have to close and reopen the
|
|
150
|
+
> user's workbook.
|
|
151
|
+
|
|
91
152
|
## Run the server manually
|
|
92
153
|
|
|
93
154
|
Start the default stdio transport:
|
|
@@ -144,7 +205,9 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
144
205
|
|
|
145
206
|
## Capabilities
|
|
146
207
|
|
|
147
|
-
-
|
|
208
|
+
- Discover all open Excel workbooks and attach the active one without
|
|
209
|
+
reopening or taking ownership of it
|
|
210
|
+
- Open, inspect, save, save-as, close, and detach workbooks
|
|
148
211
|
- Read cells, ranges, sheet previews, and workbook structure
|
|
149
212
|
- Write cells, batches, and rectangular ranges
|
|
150
213
|
- Search workbook values with exact, contains, or regex matching
|
|
@@ -152,6 +215,8 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
152
215
|
- Inspect recent write history
|
|
153
216
|
- Discover workbook resources and MCP-native workflow prompts
|
|
154
217
|
|
|
218
|
+
ExcelTamer 0.3.0 exposes 17 MCP tools, one resource, and two prompts.
|
|
219
|
+
|
|
155
220
|
## Validation
|
|
156
221
|
|
|
157
222
|
Run the automated MCP smoke tests:
|