#
tokens: 8969/50000 9/9 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .env
├── .gitignore
├── .python-version
├── Dockerfile
├── images
│   ├── input.png
│   └── output.png
├── LICENSE
├── pyproject.toml
├── README_CN.md
├── README.md
├── smithery.yaml
├── src
│   └── pdf2md
│       ├── __init__.py
│       ├── __main__.py
│       └── server.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
1 | 3.10
2 | 
```

--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------

```
1 | MINERU_API_BASE = "https://mineru.net/api/v4/extract/task"
2 | MINERU_API_KEY = "Your API_KEY"
3 | MINERU_FILE_URLS_API = "https://mineru.net/api/v4/file-urls/batch"
4 | MINERU_BATCH_API = "https://mineru.net/api/v4/extract/task/batch"
5 | MINERU_BATCH_RESULTS_API = "https://mineru.net/api/v4/extract-results/batch"
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # MCP-PDF2MD
  2 | 
  3 | [![smithery badge](https://smithery.ai/badge/@FutureUnreal/mcp-pdf2md)](https://smithery.ai/server/@FutureUnreal/mcp-pdf2md)
  4 | [English](#pdf2md-service) | [中文](README_CN.md)
  5 | 
  6 | # MCP-PDF2MD Service
  7 | 
  8 | An MCP-based high-performance PDF to Markdown conversion service powered by MinerU API, supporting batch processing for local files and URL links with structured output.
  9 | 
 10 | ## Key Features
 11 | 
 12 | - Format Conversion: Convert PDF files to structured Markdown format.
 13 | - Multi-source Support: Process both local PDF files and URL links.
 14 | - Intelligent Processing: Automatically select the best processing method.
 15 | - Batch Processing: Support multi-file batch conversion for efficient handling of large volumes of PDF files.
 16 | - MCP Integration: Seamless integration with LLM clients like Claude Desktop.
 17 | - Structure Preservation: Maintain the original document structure, including headings, paragraphs, lists, etc.
 18 | - Smart Layout: Output text in human-readable order, suitable for single-column, multi-column, and complex layouts.
 19 | - Formula Conversion: Automatically recognize and convert formulas in the document to LaTeX format.
 20 | - Table Extraction: Automatically recognize and convert tables in the document to structured format.
 21 | - Cleanup Optimization: Remove headers, footers, footnotes, page numbers, etc., to ensure semantic coherence.
 22 | - High-Quality Extraction: High-quality extraction of text, images, and layout information from PDF documents.
 23 | 
 24 | ## System Requirements
 25 | 
 26 | - Software: Python 3.10+
 27 | 
 28 | ## Quick Start
 29 | 
 30 | 1. Clone the repository and enter the directory:
 31 |    ```bash
 32 |    git clone https://github.com/FutureUnreal/mcp-pdf2md.git
 33 |    cd mcp-pdf2md
 34 |    ```
 35 | 
 36 | 2. Create a virtual environment and install dependencies:
 37 |    
 38 |    **Linux/macOS**:
 39 |    ```bash
 40 |    uv venv
 41 |    source .venv/bin/activate
 42 |    uv pip install -e .
 43 |    ```
 44 |    
 45 |    **Windows**:
 46 |    ```bash
 47 |    uv venv
 48 |    .venv\Scripts\activate
 49 |    uv pip install -e .
 50 |    ```
 51 | 
 52 | 3. Configure environment variables:
 53 | 
 54 |    Create a `.env` file in the project root directory and set the following environment variables:
 55 |    ```
 56 |    MINERU_API_BASE=https://mineru.net/api/v4/extract/task
 57 |    MINERU_BATCH_API=https://mineru.net/api/v4/extract/task/batch
 58 |    MINERU_BATCH_RESULTS_API=https://mineru.net/api/v4/extract-results/batch
 59 |    MINERU_API_KEY=your_api_key_here
 60 |    ```
 61 | 
 62 | 4. Start the service:
 63 |    ```bash
 64 |    uv run pdf2md
 65 |    ```
 66 | 
 67 | ## Command Line Arguments
 68 | 
 69 | The server supports the following command line arguments:
 70 | 
 71 | ## Claude Desktop Configuration
 72 | 
 73 | Add the following configuration in Claude Desktop:
 74 | 
 75 | **Windows**:
 76 | ```json
 77 | {
 78 |     "mcpServers": {
 79 |         "pdf2md": {
 80 |             "command": "uv",
 81 |             "args": [
 82 |                 "--directory",
 83 |                 "C:\\path\\to\\mcp-pdf2md",
 84 |                 "run",
 85 |                 "pdf2md",
 86 |                 "--output-dir",
 87 |                 "C:\\path\\to\\output"
 88 |             ],
 89 |             "env": {
 90 |                 "MINERU_API_KEY": "your_api_key_here"
 91 |             }
 92 |         }
 93 |     }
 94 | }
 95 | ```
 96 | 
 97 | **Linux/macOS**:
 98 | ```json
 99 | {
100 |     "mcpServers": {
101 |         "pdf2md": {
102 |             "command": "uv",
103 |             "args": [
104 |                 "--directory",
105 |                 "/path/to/mcp-pdf2md",
106 |                 "run",
107 |                 "pdf2md",
108 |                 "--output-dir",
109 |                 "/path/to/output"
110 |             ],
111 |             "env": {
112 |                 "MINERU_API_KEY": "your_api_key_here"
113 |             }
114 |         }
115 |     }
116 | }
117 | ```
118 | 
119 | **Note about API Key Configuration:**
120 | You can set the API key in two ways:
121 | 1. In the `.env` file within the project directory (recommended for development)
122 | 2. In the Claude Desktop configuration as shown above (recommended for regular use)
123 | 
124 | If you set the API key in both places, the one in the Claude Desktop configuration will take precedence.
125 | 
126 | ## MCP Tools
127 | 
128 | The server provides the following MCP tools:
129 | 
130 | - **convert_pdf_url**: Convert PDF URL to Markdown
131 | - **convert_pdf_file**: Convert local PDF file to Markdown
132 | 
133 | ## Getting MinerU API Key
134 | 
135 | This project relies on the MinerU API for PDF content extraction. To obtain an API key:
136 | 
137 | 1. Visit [MinerU official website](https://mineru.net/) and register for an account
138 | 2. After logging in, apply for API testing qualification at [this link](https://mineru.net/apiManage/docs?openApplyModal=true)
139 | 3. Once your application is approved, you can access the [API Management](https://mineru.net/apiManage/token) page
140 | 4. Generate your API key following the instructions provided
141 | 5. Copy the generated API key
142 | 6. Use this string as the value for `MINERU_API_KEY`
143 | 
144 | Note that access to the MinerU API is currently in testing phase and requires approval from the MinerU team. The approval process may take some time, so plan accordingly.
145 | 
146 | ## Demo
147 | 
148 | ### Input PDF
149 | ![Input PDF](images/input.png)
150 | 
151 | ### Output Markdown
152 | ![Output Markdown](images/output.png)
153 | 
154 | ## License
155 | 
156 | MIT License - see the LICENSE file for details.
157 | 
158 | ## Credits
159 | 
160 | This project is based on the API from [MinerU](https://github.com/opendatalab/MinerU/tree/master).
161 | 
```

--------------------------------------------------------------------------------
/src/pdf2md/__main__.py:
--------------------------------------------------------------------------------

```python
1 | """
2 | PDF to Markdown Conversion Service Startup Script
3 | """
4 | import asyncio
5 | from . import main
6 | 
7 | if __name__ == "__main__":
8 |     main()
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "pdf2md"
 3 | version = "0.1.0"
 4 | description = "PDF to Markdown MCP服务器"
 5 | readme = "README.md"
 6 | requires-python = ">=3.10"
 7 | dependencies = [
 8 |     "httpx>=0.28.1",
 9 |     "mcp[cli]>=1.4.1",
10 |     "python-dotenv>=1.0.0",
11 |     "asyncio>=3.4.3",
12 |     "pathlib>=1.0.1",
13 |     "typer>=0.9.0",
14 | ]
15 | 
16 | [project.optional-dependencies]
17 | dev = [
18 |     "pytest>=7.0.0",
19 | ]
20 | 
21 | [project.scripts]
22 | pdf2md = "pdf2md:main"
23 | 
24 | [build-system]
25 | requires = ["setuptools>=61.0"]
26 | build-backend = "setuptools.build_meta"
27 | 
28 | [tool.hatch.build.targets.wheel]
29 | packages = ["src/pdf2md"]
30 | 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
 2 | FROM python:3.10-slim
 3 | 
 4 | # Install system dependencies if needed
 5 | RUN apt-get update && apt-get install -y --no-install-recommends \
 6 |     build-essential \
 7 |     && rm -rf /var/lib/apt/lists/*
 8 | 
 9 | WORKDIR /app
10 | 
11 | # Copy the entire project to /app
12 | COPY . /app
13 | 
14 | # Upgrade pip and install the package in editable mode
15 | RUN pip install --upgrade pip
16 | RUN pip install -e .
17 | 
18 | # Expose any necessary ports if applicable (none required for stdio based MCP)
19 | 
20 | # Default command to run the MCP server using python module entrypoint
21 | CMD ["python", "-m", "./src/pdf2md"]
22 | 
```

--------------------------------------------------------------------------------
/src/pdf2md/__init__.py:
--------------------------------------------------------------------------------

```python
 1 | from .server import mcp
 2 | 
 3 | def main():
 4 |     """PDF to Markdown Conversion Service - Provides MCP service for converting PDF files to Markdown"""
 5 |     import os
 6 |     import argparse
 7 |     
 8 |     # Parse command line arguments
 9 |     parser = argparse.ArgumentParser(description="PDF to Markdown Conversion Service")
10 |     parser.add_argument("--output-dir", default="./downloads", help="Specify output directory path, default is ./downloads")
11 |     args = parser.parse_args()
12 |     
13 |     # Set output directory
14 |     from .server import set_output_dir
15 |     set_output_dir(args.output_dir)
16 |     
17 |     # Check API key
18 |     from .server import MINERU_API_KEY, logger
19 |     if not MINERU_API_KEY:
20 |         logger.warning("Warning: API key not set, please set the MINERU_API_KEY environment variable")
21 |     
22 |     # Run MCP server
23 |     mcp.run()
24 | 
25 | __all__ = ['main']
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
 2 | 
 3 | startCommand:
 4 |   type: stdio
 5 |   configSchema:
 6 |     # JSON Schema defining the configuration options for the MCP.
 7 |     type: object
 8 |     required:
 9 |       - mineruApiKey
10 |     properties:
11 |       mineruApiKey:
12 |         type: string
13 |         description: API key for MinerU. Must be provided as a Bearer token without the
14 |           'Bearer ' prefix.
15 |       outputDir:
16 |         type: string
17 |         default: ./downloads
18 |         description: Directory where the converted markdown files will be stored.
19 |   commandFunction:
20 |     # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
21 |     |-
22 |     (config) => ({
23 |       command: 'python',
24 |       args: ['-m', './src/pdf2md', '--output-dir', config.outputDir],
25 |       env: { MINERU_API_KEY: config.mineruApiKey }
26 |     })
27 |   exampleConfig:
28 |     mineruApiKey: your_mineru_api_key_here
29 |     outputDir: ./downloads
30 | 
```

--------------------------------------------------------------------------------
/src/pdf2md/server.py:
--------------------------------------------------------------------------------

```python
  1 | import os
  2 | import json
  3 | import time
  4 | import asyncio
  5 | import httpx
  6 | import re
  7 | import logging
  8 | from pathlib import Path
  9 | from dotenv import load_dotenv
 10 | from typing import Optional, List, Dict, Any
 11 | from mcp.server.fastmcp import FastMCP
 12 | 
 13 | # Set up logging - disable all log output
 14 | logging.basicConfig(
 15 |     level=logging.CRITICAL,  # Only record critical errors
 16 |     format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
 17 |     handlers=[]  # Remove all handlers, no log output
 18 | )
 19 | logger = logging.getLogger("pdf2md")
 20 | logger.disabled = True  # Completely disable logging
 21 | 
 22 | # Load environment variables
 23 | load_dotenv()
 24 | 
 25 | # API configuration
 26 | MINERU_API_BASE = os.environ.get("MINERU_API_BASE", "https://mineru.net/api/v4/extract/task")
 27 | MINERU_API_KEY = os.environ.get("MINERU_API_KEY", "")
 28 | MINERU_BATCH_API = os.environ.get("MINERU_BATCH_API", "https://mineru.net/api/v4/extract/task/batch")
 29 | MINERU_BATCH_RESULTS_API = os.environ.get("MINERU_BATCH_RESULTS_API", "https://mineru.net/api/v4/extract-results/batch")
 30 | MINERU_FILE_URLS_API = os.environ.get("MINERU_FILE_URLS_API", "https://mineru.net/api/v4/file-urls/batch")
 31 | 
 32 | # Global variables
 33 | OUTPUT_DIR = "./downloads"
 34 | 
 35 | # API authentication headers
 36 | HEADERS = {
 37 |     "Authorization": MINERU_API_KEY if MINERU_API_KEY.startswith("Bearer ") else f"Bearer {MINERU_API_KEY}", 
 38 |     "Content-Type": "application/json"
 39 | }
 40 | 
 41 | def set_output_dir(output_dir: str):
 42 |     """Set the output directory path"""
 43 |     global OUTPUT_DIR
 44 |     # Normalize path to handle Unicode characters properly
 45 |     OUTPUT_DIR = os.path.normpath(output_dir)
 46 | 
 47 | def print_task_status(extract_results):
 48 |     """
 49 |     Print task status and check if all tasks are completed
 50 |     
 51 |     Args:
 52 |         extract_results: List of task results
 53 |         
 54 |     Returns:
 55 |         tuple: (all tasks completed, any task completed)
 56 |     """
 57 |     all_done = True
 58 |     any_done = False
 59 |     
 60 |     for i, result in enumerate(extract_results):
 61 |         current_status = result.get("state", "")
 62 |         file_name = result.get("file_name", "")
 63 |         
 64 |         status_icon = "✅" if current_status == "done" else "⏳"
 65 |         
 66 |         if current_status == "done":
 67 |             any_done = True
 68 |         else:
 69 |             all_done = False
 70 |     
 71 |     return all_done, any_done
 72 | 
 73 | async def check_task_status(client, batch_id, max_retries=60, sleep_seconds=5):
 74 |     """
 75 |     Check batch task status
 76 |     
 77 |     Args:
 78 |         client: HTTP client
 79 |         batch_id: Batch ID
 80 |         max_retries: Maximum number of retries
 81 |         sleep_seconds: Seconds between retries
 82 |         
 83 |     Returns:
 84 |         dict: Dictionary containing task status information, or error message if failed
 85 |     """
 86 |     retry_count = 0
 87 |     
 88 |     while retry_count < max_retries:
 89 |         retry_count += 1
 90 |         
 91 |         try:
 92 |             status_response = await client.get(
 93 |                 f"{MINERU_BATCH_RESULTS_API}/{batch_id}",
 94 |                 headers=HEADERS,
 95 |                 timeout=60.0  
 96 |             )
 97 |             
 98 |             if status_response.status_code != 200:
 99 |                 retry_count += 1
100 |                 if retry_count < max_retries:
101 |                     await asyncio.sleep(sleep_seconds)
102 |                 continue
103 |             
104 |             try:
105 |                 status_data = status_response.json()
106 |             except Exception as e:
107 |                 retry_count += 1
108 |                 if retry_count < max_retries:
109 |                     await asyncio.sleep(sleep_seconds)
110 |                 continue
111 |             
112 |             task_data = status_data.get("data", {})
113 |             extract_results = task_data.get("extract_result", [])
114 |             
115 |             all_done, any_done = print_task_status(extract_results)
116 |             
117 |             if all_done:
118 |                 return {
119 |                     "success": True,
120 |                     "extract_results": extract_results,
121 |                     "task_data": task_data,
122 |                     "status_data": status_data
123 |                 }
124 |             
125 |             await asyncio.sleep(sleep_seconds)
126 |             
127 |         except Exception as e:
128 |             retry_count += 1
129 |             if retry_count < max_retries:
130 |                 await asyncio.sleep(sleep_seconds)
131 |     
132 |     return {
133 |         "success": False,
134 |         "error": "Polling timeout, unable to get final results"
135 |     }
136 | 
137 | async def download_batch_results(client, extract_results):
138 |     """
139 |     Download batch task results
140 |     
141 |     Args:
142 |         client: HTTP client
143 |         extract_results: List of task results
144 |         
145 |     Returns:
146 |         list: List of downloaded file information
147 |     """
148 |     downloaded_files = []
149 |     
150 |     for i, result in enumerate(extract_results):
151 |         if result.get("state") == "done":
152 |             try:
153 |                 file_name = result.get("file_name", f"file_{i+1}")
154 |                 zip_url = result.get("full_zip_url", "")
155 |                 
156 |                 if not zip_url:
157 |                     continue
158 |                 
159 |                 downloaded_file = await download_zip_file(client, zip_url, file_name)
160 |                 if downloaded_file:
161 |                     downloaded_files.append(downloaded_file)
162 |             except Exception as e:
163 |                 pass
164 |     
165 |     return downloaded_files
166 | 
167 | async def download_zip_file(client, zip_url, file_name, prefix="md", max_retries=3):
168 |     """
169 |     Download and save ZIP file, then automatically unzip
170 |     
171 |     Args:
172 |         client: HTTP client
173 |         zip_url: ZIP file URL
174 |         file_name: File name
175 |         prefix: File prefix
176 |         max_retries: Maximum number of retries
177 |         
178 |     Returns:
179 |         dict: Dictionary containing file name and unzip directory, or None if failed
180 |     """
181 |     retry_count = 0
182 |     
183 |     while retry_count < max_retries:
184 |         try:
185 |             zip_response = await client.get(zip_url, follow_redirects=True, timeout=120.0)
186 |             
187 |             if zip_response.status_code == 200:
188 |                 current_date = time.strftime("%Y%m%d")
189 |                 
190 |                 base_name = os.path.splitext(file_name)[0]
191 |                 # Only remove control characters and chars that are invalid in filenames
192 |                 safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '', base_name).strip()
193 |                 # Replace spaces with underscores
194 |                 safe_name = re.sub(r'\s+', '_', safe_name)
195 |                 
196 |                 if safe_name.isdigit() or re.match(r'^\d+\.\d+$', safe_name):
197 |                     safe_name = f"paper_{safe_name}"
198 |                     
199 |                 zip_filename = f"{prefix}_{safe_name}_{current_date}.zip"
200 |                 
201 |                 download_dir = Path(OUTPUT_DIR)
202 |                 if not download_dir.exists():
203 |                     try:
204 |                         download_dir.mkdir(parents=True, exist_ok=True)
205 |                     except Exception as e:
206 |                         print(f"Error creating directory: {e}")
207 |                         return None
208 |                 
209 |                 save_path = download_dir / zip_filename
210 |                 
211 |                 with open(save_path, "wb") as f:
212 |                     f.write(zip_response.content)
213 |                 
214 |                 extract_dir = download_dir / safe_name
215 |                 if not extract_dir.exists():
216 |                     extract_dir.mkdir(parents=True)
217 |                 
218 |                 import zipfile
219 |                 try:
220 |                     with zipfile.ZipFile(save_path, 'r') as zip_ref:
221 |                         zip_ref.extractall(extract_dir)
222 |                     os.remove(save_path)
223 |                     
224 |                     return {
225 |                         "file_name": file_name,
226 |                         "extract_dir": str(extract_dir)
227 |                     }
228 |                 except Exception as e:
229 |                     pass
230 |             else:
231 |                 retry_count += 1
232 |                 if retry_count < max_retries:
233 |                     await asyncio.sleep(2)
234 |                 continue
235 |         except Exception as e:
236 |             retry_count += 1
237 |             if retry_count < max_retries:
238 |                 await asyncio.sleep(2)
239 |             continue
240 |     
241 |     return None
242 | 
243 | def parse_url_string(url_string):
244 |     """
245 |     Parse URL string separated by spaces, commas, or newlines
246 |     
247 |     Args:
248 |         url_string: URL string
249 |         
250 |     Returns:
251 |         list: List of URLs
252 |     """
253 |     if isinstance(url_string, str):
254 |         if (url_string.startswith('"') and url_string.endswith('"')) or \
255 |            (url_string.startswith("'") and url_string.endswith("'")):
256 |             url_string = url_string[1:-1]
257 |     
258 |     urls = []
259 |     for part in url_string.split():
260 |         if ',' in part:
261 |             urls.extend(part.split(','))
262 |         elif '\n' in part:
263 |             urls.extend(part.split('\n'))
264 |         else:
265 |             urls.append(part)
266 |     
267 |     cleaned_urls = []
268 |     for url in urls:
269 |         if (url.startswith('"') and url.endswith('"')) or \
270 |            (url.startswith("'") and url.endswith("'")):
271 |             cleaned_urls.append(url[1:-1])
272 |         else:
273 |             cleaned_urls.append(url)
274 |     
275 |     return cleaned_urls
276 | 
277 | def parse_path_string(path_string):
278 |     """
279 |     Parse file path string separated by spaces, commas, or newlines
280 |     
281 |     Args:
282 |         path_string: File path string
283 |         
284 |     Returns:
285 |         list: List of file paths
286 |     """
287 |     if isinstance(path_string, str):
288 |         if (path_string.startswith('"') and path_string.endswith('"')) or \
289 |            (path_string.startswith("'") and path_string.endswith("'")):
290 |             path_string = path_string[1:-1]
291 |     
292 |     paths = []
293 |     for part in path_string.split():
294 |         if ',' in part:
295 |             paths.extend(part.split(','))
296 |         elif '\n' in part:
297 |             paths.extend(part.split('\n'))
298 |         else:
299 |             paths.append(part)
300 |     
301 |     cleaned_paths = []
302 |     for path in paths:
303 |         if (path.startswith('"') and path.endswith('"')) or \
304 |            (path.startswith("'") and path.endswith("'")):
305 |             cleaned_paths.append(path[1:-1])
306 |         else:
307 |             cleaned_paths.append(path)
308 |     
309 |     return cleaned_paths
310 | 
311 | # Create MCP server
312 | mcp = FastMCP("PDF to Markdown Conversion Service")
313 | 
314 | @mcp.tool()  
315 | async def convert_pdf_url(url: str, enable_ocr: bool = True) -> Dict[str, Any]:
316 |     """
317 |     Convert PDF URL to Markdown, supports single URL or URL list
318 |     
319 |     Args:
320 |         url: PDF file URL or URL list, can be separated by spaces, commas, or newlines
321 |         enable_ocr: Whether to enable OCR (default: True)
322 | 
323 |     Returns:
324 |         dict: Conversion result information
325 |     """
326 |     if not MINERU_API_KEY:
327 |         return {"success": False, "error": "Missing API key, please set environment variable MINERU_API_KEY"}
328 |     
329 |     if isinstance(url, str):
330 |         urls = parse_url_string(url)
331 |     else:
332 |         urls = [url]  
333 |     
334 |     async with httpx.AsyncClient(timeout=300.0) as client:
335 |         try:
336 |             files = []
337 |             for i, url_item in enumerate(urls):
338 |                 files.append({
339 |                     "url": url_item, 
340 |                     "is_ocr": enable_ocr, 
341 |                     "data_id": f"url_convert_{i+1}_{int(time.time())}"
342 |                 })
343 |             
344 |             batch_data = {
345 |                 "enable_formula": True,
346 |                 "language": "auto",
347 |                 "layout_model": "doclayout_yolo",
348 |                 "enable_table": True,
349 |                 "files": files
350 |             }
351 |             
352 |             response = await client.post(
353 |                 MINERU_BATCH_API,
354 |                 headers=HEADERS,
355 |                 json=batch_data,
356 |                 timeout=300.0
357 |             )
358 |             
359 |             if response.status_code != 200:
360 |                 return {"success": False, "error": f"Request failed: {response.status_code}"}
361 |             
362 |             try:
363 |                 status_data = response.json()
364 |                 
365 |                 if status_data.get("code") != 0 and status_data.get("code") != 200:
366 |                     error_msg = status_data.get("msg", "Unknown error")
367 |                     return {"success": False, "error": f"API returned error: {error_msg}"}
368 |                     
369 |                 batch_id = status_data.get("data", {}).get("batch_id", "")
370 |                 if not batch_id:
371 |                     return {"success": False, "error": "Failed to get batch ID"}
372 |                 
373 |                 task_status = await check_task_status(client, batch_id)
374 |                 
375 |                 if not task_status.get("success"):
376 |                     return task_status
377 |                 
378 |                 downloaded_files = await download_batch_results(client, task_status.get("extract_results", []))
379 |                 
380 |                 return {
381 |                     "success": True, 
382 |                     "downloaded_files": downloaded_files,
383 |                     "batch_id": batch_id,
384 |                     "total_urls": len(urls),
385 |                     "processed_urls": len(downloaded_files)
386 |                 }
387 |                 
388 |             except json.JSONDecodeError as e:
389 |                 return {"success": False, "error": f"Failed to parse JSON: {e}"}
390 |                 
391 |         except Exception as e:
392 |             return {"success": False, "error": str(e)}
393 | 
394 | @mcp.tool()  
395 | async def convert_pdf_file(file_path: str, enable_ocr: bool = True) -> Dict[str, Any]:
396 |     """
397 |     Convert local PDF file to Markdown, supports single file or file list
398 |     
399 |     Args:
400 |         file_path: PDF file local path or path list, can be separated by spaces, commas, or newlines
401 |         enable_ocr: Whether to enable OCR (default: True)
402 | 
403 |     Returns:
404 |         dict: Conversion result information
405 |     """
406 |     if not MINERU_API_KEY:
407 |         return {"success": False, "error": "Missing API key, please set environment variable MINERU_API_KEY"}
408 |     
409 |     if isinstance(file_path, str):
410 |         file_paths = parse_path_string(file_path)
411 |     else:
412 |         file_paths = [file_path]  
413 |     
414 |     for path in file_paths:
415 |         if not os.path.exists(path):
416 |             return {"success": False, "error": f"File does not exist: {path}"}
417 |         else:
418 |             if not path.lower().endswith('.pdf'):
419 |                 return {"success": False, "error": f"File is not in PDF format: {path}"}
420 |     
421 |     async with httpx.AsyncClient(timeout=300.0) as client:
422 |         try:
423 |             file_names = [os.path.basename(path) for path in file_paths]
424 |             
425 |             files_data = []
426 |             for i, name in enumerate(file_names):
427 |                 files_data.append({
428 |                     "name": name,
429 |                     "is_ocr": enable_ocr,
430 |                     "data_id": f"file_convert_{i+1}_{int(time.time())}"
431 |                 })
432 |             
433 |             file_url_data = {
434 |                 "enable_formula": True,
435 |                 "language": "auto",
436 |                 "layout_model": "doclayout_yolo",
437 |                 "enable_table": True,
438 |                 "files": files_data
439 |             }
440 |             
441 |             file_url_response = await client.post(
442 |                 MINERU_FILE_URLS_API,
443 |                 headers=HEADERS,
444 |                 json=file_url_data,
445 |                 timeout=60.0
446 |             )
447 |             
448 |             if file_url_response.status_code != 200:
449 |                 return {"success": False, "error": f"Failed to get upload link: {file_url_response.status_code}"}
450 |             
451 |             file_url_result = file_url_response.json()
452 |             
453 |             if file_url_result.get("code") != 0 and file_url_result.get("code") != 200:
454 |                 error_msg = file_url_result.get("msg", "Unknown error")
455 |                 return {"success": False, "error": f"Failed to get upload link: {error_msg}"}
456 |             
457 |             batch_id = file_url_result.get("data", {}).get("batch_id", "")
458 |             file_urls = file_url_result.get("data", {}).get("file_urls", [])
459 |             
460 |             if not batch_id or not file_urls or len(file_urls) != len(file_paths):
461 |                 return {"success": False, "error": "Failed to get upload link or batch ID"}
462 |             
463 |             upload_results = []
464 |             for i, (file_path, upload_url) in enumerate(zip(file_paths, file_urls)):
465 |                 try:
466 |                     with open(file_path, 'rb') as f:
467 |                         file_content = f.read()
468 |                         
469 |                         upload_response = await client.put(
470 |                             upload_url,
471 |                             content=file_content,
472 |                             headers={},  
473 |                             timeout=300.0
474 |                         )
475 |                     
476 |                     if upload_response.status_code != 200:
477 |                         upload_results.append({"file": file_names[i], "success": False})
478 |                     else:
479 |                         upload_results.append({"file": file_names[i], "success": True})
480 |                 except Exception as e:
481 |                     upload_results.append({"file": file_names[i], "success": False, "error": str(e)})
482 |             
483 |             if not any(result["success"] for result in upload_results):
484 |                 return {"success": False, "error": "All files failed to upload", "upload_results": upload_results}
485 |             
486 |             task_status = await check_task_status(client, batch_id)
487 |             
488 |             if not task_status.get("success"):
489 |                 return task_status
490 |             
491 |             downloaded_files = await download_batch_results(client, task_status.get("extract_results", []))
492 |             
493 |             return {
494 |                 "success": True, 
495 |                 "downloaded_files": downloaded_files,
496 |                 "batch_id": batch_id,
497 |                 "upload_results": upload_results,
498 |                 "total_files": len(file_paths),
499 |                 "processed_files": len(downloaded_files)
500 |             }
501 |                 
502 |         except Exception as e:
503 |             return {"success": False, "error": str(e)}
504 | 
505 | @mcp.prompt()
506 | def default_prompt() -> str:
507 |     """Create default tool usage prompt"""
508 |     return """
509 | PDF to Markdown Conversion Service provides two tools, each with different functions:
510 | 
511 | - convert_pdf_url: Specifically designed for handling single or multiple URL links
512 | - convert_pdf_file: Specifically designed for handling single or multiple local file paths
513 | 
514 | Please choose the appropriate tool based on the input type:
515 | - If it's a single or multiple URL, use convert_pdf_url
516 | - If it's a single or multiple local file, use convert_pdf_file
517 | - If it's a mix of URL and local file, please call the above two tools separately to handle the corresponding input
518 | 
519 | The converted Markdown files will be saved in the specified output directory, and the temporary downloaded ZIP files will be automatically deleted after unzipping to save space.
520 | """
521 | 
522 | @mcp.prompt()
523 | def pdf_prompt(path: str) -> str:
524 |     """Create PDF processing prompt"""
525 |     return f"""
526 | Please convert the following PDF to Markdown format:
527 | 
528 | {path}
529 | 
530 | Please choose the appropriate tool based on the input type:
531 | - If it's a single or multiple URL, use convert_pdf_url
532 | - If it's a single or multiple local file, use convert_pdf_file
533 | 
534 | The converted Markdown files will be saved in the specified output directory, and the temporary downloaded ZIP files will be automatically deleted after unzipping to save space.
535 | """
536 | 
537 | @mcp.resource("status://api")
538 | def get_api_status() -> str:
539 |     """Get API status information"""
540 |     if not MINERU_API_KEY:
541 |         return "API status: Not configured (missing API key)"
542 |     return f"API status: Configured\nAPI base URL: {MINERU_API_BASE}\nAPI key: {MINERU_API_KEY[:10]}..."
543 | 
544 | @mcp.resource("help://usage")
545 | def get_usage_help() -> str:
546 |     """Get tool usage help information"""
547 |     return """
548 | # PDF to Markdown Conversion Service
549 | 
550 | ## Available tools:
551 | 
552 | 1. **convert_pdf_url** - Convert PDF URL to Markdown, supports single or multiple URLs
553 |    - Parameters:
554 |      - url: PDF file URL or URL list, can be separated by spaces, commas, or newlines
555 |      - enable_ocr: Whether to enable OCR (default: True)
556 | 
557 | 2. **convert_pdf_file** - Convert local PDF file to Markdown, supports single or multiple file paths
558 |    - Parameters:
559 |      - file_path: PDF file local path or path list, can be separated by spaces, commas, or newlines
560 |      - enable_ocr: Whether to enable OCR (default: True)
561 | 
562 | ## Tool functions:
563 | 
564 | - **convert_pdf_url**: Specifically designed for handling URL links, suitable for single or multiple URL inputs
565 | - **convert_pdf_file**: Specifically designed for handling local files, suitable for single or multiple file path inputs
566 | 
567 | ## Mixed input handling:
568 | 
569 | When handling both URL and local file inputs, please call the above two tools separately to handle the corresponding input parts.
570 | 
571 | ## Usage examples:
572 | 
573 | ```python
574 | # Convert single URL
575 | result = await convert_pdf_url("https://example.com/document.pdf")
576 | 
577 | # Convert multiple URLs (batch processing)
578 | result = await convert_pdf_url('''
579 | https://example.com/document1.pdf
580 | https://example.com/document2.pdf
581 | https://example.com/document3.pdf
582 | ''')
583 | 
584 | # Convert multiple URLs with comma separation
585 | result = await convert_pdf_url("https://example.com/doc1.pdf, https://example.com/doc2.pdf")
586 | 
587 | # Convert single local file
588 | result = await convert_pdf_file("C:/Documents/document.pdf")
589 | 
590 | # Convert multiple local files (batch processing)
591 | result = await convert_pdf_file('''
592 | C:/Documents/document1.pdf
593 | C:/Documents/document2.pdf
594 | C:/Documents/document3.pdf
595 | ''')
596 | 
597 | # Mixed input handling (URLs and local files)
598 | url_result = await convert_pdf_url('''
599 | https://example.com/doc1.pdf
600 | https://example.com/doc2.pdf
601 | ''')
602 | file_result = await convert_pdf_file('''
603 | C:/Documents/doc1.pdf
604 | C:/Documents/doc2.pdf
605 | ''')
606 | ```
607 | 
608 | ## Conversion results:
609 | Successful conversion returns a dictionary containing conversion result information, and the converted Markdown files will be saved in the specified output directory, with temporary downloaded ZIP files automatically deleted after unzipping to save space.
610 | """
611 | 
612 | if __name__ == "__main__":
613 |     if not MINERU_API_KEY:
614 |         print("Warning: API key not set, please set environment variable MINERU_API_KEY")
615 |     
616 |     mcp.run()
617 | 
```