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.
Files changed (34) hide show
  1. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/diff.py +12 -6
  2. exceltamer-0.3.0/ExcelTamer/mcp/engine/workbook.py +240 -0
  3. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/excel.py +34 -8
  4. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/server.py +69 -29
  5. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/sessions.py +21 -6
  6. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/PKG-INFO +32 -2
  7. {exceltamer-0.2.1 → exceltamer-0.3.0}/PKG-INFO +32 -2
  8. {exceltamer-0.2.1 → exceltamer-0.3.0}/README.md +31 -1
  9. {exceltamer-0.2.1 → exceltamer-0.3.0}/docs/developer_guide.md +22 -12
  10. {exceltamer-0.2.1 → exceltamer-0.3.0}/pyproject.toml +1 -1
  11. {exceltamer-0.2.1 → exceltamer-0.3.0}/test/test_mcp_smoke.py +171 -6
  12. exceltamer-0.2.1/ExcelTamer/mcp/engine/workbook.py +0 -63
  13. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/__init__.py +0 -0
  14. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/__init__.py +0 -0
  15. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/audit.py +0 -0
  16. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/config.py +0 -0
  17. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/__init__.py +0 -0
  18. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/read.py +0 -0
  19. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/search.py +0 -0
  20. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/write.py +0 -0
  21. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/main.py +0 -0
  22. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/README.md +0 -0
  23. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/financial_metric_extract.md +0 -0
  24. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/safe_edit.md +0 -0
  25. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/safety.py +0 -0
  26. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer/mcp/schemas/__init__.py +0 -0
  27. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/SOURCES.txt +0 -0
  28. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/dependency_links.txt +0 -0
  29. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/entry_points.txt +0 -0
  30. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/requires.txt +0 -0
  31. {exceltamer-0.2.1 → exceltamer-0.3.0}/ExcelTamer.egg-info/top_level.txt +0 -0
  32. {exceltamer-0.2.1 → exceltamer-0.3.0}/LICENSE +0 -0
  33. {exceltamer-0.2.1 → exceltamer-0.3.0}/MANIFEST.in +0 -0
  34. {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 workbook_id not in checkpoints or name not in checkpoints[workbook_id]:
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
- """Own an Excel application/workbook pair for one MCP session entry."""
14
-
15
- def __init__(self, file_path: str | None = None):
16
- self.app = xw.apps.active if xw.apps else xw.App(visible=True)
17
- self.wb = (
18
- self.app.books.open(file_path)
19
- if file_path
20
- else self.app.books.active if self.app.books else self.app.books.add()
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.close",
47
- description="Close an open workbook by ID.",
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="Rollback workbook to a named checkpoint (reloads file).",
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="List of currently open workbook IDs and filenames",
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
- "id": wb_id,
259
- "name": automation.wb.name
260
- })
261
- return str(workbooks)
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.close":
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 remove_workbook(self, wb_id: str):
28
- if wb_id in self.open_workbooks:
29
- del self.open_workbooks[wb_id]
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.2.1
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
- - Open, inspect, save, save-as, and close workbooks
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.2.1
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
- - Open, inspect, save, save-as, and close workbooks
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
- - Open, inspect, save, save-as, and close workbooks
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 eight tests covering the MCP surface, packaged prompts,
123
- range normalization, engine read/search/write behavior, and a real stdio
124
- and SSE handshake. These tests use fakes where workbook behavior is needed and
125
- do not launch Microsoft Excel.
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.2.1`. Review unexpected working-tree changes before
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.2.1`, this creates:
183
+ For version `0.3.0`, this creates:
174
184
 
175
- - `dist\exceltamer-0.2.1.tar.gz` — source distribution
176
- - `dist\exceltamer-0.2.1-py3-none-any.whl` — binary wheel
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.2.1.tar.gz `
186
- .\dist\exceltamer-0.2.1-py3-none-any.whl
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.2.1.tar.gz, `
196
- .\dist\exceltamer-0.2.1-py3-none-any.whl
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ExcelTamer"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "A Model Context Protocol server for safe Microsoft Excel automation"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -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 test_protocol_surface_is_unchanged(self):
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), 15)
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), 15)
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), 15)
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