ExcelTamer 0.2.1__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.1 → 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.1 → exceltamer-0.3.0}/ExcelTamer/mcp/excel.py +34 -8
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/server.py +69 -29
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/sessions.py +21 -6
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/PKG-INFO +32 -2
- {exceltamer-0.2.1 → exceltamer-0.3.0}/PKG-INFO +32 -2
- {exceltamer-0.2.1 → exceltamer-0.3.0}/README.md +31 -1
- {exceltamer-0.2.1 → exceltamer-0.3.0}/docs/developer_guide.md +22 -12
- {exceltamer-0.2.1 → exceltamer-0.3.0}/pyproject.toml +1 -1
- {exceltamer-0.2.1 → exceltamer-0.3.0}/test/test_mcp_smoke.py +171 -6
- exceltamer-0.2.1/ExcelTamer/mcp/engine/workbook.py +0 -63
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/__init__.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/__init__.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/audit.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/config.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/__init__.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/read.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/search.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/write.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/main.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/README.md +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/financial_metric_extract.md +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/safe_edit.md +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/safety.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/schemas/__init__.py +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/SOURCES.txt +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/dependency_links.txt +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/entry_points.txt +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/requires.txt +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/top_level.txt +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/LICENSE +0 -0
- {exceltamer-0.2.1 → exceltamer-0.3.0}/MANIFEST.in +0 -0
- {exceltamer-0.2.1 → 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")
|
|
@@ -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
|
|
@@ -123,6 +123,32 @@ the checkpoint.
|
|
|
123
123
|
Your MCP client handles the `excel.*` tool calls and carries the returned
|
|
124
124
|
`workbook_id` through the workflow.
|
|
125
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
|
+
|
|
126
152
|
## Run the server manually
|
|
127
153
|
|
|
128
154
|
Start the default stdio transport:
|
|
@@ -179,7 +205,9 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
179
205
|
|
|
180
206
|
## Capabilities
|
|
181
207
|
|
|
182
|
-
-
|
|
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
|
|
183
211
|
- Read cells, ranges, sheet previews, and workbook structure
|
|
184
212
|
- Write cells, batches, and rectangular ranges
|
|
185
213
|
- Search workbook values with exact, contains, or regex matching
|
|
@@ -187,6 +215,8 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
187
215
|
- Inspect recent write history
|
|
188
216
|
- Discover workbook resources and MCP-native workflow prompts
|
|
189
217
|
|
|
218
|
+
ExcelTamer 0.3.0 exposes 17 MCP tools, one resource, and two prompts.
|
|
219
|
+
|
|
190
220
|
## Validation
|
|
191
221
|
|
|
192
222
|
Run the automated MCP smoke tests:
|
|
@@ -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
|
|
@@ -123,6 +123,32 @@ the checkpoint.
|
|
|
123
123
|
Your MCP client handles the `excel.*` tool calls and carries the returned
|
|
124
124
|
`workbook_id` through the workflow.
|
|
125
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
|
+
|
|
126
152
|
## Run the server manually
|
|
127
153
|
|
|
128
154
|
Start the default stdio transport:
|
|
@@ -179,7 +205,9 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
179
205
|
|
|
180
206
|
## Capabilities
|
|
181
207
|
|
|
182
|
-
-
|
|
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
|
|
183
211
|
- Read cells, ranges, sheet previews, and workbook structure
|
|
184
212
|
- Write cells, batches, and rectangular ranges
|
|
185
213
|
- Search workbook values with exact, contains, or regex matching
|
|
@@ -187,6 +215,8 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
187
215
|
- Inspect recent write history
|
|
188
216
|
- Discover workbook resources and MCP-native workflow prompts
|
|
189
217
|
|
|
218
|
+
ExcelTamer 0.3.0 exposes 17 MCP tools, one resource, and two prompts.
|
|
219
|
+
|
|
190
220
|
## Validation
|
|
191
221
|
|
|
192
222
|
Run the automated MCP smoke tests:
|
|
@@ -99,6 +99,32 @@ the checkpoint.
|
|
|
99
99
|
Your MCP client handles the `excel.*` tool calls and carries the returned
|
|
100
100
|
`workbook_id` through the workflow.
|
|
101
101
|
|
|
102
|
+
If the workbook is already open in Excel, focus its Excel window and try:
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
List the workbooks currently open in Excel, attach the active workbook,
|
|
106
|
+
describe its worksheets and used ranges, and then detach from it without
|
|
107
|
+
closing the workbook or Excel.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That follows the `list → attach → operate → detach` workflow:
|
|
111
|
+
|
|
112
|
+
1. `excel.list_open_workbooks` discovers workbooks in all visible Excel
|
|
113
|
+
applications.
|
|
114
|
+
2. `excel.attach_workbook` registers the active workbook and returns its
|
|
115
|
+
`workbook_id`.
|
|
116
|
+
3. Read, write, checkpoint, and save tools operate on that identifier.
|
|
117
|
+
4. `excel.close` removes an attached workbook from the MCP session but leaves
|
|
118
|
+
both the workbook and Excel open.
|
|
119
|
+
|
|
120
|
+
> **Security warning:** Discovery and attachment deliberately bypass
|
|
121
|
+
> `EXCELTAMER_MCP_ALLOWED_ROOTS`. They can expose every workbook open in the
|
|
122
|
+
> same Windows user session, including unsaved workbooks and files outside the
|
|
123
|
+
> configured roots. Use these tools only with a trusted local MCP client.
|
|
124
|
+
> Checkpoint creation is supported for attached workbooks, but checkpoint
|
|
125
|
+
> rollback is rejected because rollback would have to close and reopen the
|
|
126
|
+
> user's workbook.
|
|
127
|
+
|
|
102
128
|
## Run the server manually
|
|
103
129
|
|
|
104
130
|
Start the default stdio transport:
|
|
@@ -155,7 +181,9 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
155
181
|
|
|
156
182
|
## Capabilities
|
|
157
183
|
|
|
158
|
-
-
|
|
184
|
+
- Discover all open Excel workbooks and attach the active one without
|
|
185
|
+
reopening or taking ownership of it
|
|
186
|
+
- Open, inspect, save, save-as, close, and detach workbooks
|
|
159
187
|
- Read cells, ranges, sheet previews, and workbook structure
|
|
160
188
|
- Write cells, batches, and rectangular ranges
|
|
161
189
|
- Search workbook values with exact, contains, or regex matching
|
|
@@ -163,6 +191,8 @@ the quick start, or in the shell before starting `exceltamer-mcp`.
|
|
|
163
191
|
- Inspect recent write history
|
|
164
192
|
- Discover workbook resources and MCP-native workflow prompts
|
|
165
193
|
|
|
194
|
+
ExcelTamer 0.3.0 exposes 17 MCP tools, one resource, and two prompts.
|
|
195
|
+
|
|
166
196
|
## Validation
|
|
167
197
|
|
|
168
198
|
Run the automated MCP smoke tests:
|
|
@@ -119,10 +119,13 @@ Run the automated suite from the repository root:
|
|
|
119
119
|
python -m unittest discover -s test -p "test_*.py" -v
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
The current suite runs
|
|
123
|
-
range normalization, engine read/search/write behavior,
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
The current suite runs twelve tests covering the 17-tool MCP surface, packaged
|
|
123
|
+
prompts, range normalization, engine read/search/write behavior, four
|
|
124
|
+
Excel-free attachment scenarios, and real stdio and SSE handshakes. The
|
|
125
|
+
attachment tests cover multi-application discovery, saved and unsaved
|
|
126
|
+
workbooks, out-of-root paths, idempotent attachment, clear missing-Excel
|
|
127
|
+
errors, non-owning detach, and rollback rejection. These tests use fakes where
|
|
128
|
+
workbook behavior is needed and do not launch Microsoft Excel.
|
|
126
129
|
|
|
127
130
|
For optional live validation, use the included example workbook:
|
|
128
131
|
|
|
@@ -141,6 +144,13 @@ python test/mcp_client.py --transport sse --port 8123 --file .\test\example.xlsx
|
|
|
141
144
|
The workbook must be under one of `EXCELTAMER_MCP_ALLOWED_ROOTS`. The default
|
|
142
145
|
allowed root is the current working directory.
|
|
143
146
|
|
|
147
|
+
To validate attachment manually, leave a workbook open and focused in Excel,
|
|
148
|
+
then use an MCP client to call `excel.list_open_workbooks`,
|
|
149
|
+
`excel.attach_workbook`, `excel.get_structure`, and `excel.close`. Confirm the
|
|
150
|
+
last call returns `status: "detached"` and that both Excel and the workbook
|
|
151
|
+
remain open. Discovery and attachment deliberately bypass allowed-root
|
|
152
|
+
filtering, so perform this check only with non-sensitive workbooks.
|
|
153
|
+
|
|
144
154
|
## Build and verify packages
|
|
145
155
|
|
|
146
156
|
Run the following workflow from a clean repository checkout with the virtual
|
|
@@ -155,7 +165,7 @@ Select-String -Path .\pyproject.toml -Pattern '^version = '
|
|
|
155
165
|
git status --short
|
|
156
166
|
```
|
|
157
167
|
|
|
158
|
-
The current version is `0.
|
|
168
|
+
The current version is `0.3.0`. Review unexpected working-tree changes before
|
|
159
169
|
building.
|
|
160
170
|
|
|
161
171
|
### 2. Run the automated tests
|
|
@@ -170,10 +180,10 @@ python -m unittest discover -s test -p "test_*.py" -v
|
|
|
170
180
|
python -m build
|
|
171
181
|
```
|
|
172
182
|
|
|
173
|
-
For version `0.
|
|
183
|
+
For version `0.3.0`, this creates:
|
|
174
184
|
|
|
175
|
-
- `dist\exceltamer-0.
|
|
176
|
-
- `dist\exceltamer-0.
|
|
185
|
+
- `dist\exceltamer-0.3.0.tar.gz` — source distribution
|
|
186
|
+
- `dist\exceltamer-0.3.0-py3-none-any.whl` — binary wheel
|
|
177
187
|
|
|
178
188
|
The `dist/` directory is ignored by Git. If it contains artifacts from other
|
|
179
189
|
versions, verify only the files for the version being prepared.
|
|
@@ -182,8 +192,8 @@ versions, verify only the files for the version being prepared.
|
|
|
182
192
|
|
|
183
193
|
```powershell
|
|
184
194
|
python -m twine check `
|
|
185
|
-
.\dist\exceltamer-0.
|
|
186
|
-
.\dist\exceltamer-0.
|
|
195
|
+
.\dist\exceltamer-0.3.0.tar.gz `
|
|
196
|
+
.\dist\exceltamer-0.3.0-py3-none-any.whl
|
|
187
197
|
```
|
|
188
198
|
|
|
189
199
|
Both artifacts must report `PASSED`.
|
|
@@ -192,8 +202,8 @@ Both artifacts must report `PASSED`.
|
|
|
192
202
|
|
|
193
203
|
```powershell
|
|
194
204
|
Get-FileHash -Algorithm SHA256 `
|
|
195
|
-
.\dist\exceltamer-0.
|
|
196
|
-
.\dist\exceltamer-0.
|
|
205
|
+
.\dist\exceltamer-0.3.0.tar.gz, `
|
|
206
|
+
.\dist\exceltamer-0.3.0-py3-none-any.whl
|
|
197
207
|
```
|
|
198
208
|
|
|
199
209
|
Record these hashes when artifacts are transferred or retained for a release.
|
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
"""MCP-only smoke tests that do not require Microsoft Excel."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
5
6
|
import socket
|
|
6
7
|
import sys
|
|
7
8
|
import unittest
|
|
8
9
|
from types import SimpleNamespace
|
|
9
|
-
from unittest.mock import patch
|
|
10
|
+
from unittest.mock import Mock, patch
|
|
10
11
|
|
|
11
12
|
import pandas as pd
|
|
12
13
|
from mcp import ClientSession, StdioServerParameters
|
|
13
14
|
from mcp.client.sse import sse_client
|
|
14
15
|
from mcp.client.stdio import stdio_client
|
|
15
16
|
|
|
16
|
-
from ExcelTamer.mcp.engine import read, search, write
|
|
17
|
+
from ExcelTamer.mcp.engine import diff, read, search, workbook, write
|
|
17
18
|
from ExcelTamer.mcp.excel import ExcelAutomation
|
|
18
19
|
from ExcelTamer.mcp.server import (
|
|
19
20
|
handle_get_prompt,
|
|
20
21
|
handle_list_prompts,
|
|
21
22
|
handle_list_resources,
|
|
22
23
|
handle_list_tools,
|
|
24
|
+
handle_read_resource,
|
|
23
25
|
)
|
|
24
26
|
from ExcelTamer.mcp.sessions import session
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
class ProtocolSurfaceTests(unittest.TestCase):
|
|
28
|
-
def
|
|
30
|
+
def test_protocol_surface(self):
|
|
29
31
|
tools = asyncio.run(handle_list_tools())
|
|
30
32
|
resources = asyncio.run(handle_list_resources())
|
|
31
33
|
prompts = asyncio.run(handle_list_prompts())
|
|
32
34
|
|
|
33
|
-
self.assertEqual(len(tools),
|
|
35
|
+
self.assertEqual(len(tools), 17)
|
|
34
36
|
self.assertEqual(len(resources), 1)
|
|
35
37
|
self.assertEqual(
|
|
36
38
|
{prompt.name for prompt in prompts},
|
|
@@ -167,6 +169,169 @@ class EngineTests(unittest.TestCase):
|
|
|
167
169
|
self.assertEqual(self.backend.sheet.target.value, [[1, 2], [3, 4]])
|
|
168
170
|
|
|
169
171
|
|
|
172
|
+
class AttachmentTests(unittest.TestCase):
|
|
173
|
+
class Book:
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
name,
|
|
177
|
+
path=None,
|
|
178
|
+
*,
|
|
179
|
+
saved=True,
|
|
180
|
+
read_only=False,
|
|
181
|
+
):
|
|
182
|
+
self.name = name
|
|
183
|
+
self.fullname = path or name
|
|
184
|
+
directory = path.rsplit("\\", 1)[0] if path else ""
|
|
185
|
+
self.api = SimpleNamespace(
|
|
186
|
+
Path=directory,
|
|
187
|
+
Saved=saved,
|
|
188
|
+
ReadOnly=read_only,
|
|
189
|
+
)
|
|
190
|
+
self.sheets = [SimpleNamespace(name="Sheet1")]
|
|
191
|
+
self.close = Mock()
|
|
192
|
+
|
|
193
|
+
class Books(list):
|
|
194
|
+
def __init__(self, books, active=None):
|
|
195
|
+
super().__init__(books)
|
|
196
|
+
self.active = active
|
|
197
|
+
|
|
198
|
+
class App:
|
|
199
|
+
def __init__(self, pid, books):
|
|
200
|
+
self.pid = pid
|
|
201
|
+
self.books = books
|
|
202
|
+
|
|
203
|
+
class Apps(list):
|
|
204
|
+
def __init__(self, apps, active=None):
|
|
205
|
+
super().__init__(apps)
|
|
206
|
+
self.active = active
|
|
207
|
+
|
|
208
|
+
def setUp(self):
|
|
209
|
+
session.clear()
|
|
210
|
+
diff.checkpoints.clear()
|
|
211
|
+
|
|
212
|
+
def tearDown(self):
|
|
213
|
+
session.clear()
|
|
214
|
+
diff.checkpoints.clear()
|
|
215
|
+
|
|
216
|
+
def test_lists_apps_saved_unsaved_and_out_of_root_workbooks(self):
|
|
217
|
+
saved = self.Book(
|
|
218
|
+
"Budget.xlsx",
|
|
219
|
+
r"C:\Users\you\Documents\Excel\Budget.xlsx",
|
|
220
|
+
read_only=True,
|
|
221
|
+
)
|
|
222
|
+
unsaved = self.Book("Book1", saved=False)
|
|
223
|
+
outside_root = self.Book(
|
|
224
|
+
"Private.xlsx",
|
|
225
|
+
r"D:\OutsideAllowedRoots\Private.xlsx",
|
|
226
|
+
)
|
|
227
|
+
first_app = self.App(101, self.Books([saved, unsaved], active=saved))
|
|
228
|
+
second_app = self.App(
|
|
229
|
+
202,
|
|
230
|
+
self.Books([outside_root], active=outside_root),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
class InaccessibleApp:
|
|
234
|
+
pid = 303
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def books(self):
|
|
238
|
+
raise RuntimeError("Excel instance is inaccessible")
|
|
239
|
+
|
|
240
|
+
apps = self.Apps(
|
|
241
|
+
[first_app, second_app, InaccessibleApp()],
|
|
242
|
+
active=first_app,
|
|
243
|
+
)
|
|
244
|
+
registered_id = session.add_workbook(
|
|
245
|
+
ExcelAutomation.attach(first_app, saved)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
with patch.object(workbook.xw, "apps", apps):
|
|
249
|
+
result = workbook.list_open_workbooks()
|
|
250
|
+
|
|
251
|
+
self.assertEqual(result["count"], 3)
|
|
252
|
+
by_name = {item["name"]: item for item in result["workbooks"]}
|
|
253
|
+
self.assertEqual(by_name["Budget.xlsx"]["workbook_id"], registered_id)
|
|
254
|
+
self.assertTrue(by_name["Budget.xlsx"]["active"])
|
|
255
|
+
self.assertTrue(by_name["Budget.xlsx"]["read_only"])
|
|
256
|
+
self.assertIsNone(by_name["Book1"]["path"])
|
|
257
|
+
self.assertTrue(by_name["Book1"]["has_unsaved_changes"])
|
|
258
|
+
self.assertEqual(
|
|
259
|
+
by_name["Private.xlsx"]["path"],
|
|
260
|
+
r"D:\OutsideAllowedRoots\Private.xlsx",
|
|
261
|
+
)
|
|
262
|
+
self.assertFalse(by_name["Private.xlsx"]["active"])
|
|
263
|
+
self.assertEqual(result["warnings"][0]["app_pid"], 303)
|
|
264
|
+
self.assertIn("inaccessible", result["warnings"][0]["error"])
|
|
265
|
+
|
|
266
|
+
def test_attaches_active_workbook_idempotently(self):
|
|
267
|
+
active_book = self.Book(
|
|
268
|
+
"Active.xlsx",
|
|
269
|
+
r"C:\Work\Active.xlsx",
|
|
270
|
+
)
|
|
271
|
+
app = self.App(404, self.Books([active_book], active=active_book))
|
|
272
|
+
apps = self.Apps([app], active=app)
|
|
273
|
+
|
|
274
|
+
with patch.object(workbook.xw, "apps", apps):
|
|
275
|
+
first = workbook.attach_workbook()
|
|
276
|
+
second = workbook.attach_workbook()
|
|
277
|
+
|
|
278
|
+
self.assertEqual(first["workbook_id"], second["workbook_id"])
|
|
279
|
+
self.assertEqual(first["app_pid"], 404)
|
|
280
|
+
self.assertEqual(first["sheets"], ["Sheet1"])
|
|
281
|
+
self.assertTrue(first["attached"])
|
|
282
|
+
self.assertFalse(first["already_attached"])
|
|
283
|
+
self.assertTrue(second["already_attached"])
|
|
284
|
+
self.assertEqual(len(session.open_workbooks), 1)
|
|
285
|
+
|
|
286
|
+
def test_attach_reports_no_excel_and_no_active_workbook(self):
|
|
287
|
+
with patch.object(workbook.xw, "apps", self.Apps([])):
|
|
288
|
+
with self.assertRaisesRegex(
|
|
289
|
+
ValueError,
|
|
290
|
+
"No running Excel application",
|
|
291
|
+
):
|
|
292
|
+
workbook.attach_workbook()
|
|
293
|
+
|
|
294
|
+
app = self.App(505, self.Books([]))
|
|
295
|
+
with patch.object(workbook.xw, "apps", self.Apps([app], active=app)):
|
|
296
|
+
with self.assertRaisesRegex(ValueError, "no open workbook"):
|
|
297
|
+
workbook.attach_workbook()
|
|
298
|
+
|
|
299
|
+
def test_detaches_without_closing_and_rejects_rollback(self):
|
|
300
|
+
active_book = self.Book(
|
|
301
|
+
"Attached.xlsx",
|
|
302
|
+
r"C:\Work\Attached.xlsx",
|
|
303
|
+
)
|
|
304
|
+
app = self.App(606, self.Books([active_book], active=active_book))
|
|
305
|
+
apps = self.Apps([app], active=app)
|
|
306
|
+
|
|
307
|
+
with patch.object(workbook.xw, "apps", apps):
|
|
308
|
+
attached = workbook.attach_workbook()
|
|
309
|
+
workbook_id = attached["workbook_id"]
|
|
310
|
+
|
|
311
|
+
resource = json.loads(asyncio.run(handle_read_resource(
|
|
312
|
+
"excel://workbooks"
|
|
313
|
+
)))
|
|
314
|
+
self.assertEqual(resource[0]["app_pid"], 606)
|
|
315
|
+
self.assertEqual(resource[0]["path"], r"C:\Work\Attached.xlsx")
|
|
316
|
+
self.assertTrue(resource[0]["attached"])
|
|
317
|
+
self.assertEqual(resource[0]["access_mode"], "rw")
|
|
318
|
+
|
|
319
|
+
with patch.object(diff.shutil, "copy2") as copy2:
|
|
320
|
+
with self.assertRaisesRegex(
|
|
321
|
+
ValueError,
|
|
322
|
+
"not supported for attached workbooks",
|
|
323
|
+
):
|
|
324
|
+
diff.checkpoint_rollback(workbook_id, "before")
|
|
325
|
+
copy2.assert_not_called()
|
|
326
|
+
active_book.close.assert_not_called()
|
|
327
|
+
self.assertIsNotNone(session.get_workbook(workbook_id))
|
|
328
|
+
|
|
329
|
+
result = workbook.close_workbook(workbook_id)
|
|
330
|
+
self.assertEqual(result["status"], "detached")
|
|
331
|
+
active_book.close.assert_not_called()
|
|
332
|
+
self.assertIsNone(session.get_workbook(workbook_id))
|
|
333
|
+
|
|
334
|
+
|
|
170
335
|
class StdioHandshakeTests(unittest.IsolatedAsyncioTestCase):
|
|
171
336
|
async def test_stdio_discovery_and_prompt_retrieval(self):
|
|
172
337
|
env = os.environ.copy()
|
|
@@ -187,7 +352,7 @@ class StdioHandshakeTests(unittest.IsolatedAsyncioTestCase):
|
|
|
187
352
|
prompts = await session.list_prompts()
|
|
188
353
|
safe_edit = await session.get_prompt("safe-edit")
|
|
189
354
|
|
|
190
|
-
self.assertEqual(len(tools.tools),
|
|
355
|
+
self.assertEqual(len(tools.tools), 17)
|
|
191
356
|
self.assertEqual(len(resources.resources), 1)
|
|
192
357
|
self.assertEqual(len(prompts.prompts), 2)
|
|
193
358
|
self.assertTrue(safe_edit.messages[0].content.text.strip())
|
|
@@ -229,7 +394,7 @@ class SseHandshakeTests(unittest.IsolatedAsyncioTestCase):
|
|
|
229
394
|
prompts = await session.list_prompts()
|
|
230
395
|
safe_edit = await session.get_prompt("safe-edit")
|
|
231
396
|
|
|
232
|
-
self.assertEqual(len(tools.tools),
|
|
397
|
+
self.assertEqual(len(tools.tools), 17)
|
|
233
398
|
self.assertEqual(len(resources.resources), 1)
|
|
234
399
|
self.assertEqual(len(prompts.prompts), 2)
|
|
235
400
|
self.assertTrue(safe_edit.messages[0].content.text.strip())
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
from ..config import DEFAULT_MODE
|
|
3
|
-
from ..excel import ExcelAutomation
|
|
4
|
-
from ..safety import validate_path
|
|
5
|
-
from ..sessions import session
|
|
6
|
-
|
|
7
|
-
def open_workbook(path: str, mode: str = DEFAULT_MODE) -> dict:
|
|
8
|
-
"""
|
|
9
|
-
Opens a workbook and returns its ID and metadata.
|
|
10
|
-
mode: 'ro' (read-only) or 'rw' (read-write)
|
|
11
|
-
"""
|
|
12
|
-
# 1. Validate path
|
|
13
|
-
abs_path = validate_path(path)
|
|
14
|
-
|
|
15
|
-
# 2. Open workbook
|
|
16
|
-
# Note: Xlwings doesn't strictly support 'ro' at open() level easily without API flags,
|
|
17
|
-
# but we will enforce it at the Write tool level.
|
|
18
|
-
# We pass the validated absolute path string.
|
|
19
|
-
automation = ExcelAutomation(file_path=str(abs_path))
|
|
20
|
-
|
|
21
|
-
# 3. Store in session
|
|
22
|
-
wb_id = session.add_workbook(automation)
|
|
23
|
-
|
|
24
|
-
# 4. Gather metadata
|
|
25
|
-
sheets = automation.list_sheets()
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
"workbook_id": wb_id,
|
|
29
|
-
"filename": abs_path.name,
|
|
30
|
-
"sheets": sheets,
|
|
31
|
-
"mode": mode
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
def close_workbook(workbook_id: str) -> dict:
|
|
35
|
-
automation = session.get_workbook(workbook_id)
|
|
36
|
-
if not automation:
|
|
37
|
-
raise ValueError(f"Workbook {workbook_id} not found")
|
|
38
|
-
|
|
39
|
-
# Close without quitting app to support multiple workbooks
|
|
40
|
-
automation.close(quit_app=False)
|
|
41
|
-
session.remove_workbook(workbook_id)
|
|
42
|
-
|
|
43
|
-
return {"status": "closed", "workbook_id": workbook_id}
|
|
44
|
-
|
|
45
|
-
def save_workbook(workbook_id: str) -> dict:
|
|
46
|
-
automation = session.get_workbook(workbook_id)
|
|
47
|
-
if not automation:
|
|
48
|
-
raise ValueError(f"Workbook {workbook_id} not found")
|
|
49
|
-
|
|
50
|
-
automation.save()
|
|
51
|
-
return {"status": "saved", "workbook_id": workbook_id}
|
|
52
|
-
|
|
53
|
-
def save_as_workbook(workbook_id: str, output_path: str) -> dict:
|
|
54
|
-
# 1. Validate output path
|
|
55
|
-
abs_path = validate_path(output_path, allow_write=True)
|
|
56
|
-
|
|
57
|
-
automation = session.get_workbook(workbook_id)
|
|
58
|
-
if not automation:
|
|
59
|
-
raise ValueError(f"Workbook {workbook_id} not found")
|
|
60
|
-
|
|
61
|
-
automation.save(str(abs_path))
|
|
62
|
-
|
|
63
|
-
return {"status": "saved_as", "path": str(abs_path), "workbook_id": workbook_id}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|