mcp-msaccess-database 0.7.31__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
access_mcp_server.py ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ access_mcp_server.py
4
+ ====================
5
+ MCP Server for reading and editing Microsoft Access databases (.accdb/.mdb)
6
+ via COM automation (pywin32). Requires Windows + Microsoft Access installed.
7
+
8
+ Install dependencies:
9
+ pip install mcp pywin32
10
+
11
+ Register in Claude Code (one of two methods):
12
+ # Option A -- global
13
+ claude mcp add access -- python /path/to/access_mcp_server.py
14
+
15
+ # Option B -- this project only (creates .mcp.json in current directory)
16
+ claude mcp add --scope project access -- python /path/to/access_mcp_server.py
17
+
18
+ Typical workflow for editing VBA:
19
+ 1. access_list_objects -> see which modules/forms exist
20
+ 2. access_get_code -> export the object to text
21
+ 3. (Claude edits the text)
22
+ 4. access_set_code -> reimport the modified text
23
+ 5. access_close -> release Access (optional)
24
+ """
25
+
26
+ import asyncio
27
+ from mcp_access.server import main
28
+
29
+ if __name__ == "__main__":
30
+ asyncio.run(main())
mcp_access/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # mcp_access — MCP Server for Microsoft Access databases
mcp_access/code.py ADDED
@@ -0,0 +1,568 @@
1
+ """
2
+ Object management: list, get, set, delete objects, create form, export structure.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .core import (
12
+ AC_TYPE, _Session, _parsed_controls_cache, log,
13
+ invalidate_all_caches, invalidate_object_caches,
14
+ )
15
+ from .constants import BINARY_SECTIONS, AC_FORM, AC_SAVE_NO
16
+ from .helpers import read_tmp, write_tmp, strip_binary_sections, restore_binary_sections
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Design-view helpers (used by _inject_vba_after_import)
21
+ # ---------------------------------------------------------------------------
22
+ # These are small private helpers also used by controls module. Duplicated
23
+ # here to keep the module self-contained; the canonical copy lives in helpers
24
+ # once that module is extended.
25
+
26
+ _AC_DESIGN = 1 # acDesign / acViewDesign
27
+ _AC_SAVE_YES = 1 # acSaveYes
28
+ _AC_REPORT = 3 # acReport
29
+
30
+
31
+ def _open_in_design(app: Any, object_type: str, object_name: str) -> None:
32
+ """Opens a form/report in Design view."""
33
+ try:
34
+ if object_type == "form":
35
+ app.DoCmd.OpenForm(object_name, _AC_DESIGN)
36
+ else:
37
+ app.DoCmd.OpenReport(object_name, _AC_DESIGN)
38
+ except Exception as exc:
39
+ raise RuntimeError(
40
+ f"Could not open '{object_name}' in Design view. "
41
+ f"If it is open in Normal view, close it first.\nError: {exc}"
42
+ )
43
+
44
+
45
+ def _save_and_close(app: Any, object_type: str, object_name: str) -> None:
46
+ """Saves and closes a form/report open in Design view."""
47
+ ac_type = AC_FORM if object_type == "form" else _AC_REPORT
48
+ try:
49
+ app.DoCmd.Close(ac_type, object_name, _AC_SAVE_YES)
50
+ except Exception as exc:
51
+ log.warning("Error closing '%s': %s", object_name, exc)
52
+
53
+
54
+ def _get_design_obj(app: Any, object_type: str, object_name: str) -> Any:
55
+ """Returns the Form or Report object open in Design view."""
56
+ return app.Forms(object_name) if object_type == "form" else app.Reports(object_name)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # List objects
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def ac_list_objects(db_path: str, object_type: str = "all") -> dict:
64
+ """Returns a dict {type: [names...]} with the database objects."""
65
+ app = _Session.connect(db_path)
66
+
67
+ # CurrentData -> data objects (tables, queries)
68
+ # CurrentProject -> code objects (forms, reports, modules, macros)
69
+ containers = {
70
+ "table": app.CurrentData.AllTables,
71
+ "query": app.CurrentData.AllQueries,
72
+ "form": app.CurrentProject.AllForms,
73
+ "report": app.CurrentProject.AllReports,
74
+ "macro": app.CurrentProject.AllMacros,
75
+ "module": app.CurrentProject.AllModules,
76
+ }
77
+
78
+ keys = list(containers) if object_type == "all" else [object_type]
79
+ result: dict[str, list] = {}
80
+
81
+ for k in keys:
82
+ if k not in containers:
83
+ continue
84
+ col = containers[k]
85
+ names = [col.Item(i).Name for i in range(col.Count)]
86
+ if k == "table":
87
+ # Filter out system and temp tables
88
+ names = [n for n in names if not n.startswith("MSys") and not n.startswith("~")]
89
+ result[k] = names
90
+
91
+ return result
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Delete object
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def ac_delete_object(
99
+ db_path: str, object_type: str, object_name: str, confirm: bool = False,
100
+ ) -> dict:
101
+ """Deletes an Access object (module, form, report, query, macro) via DoCmd.DeleteObject."""
102
+ if object_type not in AC_TYPE:
103
+ raise ValueError(
104
+ f"Invalid object_type '{object_type}'. Valid: {list(AC_TYPE)}"
105
+ )
106
+ if not confirm:
107
+ raise ValueError(
108
+ "Destructive operation: confirm=true is required to delete an object."
109
+ )
110
+ app = _Session.connect(db_path)
111
+ try:
112
+ app.DoCmd.DeleteObject(AC_TYPE[object_type], object_name)
113
+ except Exception as exc:
114
+ raise RuntimeError(
115
+ f"Error deleting {object_type} '{object_name}': {exc}"
116
+ )
117
+ finally:
118
+ invalidate_all_caches()
119
+ return {
120
+ "action": "deleted",
121
+ "object_type": object_type,
122
+ "object_name": object_name,
123
+ }
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Get code (export)
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def ac_get_code(db_path: str, object_type: str, name: str) -> str:
131
+ """
132
+ Exports an Access object to text and returns the content.
133
+ For forms and reports, strips binary sections (PrtMip, PrtDevMode...)
134
+ that are irrelevant for editing VBA/controls and represent 95% of the size.
135
+ ac_set_code restores them automatically before importing.
136
+ """
137
+ if object_type not in AC_TYPE:
138
+ raise ValueError(
139
+ f"Invalid object_type '{object_type}'. Valid: {list(AC_TYPE)}"
140
+ )
141
+ app = _Session.connect(db_path)
142
+
143
+ fd, tmp = tempfile.mkstemp(suffix=".txt", prefix="access_mcp_")
144
+ os.close(fd)
145
+ try:
146
+ app.SaveAsText(AC_TYPE[object_type], name, tmp)
147
+ text, _enc = read_tmp(tmp)
148
+ if object_type in ("form", "report"):
149
+ text = strip_binary_sections(text)
150
+ return text
151
+ finally:
152
+ try:
153
+ os.unlink(tmp)
154
+ except OSError:
155
+ pass
156
+
157
+
158
+ # _split_code_behind has moved to helpers.split_code_behind — it was
159
+ # duplicated byte-for-byte in code.py and controls.py. Re-exported here
160
+ # under the old name for backwards compatibility within this module.
161
+ from .helpers import split_code_behind as _split_code_behind # noqa: E402,F401
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Class module header injection (for ac_set_code object_type="class_module")
166
+ # ---------------------------------------------------------------------------
167
+ #
168
+ # Access distinguishes class vs standard modules in LoadFromText by the
169
+ # presence of the four Attribute VB_* lines (VB_GlobalNameSpace, VB_Creatable,
170
+ # VB_PredeclaredId, VB_Exposed) at the top of the text -- NOT by a
171
+ # "VERSION 1.0 CLASS" header. That header is the format used by
172
+ # VBComponent.Export / VBComponents.Import (a different mechanism). If
173
+ # LoadFromText receives text starting with "VERSION 1.0 CLASS", Access
174
+ # interprets those lines as literal VBA code and creates a *standard*
175
+ # module with garbage at the top. Tested against Access 2016 on
176
+ # production DB 2026-04-08 — Type=2 only when the 4 attributes are present.
177
+
178
+ _VB_ATTR_RE = re.compile(r"^\s*Attribute\s+VB_GlobalNameSpace\s*=", re.IGNORECASE)
179
+ # Also detect the legacy (wrong) VERSION header so callers can't accidentally
180
+ # hand us a VBComponent.Export-style file — we'd strip it below.
181
+ _VERSION_CLASS_RE = re.compile(r"^\s*VERSION\s+\d+\.\d+\s+CLASS\s*$", re.IGNORECASE)
182
+
183
+
184
+ def _ensure_class_module_header(code: str, name: str) -> str:
185
+ """Prepend the four Attribute VB_* lines if missing (class module marker).
186
+
187
+ `Application.LoadFromText(acModule=5)` decides class vs standard by the
188
+ presence of these four attribute lines at the top of the file:
189
+ Attribute VB_GlobalNameSpace = False
190
+ Attribute VB_Creatable = False
191
+ Attribute VB_PredeclaredId = False
192
+ Attribute VB_Exposed = False
193
+
194
+ This helper:
195
+ - strips a leading BOM (if any),
196
+ - strips any `VERSION 1.0 CLASS` / `BEGIN` / `MultiUse` / `END` /
197
+ `Attribute VB_Name = "..."` block that the user may have pasted from
198
+ a VBComponent.Export file (wrong format for LoadFromText),
199
+ - if `Attribute VB_GlobalNameSpace` is already present in the first
200
+ handful of non-blank lines, returns code unchanged (round-trip safe),
201
+ - otherwise prepends the 4 attribute lines,
202
+ - normalises the body's line endings to CRLF.
203
+ """
204
+ if code.startswith("\ufeff"):
205
+ code = code.lstrip("\ufeff")
206
+
207
+ # Strip VBComponent.Export header block if present (wrong format; Access
208
+ # would interpret these lines as VBA code). We scan at most 8 lines.
209
+ lines = code.splitlines()
210
+ if lines and _VERSION_CLASS_RE.match(lines[0] or ""):
211
+ # Skip until we see either "END" (end of BEGIN block) or the first
212
+ # Attribute VB_Name line (which we also strip — LoadFromText takes
213
+ # the name as a parameter, Attribute VB_Name in the text is ignored
214
+ # or conflicts).
215
+ idx = 0
216
+ saw_end = False
217
+ for i, ln in enumerate(lines[:8]):
218
+ stripped = ln.strip()
219
+ if stripped.upper() == "END":
220
+ saw_end = True
221
+ idx = i + 1
222
+ break
223
+ if saw_end:
224
+ lines = lines[idx:]
225
+ # Also strip a leading Attribute VB_Name = "..." if present
226
+ while lines and re.match(r'^\s*Attribute\s+VB_Name\s*=', lines[0], re.IGNORECASE):
227
+ lines = lines[1:]
228
+ code = "\n".join(lines)
229
+
230
+ # Check if the 4 Attribute VB_* lines are already present at top
231
+ first_lines = [ln for ln in code.splitlines()[:10] if ln.strip()]
232
+ if any(_VB_ATTR_RE.match(ln) for ln in first_lines):
233
+ # Normalise endings, return unchanged header-wise
234
+ body = code.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
235
+ if body and not body.endswith("\r\n"):
236
+ body += "\r\n"
237
+ return body
238
+
239
+ header = (
240
+ "Attribute VB_GlobalNameSpace = False\r\n"
241
+ "Attribute VB_Creatable = False\r\n"
242
+ "Attribute VB_PredeclaredId = False\r\n"
243
+ "Attribute VB_Exposed = False\r\n"
244
+ )
245
+
246
+ # Normalise body to CRLF and ensure trailing newline
247
+ body = code.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
248
+ if body and not body.endswith("\r\n"):
249
+ body += "\r\n"
250
+
251
+ return header + body
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Inject VBA after import
256
+ # ---------------------------------------------------------------------------
257
+
258
+ def _inject_vba_after_import(app: Any, object_type: str, name: str, vba_code: str) -> None:
259
+ """
260
+ Injects VBA code into a form/report after importing it.
261
+ Activates HasModule by opening in Design view, then uses VBE to insert the code.
262
+ """
263
+ if not vba_code.strip():
264
+ return
265
+
266
+ # 1. Open in Design view and activate HasModule
267
+ _open_in_design(app, object_type, name)
268
+ try:
269
+ obj = _get_design_obj(app, object_type, name)
270
+ obj.HasModule = True
271
+ finally:
272
+ _save_and_close(app, object_type, name)
273
+
274
+ # 2. Clear VBE cache (module was just created)
275
+ cache_key = f"{object_type}:{name}"
276
+ _Session._cm_cache.pop(cache_key, None)
277
+
278
+ # 3. Inject code via VBE (lazy import from .vbe)
279
+ from .vbe import _get_code_module
280
+ cm = _get_code_module(app, object_type, name)
281
+ total = cm.CountOfLines
282
+
283
+ # Delete auto-generated content by Access (Option Compare Database, etc.)
284
+ # to avoid duplicates with the VBA we are about to inject
285
+ if total > 0:
286
+ cm.DeleteLines(1, total)
287
+
288
+ # Normalize line endings to \r\n (VBE requires it)
289
+ vba_code = vba_code.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
290
+ if not vba_code.endswith("\r\n"):
291
+ vba_code += "\r\n"
292
+
293
+ # Ensure Option Compare Database and Option Explicit at the top
294
+ vba_lines = vba_code.split("\r\n")
295
+ has_compare = any(re.match(r'^\s*Option\s+Compare', l, re.I) for l in vba_lines)
296
+ has_explicit = any(re.match(r'^\s*Option\s+Explicit', l, re.I) for l in vba_lines)
297
+ prepend = []
298
+ if not has_compare:
299
+ prepend.append("Option Compare Database")
300
+ if not has_explicit:
301
+ prepend.append("Option Explicit")
302
+ if prepend:
303
+ vba_code = "\r\n".join(prepend) + "\r\n" + vba_code
304
+
305
+ cm.InsertLines(1, vba_code)
306
+
307
+ # Invalidate caches
308
+ _Session._cm_cache.pop(cache_key, None)
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Set code (import)
313
+ # ---------------------------------------------------------------------------
314
+
315
+ def ac_set_code(db_path: str, object_type: str, name: str, code: str) -> str:
316
+ """
317
+ Imports text as an Access object definition (creates or overwrites).
318
+ For forms and reports, automatically re-injects binary sections
319
+ (PrtMip, PrtDevMode...) from the current export, so the caller doesn't need
320
+ to include them in the code they send.
321
+
322
+ If the code contains a CodeBehindForm/CodeBehindReport section, it is automatically
323
+ split: first the form/report is imported without VBA, then the VBA code is injected
324
+ via VBE (avoiding encoding issues with LoadFromText).
325
+
326
+ object_type='class_module' creates a VBA class module: the canonical
327
+ `VERSION 1.0 CLASS` header is prepended automatically if missing.
328
+ """
329
+ valid_types = set(AC_TYPE) | {"class_module"}
330
+ if object_type not in valid_types:
331
+ raise ValueError(
332
+ f"Invalid object_type '{object_type}'. Valid: {sorted(valid_types)}"
333
+ )
334
+ # class_module re-uses acModule (5) but with a different text header
335
+ _ac_type_code = AC_TYPE["module"] if object_type == "class_module" else AC_TYPE[object_type]
336
+ app = _Session.connect(db_path)
337
+
338
+ # Split CodeBehindForm/CodeBehindReport if present
339
+ vba_code = ""
340
+ if object_type in ("form", "report"):
341
+ code, vba_code = _split_code_behind(code)
342
+ # Remove HasModule from form text — it will be activated when injecting VBA
343
+ if vba_code:
344
+ code = re.sub(r"^\s*HasModule\s*=.*$", "", code, flags=re.MULTILINE)
345
+
346
+ # If the code doesn't contain binary sections (returned by ac_get_code
347
+ # with filtering active), restore them from the current form/report.
348
+ if object_type in ("form", "report") and not any(
349
+ s in code for s in BINARY_SECTIONS
350
+ ):
351
+ log.info("ac_set_code: restoring binary sections for '%s'", name)
352
+ code = restore_binary_sections(app, object_type, name, code)
353
+
354
+ # Ensure class module header is present (no-op if already there)
355
+ if object_type == "class_module":
356
+ code = _ensure_class_module_header(code, name)
357
+
358
+ # Backup existing object in case import fails
359
+ backup_tmp = None
360
+ if object_type in ("form", "report", "module", "class_module"):
361
+ try:
362
+ fd_bk, backup_tmp = tempfile.mkstemp(suffix=".txt", prefix="access_mcp_bk_")
363
+ os.close(fd_bk)
364
+ app.SaveAsText(_ac_type_code, name, backup_tmp)
365
+ except Exception:
366
+ # Doesn't exist yet — no backup needed
367
+ if backup_tmp:
368
+ try:
369
+ os.unlink(backup_tmp)
370
+ except OSError:
371
+ pass
372
+ backup_tmp = None
373
+
374
+ fd, tmp = tempfile.mkstemp(suffix=".txt", prefix="access_mcp_")
375
+ os.close(fd)
376
+ try:
377
+ # VBA modules (.bas and class modules) expect the system ANSI codepage
378
+ # (cp1252 on Western Windows, cp1253 on Greek, etc.);
379
+ # forms/reports/queries/macros expect UTF-16LE with BOM
380
+ if object_type in ("module", "class_module"):
381
+ import locale
382
+ enc = locale.getpreferredencoding(False) or "cp1252"
383
+ else:
384
+ enc = "utf-16"
385
+ write_tmp(tmp, code, encoding=enc)
386
+ try:
387
+ app.LoadFromText(_ac_type_code, name, tmp)
388
+ except Exception as import_exc:
389
+ # Restaurar backup si existe
390
+ if backup_tmp and os.path.exists(backup_tmp):
391
+ log.warning("ac_set_code: import failed, restoring backup for '%s'", name)
392
+ try:
393
+ app.LoadFromText(_ac_type_code, name, backup_tmp)
394
+ except Exception:
395
+ log.error("ac_set_code: could not restore backup for '%s'", name)
396
+ raise import_exc
397
+
398
+ # Invalidate caches for this object (code and controls changed).
399
+ # class_module also aliases the "module" key because access_get_code
400
+ # and _get_code_module read via the "module" key for all .bas modules.
401
+ invalidate_object_caches(object_type, name)
402
+ if object_type == "class_module":
403
+ invalidate_object_caches("module", name)
404
+
405
+ # Inject VBA if there was CodeBehindForm
406
+ vba_msg = ""
407
+ if vba_code:
408
+ _inject_vba_after_import(app, object_type, name, vba_code)
409
+ vba_msg = " (with VBA injected via VBE)"
410
+
411
+ return f"OK: '{name}' ({object_type}) imported successfully into {db_path}{vba_msg}"
412
+ finally:
413
+ try:
414
+ os.unlink(tmp)
415
+ except OSError:
416
+ pass
417
+ if backup_tmp:
418
+ try:
419
+ os.unlink(backup_tmp)
420
+ except OSError:
421
+ pass
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Create form
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def ac_create_form(db_path: str, form_name: str, has_header: bool = False) -> dict:
429
+ """Creates a new form avoiding the 'Save As' MsgBox that blocks COM.
430
+
431
+ CreateForm() generates a form with an auto name (Form1, Form2...).
432
+ DoCmd.Save saves with that name (no dialog).
433
+ DoCmd.Close with acSaveNo closes (already saved, no dialog).
434
+ DoCmd.Rename renames to the desired name.
435
+ """
436
+ app = _Session.connect(db_path)
437
+ auto_name = None
438
+ try:
439
+ form = app.CreateForm()
440
+ auto_name = form.Name # e.g. "Form1"
441
+
442
+ if has_header:
443
+ app.RunCommand(36) # acCmdFormHdrFtr — toggle header/footer
444
+
445
+ # Save with auto-name — no dialog (DoCmd.Save uses current name)
446
+ app.DoCmd.Save(AC_FORM, auto_name)
447
+ # Close without prompt (already saved)
448
+ app.DoCmd.Close(AC_FORM, auto_name, AC_SAVE_NO)
449
+
450
+ # Rename to desired name
451
+ if auto_name != form_name:
452
+ app.DoCmd.Rename(form_name, AC_FORM, auto_name)
453
+
454
+ return {"name": form_name, "created_from": auto_name, "has_header": has_header}
455
+ except Exception as exc:
456
+ if auto_name:
457
+ try:
458
+ app.DoCmd.Close(AC_FORM, auto_name, AC_SAVE_NO)
459
+ except Exception:
460
+ pass
461
+ try:
462
+ app.DoCmd.DeleteObject(AC_FORM, auto_name)
463
+ except Exception:
464
+ pass
465
+ raise RuntimeError(f"Error creating form '{form_name}': {exc}")
466
+ finally:
467
+ invalidate_all_caches()
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # Export structure
472
+ # ---------------------------------------------------------------------------
473
+
474
+ def ac_export_structure(db_path: str, output_path: str | None = None) -> str:
475
+ """
476
+ Generates a Markdown file with the complete database structure:
477
+ VBA modules with their function signatures, forms, reports and queries.
478
+ """
479
+ from datetime import datetime
480
+
481
+ if output_path is None:
482
+ output_path = str(Path(db_path).parent / "db_structure.md")
483
+
484
+ objects = ac_list_objects(db_path, "all")
485
+ modules = objects.get("module", [])
486
+ forms = objects.get("form", [])
487
+ reports = objects.get("report", [])
488
+ queries = objects.get("query", [])
489
+ macros = objects.get("macro", [])
490
+
491
+ lines: list[str] = []
492
+ lines.append(f"# Structure of `{Path(db_path).name}`")
493
+ lines.append(f"\n**Path**: `{db_path}` ")
494
+ lines.append(f"**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M')} ")
495
+ lines.append(
496
+ f"**Summary**: {len(modules)} modules · {len(forms)} forms · "
497
+ f"{len(reports)} reports · {len(queries)} queries · {len(macros)} macros\n"
498
+ )
499
+
500
+ # -- VBA Modules with signatures --
501
+ # Read modules via VBE (no SaveAsText/disk) and warming up the code cache
502
+ # Lazy imports from .vbe
503
+ from .vbe import _get_code_module, _cm_all_code
504
+
505
+ app = _Session.connect(db_path)
506
+ lines.append(f"## VBA Modules ({len(modules)})\n")
507
+ for mod_name in modules:
508
+ lines.append(f"### `{mod_name}`")
509
+ try:
510
+ cm = _get_code_module(app, "module", mod_name)
511
+ cache_key = f"module:{mod_name}"
512
+ code = _cm_all_code(cm, cache_key)
513
+ sigs = []
514
+ for line in code.splitlines():
515
+ stripped = line.strip()
516
+ if re.match(
517
+ r"^(Public\s+|Private\s+|Friend\s+)?(Function|Sub)\s+\w+",
518
+ stripped,
519
+ re.IGNORECASE,
520
+ ):
521
+ sigs.append(f" - `{stripped}`")
522
+ if sigs:
523
+ lines.extend(sigs)
524
+ else:
525
+ lines.append(" *(no public functions/subs)*")
526
+ except Exception as exc:
527
+ lines.append(f" *(error reading: {exc})*")
528
+ lines.append("")
529
+
530
+ # -- Forms --
531
+ lines.append(f"## Forms ({len(forms)})\n")
532
+ if forms:
533
+ for name in forms:
534
+ lines.append(f"- `{name}`")
535
+ else:
536
+ lines.append("*(none)*")
537
+ lines.append("")
538
+
539
+ # -- Reports --
540
+ lines.append(f"## Reports ({len(reports)})\n")
541
+ if reports:
542
+ for name in reports:
543
+ lines.append(f"- `{name}`")
544
+ else:
545
+ lines.append("*(none)*")
546
+ lines.append("")
547
+
548
+ # -- Queries --
549
+ lines.append(f"## Queries ({len(queries)})\n")
550
+ if queries:
551
+ for name in queries:
552
+ lines.append(f"- `{name}`")
553
+ else:
554
+ lines.append("*(none)*")
555
+ lines.append("")
556
+
557
+ # -- Macros --
558
+ if macros:
559
+ lines.append(f"## Macros ({len(macros)})\n")
560
+ for name in macros:
561
+ lines.append(f"- `{name}`")
562
+ lines.append("")
563
+
564
+ content = "\n".join(lines)
565
+ with open(output_path, "w", encoding="utf-8") as f:
566
+ f.write(content)
567
+
568
+ return f"[Saved to `{output_path}`]\n\n{content}"