file-convert-mcp 0.1.2__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.
- file_convert_mcp-0.1.2/.github/workflows/publish.yml +30 -0
- file_convert_mcp-0.1.2/.gitignore +7 -0
- file_convert_mcp-0.1.2/Dockerfile +25 -0
- file_convert_mcp-0.1.2/LICENSE +21 -0
- file_convert_mcp-0.1.2/PKG-INFO +223 -0
- file_convert_mcp-0.1.2/README.md +206 -0
- file_convert_mcp-0.1.2/pyproject.toml +27 -0
- file_convert_mcp-0.1.2/smithery.yaml +36 -0
- file_convert_mcp-0.1.2/src/file_convert_mcp/__init__.py +3 -0
- file_convert_mcp-0.1.2/src/file_convert_mcp/server.py +254 -0
- file_convert_mcp-0.1.2/tests/test_server.py +172 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
publish:
|
|
14
|
+
name: Build and publish to PyPI
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Build package
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade build
|
|
27
|
+
python -m build
|
|
28
|
+
|
|
29
|
+
- name: Publish package to PyPI
|
|
30
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config
|
|
2
|
+
FROM python:3.12-alpine
|
|
3
|
+
|
|
4
|
+
# Install runtime and build dependencies
|
|
5
|
+
RUN apk add --no-cache file build-base libffi-dev openssl-dev
|
|
6
|
+
|
|
7
|
+
WORKDIR /app
|
|
8
|
+
|
|
9
|
+
# Copy project files
|
|
10
|
+
COPY pyproject.toml .
|
|
11
|
+
COPY src ./src
|
|
12
|
+
COPY LICENSE README.md ./
|
|
13
|
+
|
|
14
|
+
# Install package
|
|
15
|
+
RUN pip install --no-cache-dir .
|
|
16
|
+
|
|
17
|
+
# Expose port 8000 for HTTP transport
|
|
18
|
+
EXPOSE 8000
|
|
19
|
+
|
|
20
|
+
# Set default environment variables
|
|
21
|
+
ENV TRANSPORT=http
|
|
22
|
+
ENV PORT=8000
|
|
23
|
+
|
|
24
|
+
# Set default command
|
|
25
|
+
CMD ["file-convert-mcp"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 convert.guru
|
|
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,223 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: file-convert-mcp
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: MCP tool for file conversion and file format detection by Convert.Guru
|
|
5
|
+
Project-URL: Homepage, https://convert.guru
|
|
6
|
+
Project-URL: Repository, https://github.com/convertguru/file-convert-mcp
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: aiohttp
|
|
11
|
+
Requires-Dist: fastmcp<3,>=2.14.7
|
|
12
|
+
Requires-Dist: python-dotenv
|
|
13
|
+
Requires-Dist: python-magic-bin==0.4.14; sys_platform == 'win32'
|
|
14
|
+
Requires-Dist: python-magic; sys_platform != 'win32'
|
|
15
|
+
Requires-Dist: starlette
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# File Convert MCP Server
|
|
19
|
+
[](https://smithery.ai/server/@convertguru/file-convert-mcp)
|
|
20
|
+
|
|
21
|
+
A Model Context Protocol (MCP) server for converting files between various formats.
|
|
22
|
+
|
|
23
|
+
File Convert is an MCP server designed to handle the conversion of a wide array of file formats, including images, office documents, audio, video, text, and data files. It aims to provide seamless transformation into popular formats such as PDF, JPG, MP4, and HTML, among others. This server offers a set of powerful tools to convert diverse file types into easily accessible and widely compatible formats like PDF, JPG, PNG, TXT.
|
|
24
|
+
|
|
25
|
+
## ✨ Features
|
|
26
|
+
|
|
27
|
+
- **Intelligent File Type Detection:** Employs a combination of AI, TrID, and magic bytes for accurate file type identification.
|
|
28
|
+
- **Versatile File Conversion:** Supports conversion between numerous file types and the following popular formats:
|
|
29
|
+
- **Images:** Handles a vast range of formats, including many legacy ones. Converts to PDF, JPG, PNG.
|
|
30
|
+
- **Office Documents:** Converts to PDF, TXT, HTML.
|
|
31
|
+
- **Audio/Video:** Converts to MP3, MP4.
|
|
32
|
+
- **Databases:** Converts to CSV.
|
|
33
|
+
- **Various Files:** Offers conversion capabilities for other file types as well.
|
|
34
|
+
|
|
35
|
+
## 🛠️ Requirements
|
|
36
|
+
|
|
37
|
+
1. **Python:** Version 3.12 or higher is required.
|
|
38
|
+
2. **uv Tool:** Install the [Python uv tool](https://docs.astral.sh/uv/getting-started/installation/) for efficient dependency management and easy execution of the MCP server.
|
|
39
|
+
3. **API Key (Development):** As of May 2025, the MCP is in its testing phase, and **no API key is currently required** for development. For future production use, please [contact the Convert.Guru team](https://convert.guru/contact) to obtain an API key.
|
|
40
|
+
|
|
41
|
+
## 🚀 Usage with Desktop App
|
|
42
|
+
|
|
43
|
+
To integrate this server with your desktop application, add the following configuration to your app's server settings. If necessary, adjust the absolute path to the `uv`/`uvx` executables (or `uv.exe`/`uvx.exe` on Windows).
|
|
44
|
+
|
|
45
|
+
**Using `uvx` (recommended):**
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"file-convert": {
|
|
51
|
+
"command": "uvx",
|
|
52
|
+
"args": ["--from", "git+https://github.com/convertguru/file-convert-mcp.git", "file-convert-mcp"],
|
|
53
|
+
"env": {
|
|
54
|
+
"CONVERT_GURU_API_KEY": "your_api_key_here"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**If you have cloned the repository locally (adjust paths as needed):**
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"mcpServers": {
|
|
66
|
+
"file-convert": {
|
|
67
|
+
"command": "/home/User/.local/bin/uv",
|
|
68
|
+
"args": ["--directory", "/home/User/file-convert-mcp/src/file_convert_mcp", "run", "server.py"],
|
|
69
|
+
"env": {
|
|
70
|
+
"CONVERT_GURU_API_KEY": "your_api_key_here"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Using local `uvx`:**
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"file-convert": {
|
|
83
|
+
"command": "uvx",
|
|
84
|
+
"args": ["--from", "/home/User/file-convert-mcp", "file-convert-mcp"],
|
|
85
|
+
"env": {
|
|
86
|
+
"CONVERT_GURU_API_KEY": "your_api_key_here"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Environment Variables
|
|
94
|
+
|
|
95
|
+
The MCP server supports the following environment variables:
|
|
96
|
+
|
|
97
|
+
- **`CONVERT_GURU_API_KEY`**: Your Convert.Guru API key (optional for development)
|
|
98
|
+
- **`TRANSPORT`**: Transport method for the MCP server
|
|
99
|
+
- `"stdio"` (default): Standard input/output transport
|
|
100
|
+
- `"http"`: HTTP transport (streamable) with REST API endpoints
|
|
101
|
+
- **`PORT`**: Port number for HTTP transport (default: 8000, only used when `TRANSPORT=http`)
|
|
102
|
+
|
|
103
|
+
When using HTTP transport, the server provides additional endpoints:
|
|
104
|
+
- `GET /health` - Health check endpoint
|
|
105
|
+
- `GET /tools` - List of available MCP tools
|
|
106
|
+
|
|
107
|
+
Also, see this guide on how to [set up MCP tools in Claude Desktop](https://modelcontextprotocol.io/quickstart/user#for-claude-desktop-users).
|
|
108
|
+
|
|
109
|
+
### Installing via Smithery
|
|
110
|
+
|
|
111
|
+
To install file-convert-mcp for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@convertguru/file-convert-mcp):
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx -y @smithery/cli install @convertguru/file-convert-mcp --client claude
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 🛠️ Development
|
|
118
|
+
|
|
119
|
+
Get started with local development by following these steps:
|
|
120
|
+
|
|
121
|
+
**1. Clone the repository:**
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
git clone https://github.com/convertguru/file-convert-mcp.git
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**2. Fetch and cache Python dependencies (optional, `uvx` handles this automatically):**
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cd file-convert-mcp
|
|
131
|
+
uv sync
|
|
132
|
+
cd ..
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**3. Create .env file with your (optional for now) API key:**
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
echo "CONVERT_GURU_API_KEY=your_api_key_here" > file-convert-mcp/.env
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**4. Run the MCP server locally using various `uv` commands:**
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Using uvx with .env file from the local directory
|
|
145
|
+
cd file-convert-mcp
|
|
146
|
+
UV_ENV_FILE=.env uvx --from ./file-convert-mcp file-convert-mcp
|
|
147
|
+
|
|
148
|
+
# OR using uv directly to run the server script
|
|
149
|
+
uv run file-convert-mcp/src/file_convert_mcp/server.py
|
|
150
|
+
|
|
151
|
+
# OR specifying the directory for uv
|
|
152
|
+
uv --directory file-convert-mcp/src/file_convert_mcp run server.py
|
|
153
|
+
|
|
154
|
+
# OR navigating into the server directory
|
|
155
|
+
cd file-convert-mcp/src/file_convert_mcp
|
|
156
|
+
uv run server.py
|
|
157
|
+
|
|
158
|
+
# OR using uvx to fetch the core from the GitHub repository + local .env file
|
|
159
|
+
UV_ENV_FILE=.env uvx --from git+https://github.com/convertguru/file-convert-mcp.git file-convert-mcp
|
|
160
|
+
|
|
161
|
+
# OR run with HTTP transport on custom port
|
|
162
|
+
TRANSPORT=http PORT=9000 uv run file-convert-mcp/src/file_convert_mcp/server.py
|
|
163
|
+
|
|
164
|
+
# OR run with stdio transport (default)
|
|
165
|
+
TRANSPORT=stdio uv run file-convert-mcp/src/file_convert_mcp/server.py
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**5. Create .env file with your configuration (optional for now):**
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Basic configuration
|
|
172
|
+
echo "CONVERT_GURU_API_KEY=your_api_key_here" > file-convert-mcp/.env
|
|
173
|
+
|
|
174
|
+
# OR with transport configuration
|
|
175
|
+
echo "CONVERT_GURU_API_KEY=your_api_key_here" > file-convert-mcp/.env
|
|
176
|
+
echo "TRANSPORT=http" >> file-convert-mcp/.env
|
|
177
|
+
echo "PORT=8000" >> file-convert-mcp/.env
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**6. Modify the server logic if needed:**
|
|
181
|
+
Edit the main server file located at `src/file_convert_mcp/server.py.`
|
|
182
|
+
|
|
183
|
+
**7. Clearing the `uv` Cache (if needed):**
|
|
184
|
+
If `uv` has cached an older version of the code in `~/.cache/uv`, you might need to clear the cache. Alternatively, use `uv` with the `-n` or `--no-cache` option to bypass it.
|
|
185
|
+
|
|
186
|
+
## ⚙️ Available Tools
|
|
187
|
+
|
|
188
|
+
The MCP server provides the following tools:
|
|
189
|
+
|
|
190
|
+
* `detect_file_type`: Analyzes the first 200 bytes of an uploaded file and uses the Convert.Guru API to determine its type.
|
|
191
|
+
* `convert_file`: Converts a given file to a specified output format. The desired file extension should be passed as the `ext_out` parameter.
|
|
192
|
+
|
|
193
|
+
Files larger than 40 MiB are rejected before upload. All result files are saved
|
|
194
|
+
beside the input file. Existing files are preserved by adding numbered suffixes.
|
|
195
|
+
The result contains `converted_file_paths` for all downloaded files and retains
|
|
196
|
+
`converted_file_path` for the first file for compatibility. If a later download
|
|
197
|
+
fails, the error response includes the paths already downloaded.
|
|
198
|
+
|
|
199
|
+
### Local development and checks (shared user installation)
|
|
200
|
+
|
|
201
|
+
```powershell
|
|
202
|
+
python -m pip install --user pytest build hatchling editables
|
|
203
|
+
python -m pip install --user --no-build-isolation -e .
|
|
204
|
+
python -m pytest -q
|
|
205
|
+
python -m build --no-isolation
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
These tests use a local API substitute and do not upload files to Convert.Guru.
|
|
209
|
+
They cover the 40 MiB boundary, multiple results, existing files, partial failure,
|
|
210
|
+
native file detection, and the installed STDIO/HTTP command. Large temporary test
|
|
211
|
+
files are removed automatically. Building creates local artifacts in `dist/`;
|
|
212
|
+
it does not publish them.
|
|
213
|
+
|
|
214
|
+
On Windows, the package uses `python-magic-bin`, which bundles libmagic.
|
|
215
|
+
FastMCP is constrained to the tested 2.x API. If the user Scripts directory is not
|
|
216
|
+
on PATH, launch `file-convert-mcp.exe` using its full path, or run
|
|
217
|
+
`python -m file_convert_mcp.server`.
|
|
218
|
+
|
|
219
|
+
## 📜 License
|
|
220
|
+
|
|
221
|
+
Visit us [https://convert.guru](https://convert.guru/)
|
|
222
|
+
|
|
223
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for complete details.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# File Convert MCP Server
|
|
2
|
+
[](https://smithery.ai/server/@convertguru/file-convert-mcp)
|
|
3
|
+
|
|
4
|
+
A Model Context Protocol (MCP) server for converting files between various formats.
|
|
5
|
+
|
|
6
|
+
File Convert is an MCP server designed to handle the conversion of a wide array of file formats, including images, office documents, audio, video, text, and data files. It aims to provide seamless transformation into popular formats such as PDF, JPG, MP4, and HTML, among others. This server offers a set of powerful tools to convert diverse file types into easily accessible and widely compatible formats like PDF, JPG, PNG, TXT.
|
|
7
|
+
|
|
8
|
+
## ✨ Features
|
|
9
|
+
|
|
10
|
+
- **Intelligent File Type Detection:** Employs a combination of AI, TrID, and magic bytes for accurate file type identification.
|
|
11
|
+
- **Versatile File Conversion:** Supports conversion between numerous file types and the following popular formats:
|
|
12
|
+
- **Images:** Handles a vast range of formats, including many legacy ones. Converts to PDF, JPG, PNG.
|
|
13
|
+
- **Office Documents:** Converts to PDF, TXT, HTML.
|
|
14
|
+
- **Audio/Video:** Converts to MP3, MP4.
|
|
15
|
+
- **Databases:** Converts to CSV.
|
|
16
|
+
- **Various Files:** Offers conversion capabilities for other file types as well.
|
|
17
|
+
|
|
18
|
+
## 🛠️ Requirements
|
|
19
|
+
|
|
20
|
+
1. **Python:** Version 3.12 or higher is required.
|
|
21
|
+
2. **uv Tool:** Install the [Python uv tool](https://docs.astral.sh/uv/getting-started/installation/) for efficient dependency management and easy execution of the MCP server.
|
|
22
|
+
3. **API Key (Development):** As of May 2025, the MCP is in its testing phase, and **no API key is currently required** for development. For future production use, please [contact the Convert.Guru team](https://convert.guru/contact) to obtain an API key.
|
|
23
|
+
|
|
24
|
+
## 🚀 Usage with Desktop App
|
|
25
|
+
|
|
26
|
+
To integrate this server with your desktop application, add the following configuration to your app's server settings. If necessary, adjust the absolute path to the `uv`/`uvx` executables (or `uv.exe`/`uvx.exe` on Windows).
|
|
27
|
+
|
|
28
|
+
**Using `uvx` (recommended):**
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"file-convert": {
|
|
34
|
+
"command": "uvx",
|
|
35
|
+
"args": ["--from", "git+https://github.com/convertguru/file-convert-mcp.git", "file-convert-mcp"],
|
|
36
|
+
"env": {
|
|
37
|
+
"CONVERT_GURU_API_KEY": "your_api_key_here"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**If you have cloned the repository locally (adjust paths as needed):**
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"file-convert": {
|
|
50
|
+
"command": "/home/User/.local/bin/uv",
|
|
51
|
+
"args": ["--directory", "/home/User/file-convert-mcp/src/file_convert_mcp", "run", "server.py"],
|
|
52
|
+
"env": {
|
|
53
|
+
"CONVERT_GURU_API_KEY": "your_api_key_here"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Using local `uvx`:**
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"file-convert": {
|
|
66
|
+
"command": "uvx",
|
|
67
|
+
"args": ["--from", "/home/User/file-convert-mcp", "file-convert-mcp"],
|
|
68
|
+
"env": {
|
|
69
|
+
"CONVERT_GURU_API_KEY": "your_api_key_here"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Environment Variables
|
|
77
|
+
|
|
78
|
+
The MCP server supports the following environment variables:
|
|
79
|
+
|
|
80
|
+
- **`CONVERT_GURU_API_KEY`**: Your Convert.Guru API key (optional for development)
|
|
81
|
+
- **`TRANSPORT`**: Transport method for the MCP server
|
|
82
|
+
- `"stdio"` (default): Standard input/output transport
|
|
83
|
+
- `"http"`: HTTP transport (streamable) with REST API endpoints
|
|
84
|
+
- **`PORT`**: Port number for HTTP transport (default: 8000, only used when `TRANSPORT=http`)
|
|
85
|
+
|
|
86
|
+
When using HTTP transport, the server provides additional endpoints:
|
|
87
|
+
- `GET /health` - Health check endpoint
|
|
88
|
+
- `GET /tools` - List of available MCP tools
|
|
89
|
+
|
|
90
|
+
Also, see this guide on how to [set up MCP tools in Claude Desktop](https://modelcontextprotocol.io/quickstart/user#for-claude-desktop-users).
|
|
91
|
+
|
|
92
|
+
### Installing via Smithery
|
|
93
|
+
|
|
94
|
+
To install file-convert-mcp for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@convertguru/file-convert-mcp):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npx -y @smithery/cli install @convertguru/file-convert-mcp --client claude
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 🛠️ Development
|
|
101
|
+
|
|
102
|
+
Get started with local development by following these steps:
|
|
103
|
+
|
|
104
|
+
**1. Clone the repository:**
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git clone https://github.com/convertguru/file-convert-mcp.git
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**2. Fetch and cache Python dependencies (optional, `uvx` handles this automatically):**
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
cd file-convert-mcp
|
|
114
|
+
uv sync
|
|
115
|
+
cd ..
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**3. Create .env file with your (optional for now) API key:**
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
echo "CONVERT_GURU_API_KEY=your_api_key_here" > file-convert-mcp/.env
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**4. Run the MCP server locally using various `uv` commands:**
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Using uvx with .env file from the local directory
|
|
128
|
+
cd file-convert-mcp
|
|
129
|
+
UV_ENV_FILE=.env uvx --from ./file-convert-mcp file-convert-mcp
|
|
130
|
+
|
|
131
|
+
# OR using uv directly to run the server script
|
|
132
|
+
uv run file-convert-mcp/src/file_convert_mcp/server.py
|
|
133
|
+
|
|
134
|
+
# OR specifying the directory for uv
|
|
135
|
+
uv --directory file-convert-mcp/src/file_convert_mcp run server.py
|
|
136
|
+
|
|
137
|
+
# OR navigating into the server directory
|
|
138
|
+
cd file-convert-mcp/src/file_convert_mcp
|
|
139
|
+
uv run server.py
|
|
140
|
+
|
|
141
|
+
# OR using uvx to fetch the core from the GitHub repository + local .env file
|
|
142
|
+
UV_ENV_FILE=.env uvx --from git+https://github.com/convertguru/file-convert-mcp.git file-convert-mcp
|
|
143
|
+
|
|
144
|
+
# OR run with HTTP transport on custom port
|
|
145
|
+
TRANSPORT=http PORT=9000 uv run file-convert-mcp/src/file_convert_mcp/server.py
|
|
146
|
+
|
|
147
|
+
# OR run with stdio transport (default)
|
|
148
|
+
TRANSPORT=stdio uv run file-convert-mcp/src/file_convert_mcp/server.py
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**5. Create .env file with your configuration (optional for now):**
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# Basic configuration
|
|
155
|
+
echo "CONVERT_GURU_API_KEY=your_api_key_here" > file-convert-mcp/.env
|
|
156
|
+
|
|
157
|
+
# OR with transport configuration
|
|
158
|
+
echo "CONVERT_GURU_API_KEY=your_api_key_here" > file-convert-mcp/.env
|
|
159
|
+
echo "TRANSPORT=http" >> file-convert-mcp/.env
|
|
160
|
+
echo "PORT=8000" >> file-convert-mcp/.env
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**6. Modify the server logic if needed:**
|
|
164
|
+
Edit the main server file located at `src/file_convert_mcp/server.py.`
|
|
165
|
+
|
|
166
|
+
**7. Clearing the `uv` Cache (if needed):**
|
|
167
|
+
If `uv` has cached an older version of the code in `~/.cache/uv`, you might need to clear the cache. Alternatively, use `uv` with the `-n` or `--no-cache` option to bypass it.
|
|
168
|
+
|
|
169
|
+
## ⚙️ Available Tools
|
|
170
|
+
|
|
171
|
+
The MCP server provides the following tools:
|
|
172
|
+
|
|
173
|
+
* `detect_file_type`: Analyzes the first 200 bytes of an uploaded file and uses the Convert.Guru API to determine its type.
|
|
174
|
+
* `convert_file`: Converts a given file to a specified output format. The desired file extension should be passed as the `ext_out` parameter.
|
|
175
|
+
|
|
176
|
+
Files larger than 40 MiB are rejected before upload. All result files are saved
|
|
177
|
+
beside the input file. Existing files are preserved by adding numbered suffixes.
|
|
178
|
+
The result contains `converted_file_paths` for all downloaded files and retains
|
|
179
|
+
`converted_file_path` for the first file for compatibility. If a later download
|
|
180
|
+
fails, the error response includes the paths already downloaded.
|
|
181
|
+
|
|
182
|
+
### Local development and checks (shared user installation)
|
|
183
|
+
|
|
184
|
+
```powershell
|
|
185
|
+
python -m pip install --user pytest build hatchling editables
|
|
186
|
+
python -m pip install --user --no-build-isolation -e .
|
|
187
|
+
python -m pytest -q
|
|
188
|
+
python -m build --no-isolation
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
These tests use a local API substitute and do not upload files to Convert.Guru.
|
|
192
|
+
They cover the 40 MiB boundary, multiple results, existing files, partial failure,
|
|
193
|
+
native file detection, and the installed STDIO/HTTP command. Large temporary test
|
|
194
|
+
files are removed automatically. Building creates local artifacts in `dist/`;
|
|
195
|
+
it does not publish them.
|
|
196
|
+
|
|
197
|
+
On Windows, the package uses `python-magic-bin`, which bundles libmagic.
|
|
198
|
+
FastMCP is constrained to the tested 2.x API. If the user Scripts directory is not
|
|
199
|
+
on PATH, launch `file-convert-mcp.exe` using its full path, or run
|
|
200
|
+
`python -m file_convert_mcp.server`.
|
|
201
|
+
|
|
202
|
+
## 📜 License
|
|
203
|
+
|
|
204
|
+
Visit us [https://convert.guru](https://convert.guru/)
|
|
205
|
+
|
|
206
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for complete details.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "file-convert-mcp"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
description = "MCP tool for file conversion and file format detection by Convert.Guru"
|
|
7
|
+
authors = []
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"fastmcp>=2.14.7,<3",
|
|
11
|
+
"aiohttp",
|
|
12
|
+
"python-dotenv",
|
|
13
|
+
"python-magic; sys_platform != 'win32'",
|
|
14
|
+
"python-magic-bin==0.4.14; sys_platform == 'win32'",
|
|
15
|
+
"starlette"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://convert.guru"
|
|
20
|
+
Repository = "https://github.com/convertguru/file-convert-mcp"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
file-convert-mcp = "file_convert_mcp.server:main"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Smithery configuration file: https://smithery.ai/docs/build/project-config
|
|
2
|
+
|
|
3
|
+
startCommand:
|
|
4
|
+
type: stdio
|
|
5
|
+
configSchema:
|
|
6
|
+
# JSON Schema defining the configuration options for the MCP.
|
|
7
|
+
type: object
|
|
8
|
+
properties:
|
|
9
|
+
apiKey:
|
|
10
|
+
type: string
|
|
11
|
+
default: ""
|
|
12
|
+
description: Convert.Guru API key (optional for development)
|
|
13
|
+
transport:
|
|
14
|
+
type: string
|
|
15
|
+
enum: ["stdio", "http"]
|
|
16
|
+
default: "http"
|
|
17
|
+
description: Transport method for the MCP server
|
|
18
|
+
port:
|
|
19
|
+
type: integer
|
|
20
|
+
default: 8000
|
|
21
|
+
description: Port number for HTTP transport (optional, only used when transport is http)
|
|
22
|
+
commandFunction:
|
|
23
|
+
# A JS function that produces the CLI command based on the given config to start the MCP on stdio.
|
|
24
|
+
|-
|
|
25
|
+
(config) => ({
|
|
26
|
+
command: 'file-convert-mcp',
|
|
27
|
+
env: {
|
|
28
|
+
CONVERT_GURU_API_KEY: config.apiKey,
|
|
29
|
+
TRANSPORT: (config.transport || "http").toUpperCase(),
|
|
30
|
+
PORT: config.transport === "http" ? (config.port?.toString() || "8000") : undefined
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
exampleConfig:
|
|
34
|
+
apiKey: ""
|
|
35
|
+
transport: "http"
|
|
36
|
+
port: 8000
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import re
|
|
4
|
+
import magic
|
|
5
|
+
import json
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
import aiohttp
|
|
10
|
+
from starlette.responses import JSONResponse
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
# Load environment variables
|
|
14
|
+
load_dotenv()
|
|
15
|
+
|
|
16
|
+
# Initialize FastMCP
|
|
17
|
+
mcp = FastMCP("file-convert-mcp")
|
|
18
|
+
base_url = 'https://convert.guru'
|
|
19
|
+
MAX_FILE_SIZE = 40 * 1024 * 1024
|
|
20
|
+
api_key = os.getenv("CONVERT_GURU_API_KEY")
|
|
21
|
+
if api_key is None:
|
|
22
|
+
api_key = ''
|
|
23
|
+
|
|
24
|
+
@mcp.custom_route("/health", methods=["GET"])
|
|
25
|
+
async def health_check(request):
|
|
26
|
+
return JSONResponse({"status": "healthy", "service": "mcp-server"})
|
|
27
|
+
|
|
28
|
+
@mcp.custom_route("/tools", methods=["GET"])
|
|
29
|
+
async def list_tools(request):
|
|
30
|
+
tools_info = []
|
|
31
|
+
for tool_name, tool in (await mcp.get_tools()).items():
|
|
32
|
+
tools_info.append({
|
|
33
|
+
"name": tool_name,
|
|
34
|
+
"description": tool.description or "No description available",
|
|
35
|
+
"async": asyncio.iscoroutinefunction(tool.fn)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return JSONResponse({
|
|
39
|
+
"tools": tools_info,
|
|
40
|
+
"total_tools": len(tools_info)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
async def detect_file_type(file_path: str) -> Dict[str, Any]:
|
|
45
|
+
"""Detect file type by sending the first 200 bytes of the file to Convert.Guru API."""
|
|
46
|
+
if not file_path:
|
|
47
|
+
return {"error": "file_path parameter is required"}
|
|
48
|
+
|
|
49
|
+
if not os.path.exists(file_path):
|
|
50
|
+
return {"error": f"File not found (use the absolute file path): {file_path}"}
|
|
51
|
+
|
|
52
|
+
if not os.path.isfile(file_path):
|
|
53
|
+
return {"error": f"Not a file: {file_path}"}
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
with open(file_path, "rb") as f:
|
|
57
|
+
first_200_bytes = f.read(200) # Read only the first 200 bytes
|
|
58
|
+
|
|
59
|
+
_, file_extension = os.path.splitext(file_path)
|
|
60
|
+
file_extension = file_extension.lstrip('.') # Remove dot
|
|
61
|
+
filename = os.path.basename(file_path)
|
|
62
|
+
file_size_bytes = os.path.getsize(file_path)
|
|
63
|
+
size_hex = hex(file_size_bytes)
|
|
64
|
+
mime_type = magic.Magic(mime=True).from_file(file_path)
|
|
65
|
+
|
|
66
|
+
data = [file_extension,size_hex,mime_type,filename]
|
|
67
|
+
separator = '\xFE'
|
|
68
|
+
joined_data = separator.join(data)
|
|
69
|
+
headers = {'cache-control': 'no-cache', 'api-key': api_key}
|
|
70
|
+
|
|
71
|
+
data_array = bytearray()
|
|
72
|
+
for char in joined_data:
|
|
73
|
+
data_array.append(ord(char) & 0xFF)
|
|
74
|
+
|
|
75
|
+
data_array.append(0xFE)
|
|
76
|
+
data_array.extend(first_200_bytes)
|
|
77
|
+
|
|
78
|
+
async with aiohttp.ClientSession() as session:
|
|
79
|
+
async with session.post(base_url + '/api/v1/detect_file_type', data=data_array, headers=headers) as response:
|
|
80
|
+
if response.status == 200:
|
|
81
|
+
content = await response.text()
|
|
82
|
+
comment_pattern = re.compile(r'<!--.+-->')
|
|
83
|
+
# Remove all occurrences of the pattern from the string
|
|
84
|
+
content = comment_pattern.sub('', content)
|
|
85
|
+
return {"result": {"file_type_description": content.strip()}}
|
|
86
|
+
else:
|
|
87
|
+
error_text = await response.text()
|
|
88
|
+
return {"error": f"API request failed with status {response.status}: {error_text}"}
|
|
89
|
+
|
|
90
|
+
except aiohttp.ClientError as e:
|
|
91
|
+
return {"error": f"Error making API request: {e}"}
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return {"error": f"Error processing file: {e}"}
|
|
94
|
+
|
|
95
|
+
@mcp.tool()
|
|
96
|
+
async def convert_file(file_path: str, ext_out: str) -> Dict[str, Any]:
|
|
97
|
+
"""Convert a file to other format via Convert.Guru API. Pass the file path and output extension."""
|
|
98
|
+
if not file_path:
|
|
99
|
+
return {"error": "file_path parameter is required"}
|
|
100
|
+
|
|
101
|
+
if not ext_out:
|
|
102
|
+
return {"error": "ext_out parameter is required"}
|
|
103
|
+
|
|
104
|
+
if not os.path.exists(file_path):
|
|
105
|
+
return {"error": f"File not found (use the absolute file path): {file_path}"}
|
|
106
|
+
|
|
107
|
+
if not os.path.isfile(file_path):
|
|
108
|
+
return {"error": f"Not a file: {file_path}"}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with open(file_path, "rb") as f:
|
|
112
|
+
chunk = f.read(MAX_FILE_SIZE + 1)
|
|
113
|
+
if len(chunk) > MAX_FILE_SIZE:
|
|
114
|
+
return {"error": "File exceeds the maximum upload size of 40 MiB."}
|
|
115
|
+
|
|
116
|
+
filename = os.path.basename(file_path)
|
|
117
|
+
|
|
118
|
+
data = aiohttp.FormData()
|
|
119
|
+
data.add_field('file', chunk, filename=filename)
|
|
120
|
+
headers = {'cache-control': 'no-cache', 'api-key': api_key}
|
|
121
|
+
|
|
122
|
+
async with aiohttp.ClientSession() as session:
|
|
123
|
+
async with session.post(base_url + '/api/v1/upload', data=data, headers=headers) as response:
|
|
124
|
+
if response.status == 200:
|
|
125
|
+
response_text = await response.text()
|
|
126
|
+
try:
|
|
127
|
+
# Explicitly parse using json.loads
|
|
128
|
+
json_result = json.loads(response_text)
|
|
129
|
+
json_payload = {
|
|
130
|
+
"filename": json_result['Filename'], "ext_out": ext_out.lower()}
|
|
131
|
+
conv_response_text = await post_json(base_url + '/api/v1/convert', json_payload, headers)
|
|
132
|
+
try:
|
|
133
|
+
# Explicitly parse using json.loads
|
|
134
|
+
conv_json_result = json.loads(
|
|
135
|
+
conv_response_text)
|
|
136
|
+
|
|
137
|
+
## check for error in conv_json_result
|
|
138
|
+
if conv_json_result.get('error'):
|
|
139
|
+
conv_json_result['error'] = f"File conversion failed: {conv_json_result.get('error')}"
|
|
140
|
+
return conv_json_result
|
|
141
|
+
|
|
142
|
+
file_info = conv_json_result.get('file')
|
|
143
|
+
if file_info and 'ext_out' in file_info and 'urls' in file_info and file_info['urls']:
|
|
144
|
+
file_extension = file_info.get('ext_out')
|
|
145
|
+
save_path = file_path + '.converted' + \
|
|
146
|
+
f".{file_extension}"
|
|
147
|
+
saved_paths = []
|
|
148
|
+
for file_url_path in file_info['urls']:
|
|
149
|
+
full_file_url = (base_url + file_url_path
|
|
150
|
+
if file_url_path.startswith('/') else file_url_path)
|
|
151
|
+
download_result = await download_file(session, full_file_url, save_path)
|
|
152
|
+
if 'error' in download_result:
|
|
153
|
+
download_result['result'] = {"converted_file_paths": saved_paths}
|
|
154
|
+
return download_result
|
|
155
|
+
saved_paths.append(download_result['result']['converted_file_path'])
|
|
156
|
+
return {"result": {
|
|
157
|
+
"converted_file_path": saved_paths[0],
|
|
158
|
+
"converted_file_paths": saved_paths,
|
|
159
|
+
}}
|
|
160
|
+
|
|
161
|
+
else:
|
|
162
|
+
return {"error": f"File URL information not found in the API response.: {conv_response_text}"}
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
# If the response is not JSON (convert)
|
|
165
|
+
return {"error": f"API convert request failed - response text is not JSON: {conv_response_text}"}
|
|
166
|
+
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
# If the response is not JSON (upload)
|
|
169
|
+
return {"error": f"API upload request failed - response text is not JSON: {response_text}"}
|
|
170
|
+
else:
|
|
171
|
+
error_text = await response.text()
|
|
172
|
+
return {"error": f"API request failed with status {response.status}: {error_text}"}
|
|
173
|
+
|
|
174
|
+
except aiohttp.ClientError as e:
|
|
175
|
+
return {"error": f"Error making API request: {e}"}
|
|
176
|
+
except Exception as e:
|
|
177
|
+
return {"error": f"Error processing file: {e}"}
|
|
178
|
+
|
|
179
|
+
async def post_json(url: str, data: dict, headers):
|
|
180
|
+
"""
|
|
181
|
+
Posts JSON data to a specified URL using aiohttp.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
url: The URL to post the JSON data to.
|
|
185
|
+
data: A Python dictionary that will be converted to JSON.
|
|
186
|
+
"""
|
|
187
|
+
cloned_headers = headers.copy()
|
|
188
|
+
cloned_headers['Content-Type'] = 'application/json'
|
|
189
|
+
async with aiohttp.ClientSession() as session:
|
|
190
|
+
try:
|
|
191
|
+
async with session.post(url, json=data, headers=cloned_headers) as response:
|
|
192
|
+
response_text = await response.text()
|
|
193
|
+
status = response.status
|
|
194
|
+
err = {}
|
|
195
|
+
|
|
196
|
+
if status == 200 or status == 400:
|
|
197
|
+
return response_text
|
|
198
|
+
elif status == 524:
|
|
199
|
+
err = {"error": f"Error HTTP 524 - operation takes too long to complete, timeout occured."}
|
|
200
|
+
elif status == 500:
|
|
201
|
+
err = {"error": f"Error HTTP 500 - operation takes too long to complete, internal server error."}
|
|
202
|
+
else:
|
|
203
|
+
err = {"error": f"Error HTTP {status})."}
|
|
204
|
+
|
|
205
|
+
return json.dumps(err)
|
|
206
|
+
except aiohttp.ClientError as e:
|
|
207
|
+
err = {"error": f"An error occurred during the request (exception): {e}"}
|
|
208
|
+
return json.dumps(err)
|
|
209
|
+
|
|
210
|
+
async def download_file(session: aiohttp.ClientSession, url: str, save_path: str) -> Dict[str, Any]:
|
|
211
|
+
"""Downloads a file from a URL and saves it to the specified path."""
|
|
212
|
+
try:
|
|
213
|
+
async with session.get(url) as response:
|
|
214
|
+
if response.status == 200:
|
|
215
|
+
root, extension = os.path.splitext(save_path)
|
|
216
|
+
counter = 1
|
|
217
|
+
while True:
|
|
218
|
+
try:
|
|
219
|
+
f = open(save_path, 'xb')
|
|
220
|
+
break
|
|
221
|
+
except FileExistsError:
|
|
222
|
+
counter += 1
|
|
223
|
+
save_path = f"{root}_{counter}{extension}"
|
|
224
|
+
try:
|
|
225
|
+
with f:
|
|
226
|
+
async for chunk in response.content.iter_chunked(8192):
|
|
227
|
+
f.write(chunk)
|
|
228
|
+
except BaseException:
|
|
229
|
+
os.remove(save_path)
|
|
230
|
+
raise
|
|
231
|
+
return {"result": {"converted_file_path": save_path}}
|
|
232
|
+
else:
|
|
233
|
+
response_text = await response.text()
|
|
234
|
+
return {"error": f"File download from {url} failed with status {response.status}: {response_text}"}
|
|
235
|
+
except aiohttp.ClientError as e:
|
|
236
|
+
return {"error": f"An error occurred during file download from {url}: {e}"}
|
|
237
|
+
|
|
238
|
+
def main():
|
|
239
|
+
"""Start the installed command and direct script with the same settings."""
|
|
240
|
+
transport = os.getenv("TRANSPORT", "").upper()
|
|
241
|
+
if transport == "HTTP":
|
|
242
|
+
port = os.getenv("PORT", "8000")
|
|
243
|
+
try:
|
|
244
|
+
port_int = int(port)
|
|
245
|
+
mcp.run(transport="http", host="0.0.0.0", port=port_int)
|
|
246
|
+
except ValueError:
|
|
247
|
+
print(f"Invalid PORT value: {port}. Using default stdio transport.", file=sys.stderr)
|
|
248
|
+
mcp.run()
|
|
249
|
+
else:
|
|
250
|
+
mcp.run()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
main()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import sysconfig
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
from aiohttp import web
|
|
12
|
+
from fastmcp import Client
|
|
13
|
+
from fastmcp.client.transports import StdioTransport
|
|
14
|
+
import magic
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from file_convert_mcp import server
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def work_dir():
|
|
22
|
+
# Large boundary-test files live in TEMP and are removed after each test.
|
|
23
|
+
with tempfile.TemporaryDirectory(prefix="file-convert-mcp-test-", ignore_cleanup_errors=True) as folder:
|
|
24
|
+
yield Path(folder)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def conversion_case(monkeypatch, source, *, outputs=1, failed_output=None):
|
|
28
|
+
uploads = []
|
|
29
|
+
|
|
30
|
+
async def upload(request):
|
|
31
|
+
part = await (await request.multipart()).next()
|
|
32
|
+
size = 0
|
|
33
|
+
while chunk := await part.read_chunk():
|
|
34
|
+
size += len(chunk)
|
|
35
|
+
uploads.append(size)
|
|
36
|
+
return web.json_response({"Filename": "uploaded.bin"})
|
|
37
|
+
|
|
38
|
+
async def convert(request):
|
|
39
|
+
assert (await request.json())["ext_out"] == "txt"
|
|
40
|
+
return web.json_response({"file": {
|
|
41
|
+
"ext_out": "txt", "urls": [f"/result/{n}" for n in range(outputs)]
|
|
42
|
+
}})
|
|
43
|
+
|
|
44
|
+
async def result(request):
|
|
45
|
+
n = int(request.match_info["n"])
|
|
46
|
+
if n == failed_output:
|
|
47
|
+
return web.Response(status=500, text="Test download failure")
|
|
48
|
+
return web.Response(body=f"Result {n}".encode())
|
|
49
|
+
|
|
50
|
+
app = web.Application()
|
|
51
|
+
app.router.add_post('/api/v1/upload', upload)
|
|
52
|
+
app.router.add_post('/api/v1/convert', convert)
|
|
53
|
+
app.router.add_get('/result/{n}', result)
|
|
54
|
+
runner = web.AppRunner(app)
|
|
55
|
+
await runner.setup()
|
|
56
|
+
sock = socket.socket()
|
|
57
|
+
sock.bind(('127.0.0.1', 0))
|
|
58
|
+
port = sock.getsockname()[1]
|
|
59
|
+
await web.SockSite(runner, sock).start()
|
|
60
|
+
monkeypatch.setattr(server, 'base_url', f'http://127.0.0.1:{port}')
|
|
61
|
+
try:
|
|
62
|
+
response = await server.convert_file.fn(str(source), 'TXT')
|
|
63
|
+
return response, uploads
|
|
64
|
+
finally:
|
|
65
|
+
await runner.cleanup()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize('offset', [-1, 0, 1])
|
|
69
|
+
def test_upload_size_boundary(monkeypatch, work_dir, offset):
|
|
70
|
+
source = work_dir / 'boundary.bin'
|
|
71
|
+
size = server.MAX_FILE_SIZE + offset
|
|
72
|
+
with source.open('wb') as f:
|
|
73
|
+
f.truncate(size)
|
|
74
|
+
response, uploads = asyncio.run(conversion_case(monkeypatch, source))
|
|
75
|
+
if offset > 0:
|
|
76
|
+
assert '40 MiB' in response['error']
|
|
77
|
+
assert uploads == []
|
|
78
|
+
else:
|
|
79
|
+
assert uploads == [size]
|
|
80
|
+
assert Path(response['result']['converted_file_path']).read_bytes() == b'Result 0'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_multiple_results_and_repeated_conversion(monkeypatch, work_dir):
|
|
84
|
+
source = work_dir / 'input.bin'
|
|
85
|
+
source.write_bytes(b'input')
|
|
86
|
+
original = work_dir / 'input.bin.converted.txt'
|
|
87
|
+
original.write_bytes(b'Keep me')
|
|
88
|
+
seen = set()
|
|
89
|
+
for _ in range(2):
|
|
90
|
+
response, uploads = asyncio.run(conversion_case(monkeypatch, source, outputs=3))
|
|
91
|
+
paths = response['result']['converted_file_paths']
|
|
92
|
+
assert response['result']['converted_file_path'] == paths[0]
|
|
93
|
+
assert len(paths) == 3
|
|
94
|
+
for n, path in enumerate(paths):
|
|
95
|
+
assert path not in seen
|
|
96
|
+
seen.add(path)
|
|
97
|
+
assert Path(path).read_bytes() == f'Result {n}'.encode()
|
|
98
|
+
assert uploads == [5]
|
|
99
|
+
assert original.read_bytes() == b'Keep me'
|
|
100
|
+
assert source.read_bytes() == b'input'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_failed_download_reports_completed_files(monkeypatch, work_dir):
|
|
104
|
+
source = work_dir / 'input.bin'
|
|
105
|
+
source.write_bytes(b'input')
|
|
106
|
+
response, _ = asyncio.run(conversion_case(monkeypatch, source, outputs=3, failed_output=1))
|
|
107
|
+
assert '500' in response['error']
|
|
108
|
+
paths = response['result']['converted_file_paths']
|
|
109
|
+
assert len(paths) == 1
|
|
110
|
+
assert Path(paths[0]).read_bytes() == b'Result 0'
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def installed_command():
|
|
114
|
+
scheme = 'nt_user' if os.name == 'nt' else 'posix_user'
|
|
115
|
+
suffix = '.exe' if os.name == 'nt' else ''
|
|
116
|
+
command = Path(sysconfig.get_path('scripts', scheme)) / f'file-convert-mcp{suffix}'
|
|
117
|
+
assert command.is_file(), 'Install the package before running the transport tests.'
|
|
118
|
+
return str(command)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def check_mcp(transport):
|
|
122
|
+
async with Client(transport, timeout=10) as client:
|
|
123
|
+
tools = await client.list_tools()
|
|
124
|
+
assert {tool.name for tool in tools} == {'detect_file_type', 'convert_file'}
|
|
125
|
+
# An actual tool call, deliberately rejected before any external API request.
|
|
126
|
+
response = await client.call_tool('convert_file', {'file_path': '', 'ext_out': 'txt'})
|
|
127
|
+
assert 'file_path parameter is required' in str(response)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize('port', [None, 'invalid'])
|
|
131
|
+
def test_installed_stdio_start(work_dir, port):
|
|
132
|
+
env = dict(os.environ, PYTHONIOENCODING='utf-8', TRANSPORT='stdio')
|
|
133
|
+
if port:
|
|
134
|
+
env.update(TRANSPORT='http', PORT=port)
|
|
135
|
+
transport = StdioTransport(command=installed_command(), args=[], env=env, cwd=str(work_dir))
|
|
136
|
+
asyncio.run(check_mcp(transport))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_installed_http_start(work_dir):
|
|
140
|
+
with socket.socket() as sock:
|
|
141
|
+
sock.bind(('127.0.0.1', 0))
|
|
142
|
+
port = sock.getsockname()[1]
|
|
143
|
+
env = dict(os.environ, PYTHONIOENCODING='utf-8', TRANSPORT='http', PORT=str(port))
|
|
144
|
+
log_path = work_dir / 'http.log'
|
|
145
|
+
with log_path.open('w', encoding='utf-8') as log:
|
|
146
|
+
process = subprocess.Popen(
|
|
147
|
+
[installed_command()], env=env, cwd=work_dir,
|
|
148
|
+
stdout=log, stderr=log,
|
|
149
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
deadline = time.monotonic() + 25
|
|
153
|
+
while time.monotonic() < deadline:
|
|
154
|
+
assert process.poll() is None, log_path.read_text(encoding='utf-8')
|
|
155
|
+
try:
|
|
156
|
+
with urlopen(f'http://127.0.0.1:{port}/health', timeout=1) as response:
|
|
157
|
+
assert response.status == 200
|
|
158
|
+
break
|
|
159
|
+
except OSError:
|
|
160
|
+
time.sleep(0.1)
|
|
161
|
+
else:
|
|
162
|
+
pytest.fail(log_path.read_text(encoding='utf-8'))
|
|
163
|
+
with urlopen(f'http://127.0.0.1:{port}/tools', timeout=2) as response:
|
|
164
|
+
assert b'convert_file' in response.read()
|
|
165
|
+
asyncio.run(check_mcp(f'http://127.0.0.1:{port}/mcp'))
|
|
166
|
+
finally:
|
|
167
|
+
process.terminate()
|
|
168
|
+
process.wait(timeout=10)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_native_file_detection():
|
|
172
|
+
assert magic.from_buffer(b'%PDF-1.4\n', mime=True) == 'application/pdf'
|