docalyze-mcp-server 0.1.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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .env
9
+ .mcpregistry_*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juan Esteban Mosquera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: docalyze-mcp-server
3
+ Version: 0.1.0
4
+ Summary: MCP server for reading and visually analyzing local documents (PDF, Excel, CSV, Word, PowerPoint, images). No API keys required — works with GitHub Copilot and any MCP-compatible AI host.
5
+ Project-URL: Repository, https://github.com/LunarPerovskite/docalyze-mcp-server
6
+ Author: Juan Esteban Mosquera
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: copilot,docalyze,documents,excel,mcp,pdf,vscode
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Text Processing :: General
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: mcp>=1.0.0
16
+ Requires-Dist: openpyxl>=3.1
17
+ Requires-Dist: pandas>=2.0
18
+ Requires-Dist: pdfplumber>=0.10
19
+ Provides-Extra: all
20
+ Requires-Dist: pillow>=10.0; extra == 'all'
21
+ Requires-Dist: pytesseract>=0.3; extra == 'all'
22
+ Requires-Dist: python-docx>=1.0; extra == 'all'
23
+ Requires-Dist: python-pptx>=0.6; extra == 'all'
24
+ Provides-Extra: docx
25
+ Requires-Dist: python-docx>=1.0; extra == 'docx'
26
+ Provides-Extra: ocr
27
+ Requires-Dist: pillow>=10.0; extra == 'ocr'
28
+ Requires-Dist: pytesseract>=0.3; extra == 'ocr'
29
+ Provides-Extra: pptx
30
+ Requires-Dist: python-pptx>=0.6; extra == 'pptx'
31
+ Description-Content-Type: text/markdown
32
+
33
+ <!-- mcp-name: io.github.LunarPerovskite/docalyze -->
34
+
35
+ # Docalyze MCP Server
36
+
37
+ An MCP (Model Context Protocol) server that lets AI assistants read and visually analyze local documents — PDFs, Excel spreadsheets, CSV files, Word documents, PowerPoint presentations, and images.
38
+
39
+ No API keys required. The host AI (GitHub Copilot, Claude, etc.) does all the reasoning directly.
40
+
41
+ ## Supported Formats
42
+
43
+ | Format | Extensions | Read | Visual |
44
+ |--------|-----------|:----:|:------:|
45
+ | PDF | `.pdf` | ✅ | ✅ |
46
+ | Excel | `.xlsx`, `.xls` | ✅ | ✅ |
47
+ | CSV / TSV | `.csv`, `.tsv` | ✅ | — |
48
+ | JSON | `.json` | ✅ | — |
49
+ | Word | `.docx` | ✅ | ✅ |
50
+ | PowerPoint | `.pptx` | ✅ | ✅ |
51
+ | Plain text | `.txt`, `.md` | ✅ | — |
52
+ | Images | `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tiff`, `.webp` | — | ✅ |
53
+
54
+ ## Tools
55
+
56
+ | Tool | Description |
57
+ |------|-------------|
58
+ | `list_documents` | List files under a directory, filtered by glob pattern |
59
+ | `document_info` | Get metadata (size, modified date, sheets) for a file |
60
+ | `read_document` | Extract text content from a document with pagination |
61
+ | `visual_evaluate_document` | Return page images inline so the AI can analyze charts, tables, and diagrams |
62
+
63
+ ## Installation
64
+
65
+ ### From VS Code (recommended)
66
+
67
+ Search for **docalyze** in the MCP server gallery (Extensions sidebar → MCP tab) and click Install.
68
+
69
+ ### From PyPI
70
+
71
+ ```bash
72
+ pip install docalyze-mcp-server
73
+ ```
74
+
75
+ ### Manual setup
76
+
77
+ Add to your VS Code `mcp.json` (or `settings.json`):
78
+
79
+ ```jsonc
80
+ {
81
+ "servers": {
82
+ "docalyze": {
83
+ "type": "stdio",
84
+ "command": "python",
85
+ "args": ["-m", "docalyze_mcp_server"],
86
+ "env": {
87
+ "PYTHONIOENCODING": "utf-8"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Or, if you installed via pip and want to use the entry point:
95
+
96
+ ```jsonc
97
+ {
98
+ "servers": {
99
+ "docalyze": {
100
+ "type": "stdio",
101
+ "command": "docalyze-mcp-server"
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ ## Optional Dependencies
108
+
109
+ The base install handles PDF, Excel, CSV, JSON, and plain text. For additional formats:
110
+
111
+ ```bash
112
+ # Word documents
113
+ pip install docalyze-mcp-server[docx]
114
+
115
+ # PowerPoint
116
+ pip install docalyze-mcp-server[pptx]
117
+
118
+ # OCR (requires Tesseract installed on your system)
119
+ pip install docalyze-mcp-server[ocr]
120
+
121
+ # Everything
122
+ pip install docalyze-mcp-server[all]
123
+ ```
124
+
125
+ ## Configuration
126
+
127
+ The server reads documents from a configurable root directory. Set the `DOCUMENTS_ROOT` environment variable to change it:
128
+
129
+ ```jsonc
130
+ {
131
+ "servers": {
132
+ "docalyze": {
133
+ "type": "stdio",
134
+ "command": "docalyze-mcp-server",
135
+ "env": {
136
+ "DOCUMENTS_ROOT": "/path/to/your/documents"
137
+ }
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ If not set, it defaults to the directory containing the server script.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,115 @@
1
+ <!-- mcp-name: io.github.LunarPerovskite/docalyze -->
2
+
3
+ # Docalyze MCP Server
4
+
5
+ An MCP (Model Context Protocol) server that lets AI assistants read and visually analyze local documents — PDFs, Excel spreadsheets, CSV files, Word documents, PowerPoint presentations, and images.
6
+
7
+ No API keys required. The host AI (GitHub Copilot, Claude, etc.) does all the reasoning directly.
8
+
9
+ ## Supported Formats
10
+
11
+ | Format | Extensions | Read | Visual |
12
+ |--------|-----------|:----:|:------:|
13
+ | PDF | `.pdf` | ✅ | ✅ |
14
+ | Excel | `.xlsx`, `.xls` | ✅ | ✅ |
15
+ | CSV / TSV | `.csv`, `.tsv` | ✅ | — |
16
+ | JSON | `.json` | ✅ | — |
17
+ | Word | `.docx` | ✅ | ✅ |
18
+ | PowerPoint | `.pptx` | ✅ | ✅ |
19
+ | Plain text | `.txt`, `.md` | ✅ | — |
20
+ | Images | `.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tiff`, `.webp` | — | ✅ |
21
+
22
+ ## Tools
23
+
24
+ | Tool | Description |
25
+ |------|-------------|
26
+ | `list_documents` | List files under a directory, filtered by glob pattern |
27
+ | `document_info` | Get metadata (size, modified date, sheets) for a file |
28
+ | `read_document` | Extract text content from a document with pagination |
29
+ | `visual_evaluate_document` | Return page images inline so the AI can analyze charts, tables, and diagrams |
30
+
31
+ ## Installation
32
+
33
+ ### From VS Code (recommended)
34
+
35
+ Search for **docalyze** in the MCP server gallery (Extensions sidebar → MCP tab) and click Install.
36
+
37
+ ### From PyPI
38
+
39
+ ```bash
40
+ pip install docalyze-mcp-server
41
+ ```
42
+
43
+ ### Manual setup
44
+
45
+ Add to your VS Code `mcp.json` (or `settings.json`):
46
+
47
+ ```jsonc
48
+ {
49
+ "servers": {
50
+ "docalyze": {
51
+ "type": "stdio",
52
+ "command": "python",
53
+ "args": ["-m", "docalyze_mcp_server"],
54
+ "env": {
55
+ "PYTHONIOENCODING": "utf-8"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ Or, if you installed via pip and want to use the entry point:
63
+
64
+ ```jsonc
65
+ {
66
+ "servers": {
67
+ "docalyze": {
68
+ "type": "stdio",
69
+ "command": "docalyze-mcp-server"
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Optional Dependencies
76
+
77
+ The base install handles PDF, Excel, CSV, JSON, and plain text. For additional formats:
78
+
79
+ ```bash
80
+ # Word documents
81
+ pip install docalyze-mcp-server[docx]
82
+
83
+ # PowerPoint
84
+ pip install docalyze-mcp-server[pptx]
85
+
86
+ # OCR (requires Tesseract installed on your system)
87
+ pip install docalyze-mcp-server[ocr]
88
+
89
+ # Everything
90
+ pip install docalyze-mcp-server[all]
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ The server reads documents from a configurable root directory. Set the `DOCUMENTS_ROOT` environment variable to change it:
96
+
97
+ ```jsonc
98
+ {
99
+ "servers": {
100
+ "docalyze": {
101
+ "type": "stdio",
102
+ "command": "docalyze-mcp-server",
103
+ "env": {
104
+ "DOCUMENTS_ROOT": "/path/to/your/documents"
105
+ }
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ If not set, it defaults to the directory containing the server script.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,628 @@
1
+ """
2
+ Docalyze MCP Server
3
+
4
+ Provides Model Context Protocol tools to inspect and read local documents
5
+ (PDF, Excel, CSV, JSON, TXT, Markdown). Designed to be launched via
6
+ VS Code's MCP configuration.
7
+
8
+ No external LLM API keys required — the host AI (e.g. GitHub Copilot)
9
+ performs all analysis on the returned content, including images.
10
+ """
11
+ import json
12
+ import os
13
+ import base64
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import pandas as pd
18
+ import pdfplumber
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ try:
22
+ import openpyxl
23
+ from openpyxl.drawing.image import Image as XLImage
24
+ except ImportError: # pragma: no cover
25
+ openpyxl = None
26
+ XLImage = None
27
+
28
+ try:
29
+ import docx # optional dependency
30
+ except ImportError: # pragma: no cover
31
+ docx = None
32
+
33
+ try:
34
+ import pptx
35
+ except ImportError:
36
+ pptx = None
37
+
38
+ try: # optional OCR dependencies
39
+ import pytesseract
40
+ from PIL import Image
41
+ except ImportError: # pragma: no cover
42
+ pytesseract = None
43
+ Image = None
44
+
45
+ # ----------------------------------------------------------------------------
46
+ # Configuration
47
+ # ----------------------------------------------------------------------------
48
+ DEFAULT_ROOT = Path(os.getenv("DOCUMENTS_ROOT", Path(__file__).parent))
49
+ ALLOWED_EXTENSIONS = {
50
+ ".pdf",
51
+ ".txt",
52
+ ".md",
53
+ ".json",
54
+ ".csv",
55
+ ".xlsx",
56
+ ".xls",
57
+ ".tsv",
58
+ ".docx",
59
+ ".pptx",
60
+ ".png",
61
+ ".jpg",
62
+ ".jpeg",
63
+ ".bmp",
64
+ ".tif",
65
+ ".tiff",
66
+ }
67
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
68
+
69
+ server = FastMCP("docalyze")
70
+
71
+
72
+ def _resolve_path(file_path: str) -> Path:
73
+ path = (DEFAULT_ROOT / file_path).resolve() if not Path(file_path).is_absolute() else Path(file_path).resolve()
74
+ if not str(path).startswith(str(DEFAULT_ROOT.resolve())):
75
+ raise ValueError("Access outside of root directory is not allowed")
76
+ if not path.exists():
77
+ raise FileNotFoundError(f"File not found: {path}")
78
+ if path.suffix.lower() not in ALLOWED_EXTENSIONS:
79
+ raise ValueError(f"Unsupported file type: {path.suffix}")
80
+ return path
81
+
82
+
83
+ def _read_text_file(path: Path, max_chars: int) -> str:
84
+ data = path.read_text(encoding="utf-8", errors="ignore")
85
+ return data[:max_chars]
86
+
87
+
88
+ def _read_json_file(path: Path, max_chars: int) -> str:
89
+ data = json.loads(path.read_text(encoding="utf-8"))
90
+ pretty = json.dumps(data, indent=2)
91
+ return pretty[:max_chars]
92
+
93
+
94
+ def _read_csv_file(path: Path, max_rows: int, sep: str = ",") -> str:
95
+ df = pd.read_csv(path, sep=sep, nrows=max_rows)
96
+ return df.to_string(index=False)
97
+
98
+
99
+ def _read_excel_file(path: Path, sheet: Optional[str], start_row: int, num_rows: int) -> str:
100
+ """Read Excel file with full openpyxl support for chart/image detection."""
101
+ try:
102
+ from openpyxl import load_workbook
103
+ except ImportError:
104
+ raise ImportError("openpyxl is required for Excel files; install it with: pip install openpyxl")
105
+
106
+ # Load workbook to inspect structure
107
+ wb = load_workbook(path, data_only=True)
108
+ available_sheets = wb.sheetnames
109
+
110
+ # Validate requested sheet
111
+ if sheet:
112
+ if sheet not in available_sheets:
113
+ wb.close()
114
+ raise ValueError(
115
+ f"Sheet '{sheet}' not found. Available sheets: {', '.join(available_sheets)}"
116
+ )
117
+ sheet_name = sheet
118
+ else:
119
+ sheet_name = available_sheets[0]
120
+
121
+ ws = wb[sheet_name]
122
+
123
+ # Detect charts and images (filter small logos)
124
+ has_charts = len(ws._charts) > 0 if hasattr(ws, '_charts') else False
125
+
126
+ # Count only significant images (>= 50x50 pixels)
127
+ significant_image_count = 0
128
+ if hasattr(ws, '_images'):
129
+ for img in ws._images:
130
+ # Check image dimensions
131
+ if hasattr(img, 'width') and hasattr(img, 'height'):
132
+ # openpyxl stores dimensions in EMUs (English Metric Units)
133
+ # 914400 EMUs = 1 inch, typical 96 DPI means ~9525 EMUs per pixel
134
+ width_pixels = img.width / 9525 if img.width else 0
135
+ height_pixels = img.height / 9525 if img.height else 0
136
+ # Only count images >= 200x200 pixels (filter logos)
137
+ if width_pixels >= 200 and height_pixels >= 200:
138
+ significant_image_count += 1
139
+ else:
140
+ # If no dimensions, count it
141
+ significant_image_count += 1
142
+
143
+ has_images = significant_image_count > 0
144
+ chart_count = len(ws._charts) if has_charts else 0
145
+ image_count = significant_image_count
146
+
147
+ # Try to read data
148
+ try:
149
+ df = pd.read_excel(path, sheet_name=sheet_name)
150
+ if df.empty or df.dropna(how='all').empty:
151
+ # Empty data sheet, likely chart-only
152
+ wb.close()
153
+ sheets_list = ', '.join(f"'{s}'" for s in available_sheets)
154
+ header = f"[Available sheets: {sheets_list}]\nReading sheet: '{sheet_name}'\n\n"
155
+ msg = (
156
+ f"⚠️ VISUAL CONTENT DETECTED: Sheet contains {chart_count} chart(s) and {image_count} image(s) but no data table ⚠️\n\n"
157
+ f"RECOMMENDATION: Convert Excel to PDF first, then use:\n"
158
+ f" visual_evaluate_document(file_path='<pdf_path>', enable_vision=true, sheet='{sheet_name}')\n"
159
+ "to extract chart content with GPT-4 Vision.\n"
160
+ )
161
+ return header + msg
162
+
163
+ subset = df.iloc[start_row : start_row + num_rows]
164
+ wb.close()
165
+
166
+ # Include metadata in output
167
+ sheets_list = ', '.join(f"'{s}'" for s in available_sheets)
168
+ header = f"[Available sheets: {sheets_list}]\nReading sheet: '{sheet_name}'\n"
169
+ if has_charts or has_images:
170
+ header += (
171
+ f"\n⚠️ VISUAL CONTENT DETECTED: {chart_count} chart(s) and {image_count} image(s) in this sheet ⚠️\n"
172
+ f"NOTE: Text/data extraction successful (see below), but charts/images not extracted.\n"
173
+ f"RECOMMENDATION: To extract chart content, convert to PDF and use:\n"
174
+ f" visual_evaluate_document(file_path='<pdf_path>', enable_vision=true)\n"
175
+ )
176
+ header += "\n"
177
+ return header + subset.to_string(index=False)
178
+
179
+ except Exception as e:
180
+ # Sheet might be chart-only or corrupted
181
+ wb.close()
182
+ sheets_list = ', '.join(f"'{s}'" for s in available_sheets)
183
+ header = f"[Available sheets: {sheets_list}]\nReading sheet: '{sheet_name}'\n\n"
184
+ msg = (
185
+ f"⚠️ VISUAL CONTENT DETECTED: {chart_count} chart(s) and {image_count} image(s) ⚠️\n"
186
+ f"[Cannot read sheet as data table: {str(e)}]\n\n"
187
+ f"RECOMMENDATION: Convert Excel to PDF first, then use:\n"
188
+ f" visual_evaluate_document(file_path='<pdf_path>', enable_vision=true)\n"
189
+ "to analyze this chart-only sheet with GPT-4 Vision.\n"
190
+ )
191
+ return header + msg
192
+
193
+
194
+ def _read_pdf_file(path: Path, page: int, max_pages: int, max_chars: int) -> str:
195
+ """Read PDF with detection of images/charts to recommend vision analysis."""
196
+ texts: List[str] = []
197
+ visual_content_detected = []
198
+
199
+ with pdfplumber.open(path) as pdf:
200
+ total_pages = len(pdf.pages)
201
+ start = max(page - 1, 0)
202
+ end = min(start + max_pages, total_pages)
203
+
204
+ for idx in range(start, end):
205
+ page_obj = pdf.pages[idx]
206
+
207
+ # Detect visual content on this page
208
+ # Filter out small images (logos, icons) - only count images >= 200x200 pixels
209
+ significant_images = []
210
+ if hasattr(page_obj, 'images'):
211
+ for img in page_obj.images:
212
+ # Check image dimensions
213
+ width = img.get('width', 0)
214
+ height = img.get('height', 0)
215
+ # Only count images that are large enough to contain data (>= 200x200)
216
+ # Logos and small icons are typically < 200 pixels
217
+ if width >= 200 and height >= 200:
218
+ significant_images.append(img)
219
+
220
+ has_images = len(significant_images) > 0
221
+ has_curves = len(page_obj.curves) > 0 if hasattr(page_obj, 'curves') else False
222
+
223
+ # Extract text
224
+ extracted = page_obj.extract_text() or ""
225
+
226
+ # Check if page is mostly images (scanned or has charts)
227
+ if has_images or (has_curves and len(extracted.strip()) < 100):
228
+ image_count = len(significant_images)
229
+ visual_content_detected.append(f"Page {idx + 1}: {image_count} image(s), possible chart/diagram")
230
+
231
+ header = f"\n--- Page {idx + 1}/{total_pages} ---\n"
232
+ texts.append(header + extracted)
233
+
234
+ joined = "\n".join(texts)
235
+
236
+ # Add recommendation if visual content detected
237
+ if visual_content_detected:
238
+ recommendation = (
239
+ "\n\n⚠️ VISUAL CONTENT DETECTED ⚠️\n"
240
+ + "\n".join(visual_content_detected)
241
+ + f"\n\nRECOMMENDATION: Use visual_evaluate_document(file_path='{path.name}', enable_vision=true, page={page}) "
242
+ "to extract charts, diagrams, and image content with GPT-4 Vision.\n"
243
+ )
244
+ joined = recommendation + joined
245
+
246
+ return joined[:max_chars + 500] # Allow extra space for recommendation
247
+
248
+
249
+ def _format_table(rows: List[List[str]]) -> str:
250
+ if not rows:
251
+ return ""
252
+ num_cols = max(len(row) for row in rows)
253
+ widths = [0] * num_cols
254
+ for row in rows:
255
+ for idx in range(num_cols):
256
+ cell = row[idx] if idx < len(row) else ""
257
+ widths[idx] = max(widths[idx], len(cell))
258
+ lines: List[str] = []
259
+ for row in rows:
260
+ cells: List[str] = []
261
+ for idx in range(num_cols):
262
+ cell = row[idx] if idx < len(row) else ""
263
+ cells.append(cell.ljust(widths[idx]))
264
+ lines.append(" | ".join(cells).rstrip())
265
+ return "\n".join(lines)
266
+
267
+
268
+ def _read_docx_file(path: Path, max_chars: int, include_tables: bool) -> str:
269
+ """Read Word document with detection of images and embedded objects."""
270
+ if docx is None:
271
+ raise ImportError("python-docx is required to read DOCX files")
272
+
273
+ document = docx.Document(str(path))
274
+
275
+ # Detect images and embedded objects (filter out small logos)
276
+ from PIL import Image as PILImage
277
+ import io
278
+
279
+ image_count = 0
280
+ for rel in document.part.rels.values():
281
+ if "image" in rel.target_ref:
282
+ try:
283
+ # Try to get image dimensions
284
+ image_part = rel.target_part
285
+ img_stream = io.BytesIO(image_part.blob)
286
+ img = PILImage.open(img_stream)
287
+ width, height = img.size
288
+ # Only count images >= 200x200 pixels (filter logos and small icons)
289
+ if width >= 200 and height >= 200:
290
+ image_count += 1
291
+ except:
292
+ # If we can't read dimensions, count it anyway
293
+ image_count += 1
294
+
295
+ # Count tables
296
+ table_count = len(document.tables)
297
+
298
+ # Extract text
299
+ paragraphs = [p.text for p in document.paragraphs]
300
+ text = "\n".join(paragraphs)
301
+
302
+ # Add metadata header with visual content detection
303
+ header_parts = []
304
+ if image_count > 0:
305
+ header_parts.append(f"{image_count} image(s)")
306
+ if table_count > 0:
307
+ header_parts.append(f"{table_count} table(s)")
308
+
309
+ if header_parts:
310
+ header = f"[Document contains: {', '.join(header_parts)}]\n"
311
+ if image_count > 0:
312
+ header += (
313
+ f"\n⚠️ VISUAL CONTENT DETECTED: {image_count} image(s) in this Word document ⚠️\n"
314
+ f"RECOMMENDATION: Convert to PDF first, then use:\n"
315
+ f" visual_evaluate_document(file_path='<pdf_path>', enable_vision=true)\n"
316
+ "to extract content from images with GPT-4 Vision.\n"
317
+ )
318
+ header += "\n"
319
+ text = header + text
320
+
321
+ # Extract tables if requested
322
+ if include_tables and document.tables:
323
+ table_chunks: List[str] = []
324
+ for idx, table in enumerate(document.tables, start=1):
325
+ tables_rows: List[List[str]] = []
326
+ for row in table.rows:
327
+ tables_rows.append([cell.text.strip() for cell in row.cells])
328
+ formatted = _format_table(tables_rows)
329
+ table_chunks.append(f"Table {idx}:\n{formatted}")
330
+ if table_chunks:
331
+ text += "\n\n" + "\n\n".join(table_chunks)
332
+
333
+ return text[:max_chars]
334
+
335
+
336
+ def _read_pptx_file(path: Path, max_chars: int) -> str:
337
+ """Read PowerPoint file with detection of visuals."""
338
+ if pptx is None:
339
+ raise ImportError("python-pptx is required to read PPTX files")
340
+
341
+ prs = pptx.Presentation(str(path))
342
+ texts = []
343
+
344
+ # Metadata
345
+ slide_count = len(prs.slides)
346
+ header = f"[PowerPoint Presentation: {slide_count} slides]\n"
347
+
348
+ for i, slide in enumerate(prs.slides):
349
+ slide_text = []
350
+ title = slide.shapes.title.text if slide.shapes.title else "No Title"
351
+ slide_text.append(f"--- Slide {i+1}: {title} ---")
352
+
353
+ # Shapes text
354
+ for shape in slide.shapes:
355
+ if hasattr(shape, "text") and shape.text:
356
+ text_clean = shape.text.strip()
357
+ if text_clean and text_clean != title:
358
+ slide_text.append(text_clean)
359
+
360
+ if shape.has_table:
361
+ table = shape.table
362
+ rows_data = []
363
+ for row in table.rows:
364
+ rows_data.append([cell.text_frame.text.strip() for cell in row.cells])
365
+ formatted = _format_table(rows_data)
366
+ slide_text.append(f"\n[Table]\n{formatted}\n")
367
+
368
+ texts.append("\n".join(slide_text))
369
+
370
+ return header + "\n\n".join(texts)[:max_chars]
371
+
372
+
373
+ def _require_ocr_dependencies() -> None:
374
+ if pytesseract is None or Image is None:
375
+ raise ImportError("pytesseract and pillow are required for OCR; install them to enable this feature")
376
+
377
+
378
+
379
+ def _ocr_image(path: Path, lang: str) -> str:
380
+ _require_ocr_dependencies()
381
+ with Image.open(path) as img:
382
+ return pytesseract.image_to_string(img, lang=lang)
383
+
384
+
385
+ def _ocr_pdf_pages(path: Path, page: int, max_pages: int, lang: str) -> str:
386
+ _require_ocr_dependencies()
387
+ texts: List[str] = []
388
+ with pdfplumber.open(path) as pdf:
389
+ total_pages = len(pdf.pages)
390
+ start = max(page - 1, 0)
391
+ end = min(start + max_pages, total_pages)
392
+ for idx in range(start, end):
393
+ page_obj = pdf.pages[idx]
394
+ page_image = page_obj.to_image(resolution=200).original
395
+ ocr_text = pytesseract.image_to_string(page_image, lang=lang)
396
+ texts.append(f"--- OCR Page {idx + 1}/{total_pages} ---\n{ocr_text.strip()}")
397
+ return "\n\n".join(texts)
398
+
399
+
400
+ def _encode_image_base64(image_path: Path) -> str:
401
+ """Encode image file to base64 string."""
402
+ with open(image_path, "rb") as img_file:
403
+ return base64.b64encode(img_file.read()).decode('utf-8')
404
+
405
+
406
+ def _pdf_page_to_base64(pdf_path: Path, page_num: int) -> str:
407
+ """Convert a PDF page to base64-encoded image."""
408
+ _require_ocr_dependencies()
409
+ with pdfplumber.open(pdf_path) as pdf:
410
+ if page_num < 1 or page_num > len(pdf.pages):
411
+ raise ValueError(f"Page {page_num} out of range (1-{len(pdf.pages)})")
412
+ page = pdf.pages[page_num - 1]
413
+ page_image = page.to_image(resolution=200).original
414
+ from io import BytesIO
415
+ buffer = BytesIO()
416
+ page_image.save(buffer, format="PNG")
417
+ return base64.b64encode(buffer.getvalue()).decode('utf-8')
418
+
419
+
420
+ def _get_image_mime(path: Path) -> str:
421
+ """Return MIME type for an image file."""
422
+ ext = path.suffix.lower()
423
+ return {
424
+ ".png": "image/png",
425
+ ".jpg": "image/jpeg",
426
+ ".jpeg": "image/jpeg",
427
+ ".bmp": "image/bmp",
428
+ ".tif": "image/tiff",
429
+ ".tiff": "image/tiff",
430
+ }.get(ext, "image/png")
431
+
432
+
433
+ @server.tool()
434
+ def list_documents(
435
+ root: Optional[str] = None,
436
+ pattern: str = "**/*",
437
+ limit: int = 100,
438
+ ) -> List[Dict[str, Any]]:
439
+ """List documents under the configured root directory."""
440
+ base = Path(root).resolve() if root else DEFAULT_ROOT.resolve()
441
+ results: List[Dict[str, Any]] = []
442
+ for path in base.glob(pattern):
443
+ if path.is_file() and path.suffix.lower() in ALLOWED_EXTENSIONS:
444
+ results.append({
445
+ "path": str(path),
446
+ "size_bytes": path.stat().st_size,
447
+ "suffix": path.suffix,
448
+ })
449
+ if len(results) >= limit:
450
+ break
451
+ return results
452
+
453
+
454
+ @server.tool()
455
+ def document_info(file_path: str) -> Dict[str, Any]:
456
+ """Return metadata about a specific document."""
457
+ path = _resolve_path(file_path)
458
+ info = path.stat()
459
+ response: Dict[str, Any] = {
460
+ "path": str(path),
461
+ "size_bytes": info.st_size,
462
+ "modified": info.st_mtime,
463
+ "extension": path.suffix,
464
+ }
465
+ if path.suffix.lower() in {".xlsx", ".xls"}:
466
+ try:
467
+ sheets = pd.ExcelFile(path).sheet_names
468
+ except Exception as exc: # pragma: no cover
469
+ sheets = [f"Error: {exc}"]
470
+ response["sheets"] = sheets
471
+ return response
472
+
473
+
474
+ @server.tool()
475
+ def read_document(
476
+ file_path: str,
477
+ max_chars: int = 8000,
478
+ page: int = 1,
479
+ max_pages: int = 2,
480
+ sheet: Optional[str] = None,
481
+ start_row: int = 0,
482
+ num_rows: int = 50,
483
+ include_tables: bool = False,
484
+ ) -> str:
485
+ """Read a portion of a document (PDF, Excel, CSV, JSON, TXT, DOCX).
486
+
487
+ IMPORTANT: When presenting results to users, ALWAYS mention:
488
+ - The folder/directory path (e.g., 'Well 1/Well Test/')
489
+ - The complete document name
490
+ This helps users understand which well and document type is being referenced.
491
+ """
492
+ path = _resolve_path(file_path)
493
+ suffix = path.suffix.lower()
494
+
495
+ # Add context header with folder and filename
496
+ folder_path = str(path.parent.relative_to(DEFAULT_ROOT)) if path.is_relative_to(DEFAULT_ROOT) else str(path.parent)
497
+ context_header = f"📁 Document: {folder_path}/{path.name}\n" + "="*80 + "\n\n"
498
+
499
+ if suffix in {".txt", ".md"}:
500
+ return context_header + _read_text_file(path, max_chars)
501
+ if suffix == ".json":
502
+ return context_header + _read_json_file(path, max_chars)
503
+ if suffix in {".csv", ".tsv"}:
504
+ sep = "\t" if suffix == ".tsv" else ","
505
+ return context_header + _read_csv_file(path, num_rows, sep)
506
+ if suffix in {".xlsx", ".xls"}:
507
+ return context_header + _read_excel_file(path, sheet, start_row, num_rows)
508
+ if suffix == ".pdf":
509
+ return context_header + _read_pdf_file(path, page, max_pages, max_chars)
510
+ if suffix == ".docx":
511
+ return context_header + _read_docx_file(path, max_chars, include_tables)
512
+ if suffix == ".pptx":
513
+ return context_header + _read_pptx_file(path, max_chars)
514
+
515
+ raise ValueError(f"Unsupported file type: {suffix}")
516
+
517
+
518
+ @server.tool()
519
+ def visual_evaluate_document(
520
+ file_path: str,
521
+ enable_ocr: bool = False,
522
+ page: int = 1,
523
+ max_pages: int = 1,
524
+ ocr_lang: str = "eng",
525
+ ) -> list:
526
+ """Extract visual content from a document (PDF pages or images) and return
527
+ it as inline images so the host AI can analyse charts, tables, and diagrams
528
+ directly. Optionally run local Tesseract OCR as well.
529
+
530
+ Returns a list of content blocks (text and/or base64 images) that the
531
+ calling AI model can interpret.
532
+ """
533
+ from mcp.types import TextContent, ImageContent
534
+
535
+ path = _resolve_path(file_path)
536
+ suffix = path.suffix.lower()
537
+
538
+ folder_path = (
539
+ str(path.parent.relative_to(DEFAULT_ROOT))
540
+ if path.is_relative_to(DEFAULT_ROOT)
541
+ else str(path.parent)
542
+ )
543
+
544
+ contents: list = []
545
+ contents.append(
546
+ TextContent(
547
+ type="text",
548
+ text=f"Document: {folder_path}/{path.name}",
549
+ )
550
+ )
551
+
552
+ # ---- Return images so the host AI can see them ----
553
+ if suffix in IMAGE_EXTENSIONS:
554
+ mime = _get_image_mime(path)
555
+ b64 = _encode_image_base64(path)
556
+ contents.append(
557
+ ImageContent(type="image", data=b64, mimeType=mime)
558
+ )
559
+ elif suffix == ".pdf":
560
+ try:
561
+ with pdfplumber.open(path) as pdf:
562
+ total = len(pdf.pages)
563
+ start = max(page - 1, 0)
564
+ end = min(start + max_pages, total)
565
+ for idx in range(start, end):
566
+ page_b64 = _pdf_page_to_base64(path, idx + 1)
567
+ contents.append(
568
+ TextContent(type="text", text=f"--- Page {idx + 1}/{total} ---")
569
+ )
570
+ contents.append(
571
+ ImageContent(type="image", data=page_b64, mimeType="image/png")
572
+ )
573
+ except Exception as exc:
574
+ contents.append(
575
+ TextContent(type="text", text=f"Failed to render PDF pages: {exc}")
576
+ )
577
+
578
+ # Also include pdfplumber extracted text/tables as supplementary data
579
+ try:
580
+ with pdfplumber.open(path) as pdf:
581
+ total = len(pdf.pages)
582
+ start = max(page - 1, 0)
583
+ end = min(start + max_pages, total)
584
+ for idx in range(start, end):
585
+ pg = pdf.pages[idx]
586
+ text = pg.extract_text() or ""
587
+ tables = pg.extract_tables()
588
+ parts = [f"Page {idx + 1} extracted text:\n{text}"]
589
+ for ti, table in enumerate(tables, 1):
590
+ rows = "\n".join(
591
+ " | ".join(str(c or "") for c in row) for row in table
592
+ )
593
+ parts.append(f"Table {ti}:\n{rows}")
594
+ contents.append(
595
+ TextContent(type="text", text="\n\n".join(parts))
596
+ )
597
+ except Exception:
598
+ pass
599
+ else:
600
+ contents.append(
601
+ TextContent(
602
+ type="text",
603
+ text=f"Visual extraction not supported for {suffix}; use read_document instead.",
604
+ )
605
+ )
606
+
607
+ # ---- Optional local OCR ----
608
+ if enable_ocr:
609
+ try:
610
+ if suffix == ".pdf":
611
+ ocr_text = _ocr_pdf_pages(path, page, max_pages, ocr_lang)
612
+ elif suffix in IMAGE_EXTENSIONS:
613
+ ocr_text = _ocr_image(path, ocr_lang)
614
+ else:
615
+ ocr_text = f"OCR not supported for {suffix}"
616
+ contents.append(
617
+ TextContent(type="text", text=f"OCR result:\n{ocr_text}")
618
+ )
619
+ except Exception as exc:
620
+ contents.append(
621
+ TextContent(type="text", text=f"OCR failed: {exc}")
622
+ )
623
+
624
+ return contents
625
+
626
+
627
+ if __name__ == "__main__":
628
+ server.run()
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "docalyze-mcp-server"
7
+ version = "0.1.0"
8
+ description = "MCP server for reading and visually analyzing local documents (PDF, Excel, CSV, Word, PowerPoint, images). No API keys required — works with GitHub Copilot and any MCP-compatible AI host."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Juan Esteban Mosquera" }]
13
+ keywords = ["mcp", "docalyze", "documents", "pdf", "excel", "copilot", "vscode"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Topic :: Text Processing :: General",
19
+ ]
20
+ dependencies = [
21
+ "mcp>=1.0.0",
22
+ "pandas>=2.0",
23
+ "pdfplumber>=0.10",
24
+ "openpyxl>=3.1",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ docx = ["python-docx>=1.0"]
29
+ pptx = ["python-pptx>=0.6"]
30
+ ocr = ["pytesseract>=0.3", "Pillow>=10.0"]
31
+ all = [
32
+ "python-docx>=1.0",
33
+ "python-pptx>=0.6",
34
+ "pytesseract>=0.3",
35
+ "Pillow>=10.0",
36
+ ]
37
+
38
+ [project.scripts]
39
+ docalyze-mcp-server = "docalyze_mcp_server:server.run"
40
+
41
+ [project.urls]
42
+ Repository = "https://github.com/LunarPerovskite/docalyze-mcp-server"
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.LunarPerovskite/docalyze",
4
+ "description": "Read and analyze local documents (PDF, Excel, CSV, Word, PowerPoint, images). No API keys needed.",
5
+ "repository": {
6
+ "url": "https://github.com/LunarPerovskite/docalyze-mcp-server",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "pypi",
13
+ "identifier": "docalyze-mcp-server",
14
+ "version": "0.1.0",
15
+ "transport": {
16
+ "type": "stdio"
17
+ }
18
+ }
19
+ ]
20
+ }