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.
Files changed (35) hide show
  1. {exceltamer-0.2.0 → 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.0 → exceltamer-0.3.0}/ExcelTamer/mcp/excel.py +34 -8
  4. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/server.py +92 -53
  5. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/sessions.py +21 -6
  6. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/PKG-INFO +70 -5
  7. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/SOURCES.txt +1 -0
  8. {exceltamer-0.2.0 → exceltamer-0.3.0}/MANIFEST.in +1 -0
  9. {exceltamer-0.2.0 → exceltamer-0.3.0}/PKG-INFO +70 -5
  10. {exceltamer-0.2.0 → exceltamer-0.3.0}/README.md +69 -4
  11. exceltamer-0.3.0/docs/developer_guide.md +212 -0
  12. {exceltamer-0.2.0 → exceltamer-0.3.0}/pyproject.toml +9 -9
  13. exceltamer-0.3.0/test/test_mcp_smoke.py +451 -0
  14. exceltamer-0.2.0/ExcelTamer/mcp/engine/workbook.py +0 -63
  15. exceltamer-0.2.0/test/test_mcp_smoke.py +0 -195
  16. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/__init__.py +0 -0
  17. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/__init__.py +0 -0
  18. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/audit.py +0 -0
  19. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/config.py +0 -0
  20. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/__init__.py +0 -0
  21. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/read.py +0 -0
  22. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/search.py +0 -0
  23. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/engine/write.py +0 -0
  24. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/main.py +0 -0
  25. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/README.md +0 -0
  26. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/financial_metric_extract.md +0 -0
  27. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/prompts/safe_edit.md +0 -0
  28. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/safety.py +0 -0
  29. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer/mcp/schemas/__init__.py +0 -0
  30. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/dependency_links.txt +0 -0
  31. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/entry_points.txt +0 -0
  32. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/requires.txt +0 -0
  33. {exceltamer-0.2.0 → exceltamer-0.3.0}/ExcelTamer.egg-info/top_level.txt +0 -0
  34. {exceltamer-0.2.0 → exceltamer-0.3.0}/LICENSE +0 -0
  35. {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 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")
@@ -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.routing import Route
505
- import uvicorn
506
-
507
- sse = SseServerTransport("/messages")
508
-
509
- async def handle_sse(request):
510
- async with sse.connect_sse(
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
- async def handle_messages(request):
522
- await sse.handle_post_message(request.scope, request.receive, request._send)
523
-
524
- app = Starlette(
525
- debug=True,
526
- routes=[
527
- Route("/sse", endpoint=handle_sse),
528
- Route("/messages", endpoint=handle_messages, methods=["POST"])
529
- ]
530
- )
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 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.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
- Add this client-neutral server definition to your MCP client's configuration,
50
- replacing the allowed root with the directory containing your workbooks:
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
- - 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
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:
@@ -27,4 +27,5 @@ ExcelTamer/mcp/prompts/README.md
27
27
  ExcelTamer/mcp/prompts/financial_metric_extract.md
28
28
  ExcelTamer/mcp/prompts/safe_edit.md
29
29
  ExcelTamer/mcp/schemas/__init__.py
30
+ docs/developer_guide.md
30
31
  test/test_mcp_smoke.py