omnidesk-mcp 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,19 @@
|
|
|
1
|
+
!desktop/src-tauri/resources/.gitignore
|
|
2
|
+
!desktop/src-tauri/resources/README.txt
|
|
3
|
+
*.bak_*
|
|
4
|
+
*.log
|
|
5
|
+
*.pyc
|
|
6
|
+
.DS_Store
|
|
7
|
+
.env
|
|
8
|
+
.venv/
|
|
9
|
+
/data/
|
|
10
|
+
__pycache__/
|
|
11
|
+
apps/site/dist/
|
|
12
|
+
apps/web/dist/
|
|
13
|
+
desktop/.cache/
|
|
14
|
+
desktop/src-tauri/gen/schemas/
|
|
15
|
+
desktop/src-tauri/resources/*
|
|
16
|
+
desktop/src-tauri/target/
|
|
17
|
+
dist/
|
|
18
|
+
node_modules/
|
|
19
|
+
tools/private_key.b64
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: omnidesk-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OmniDesk MCP server — let AI agents convert files across 325 formats
|
|
5
|
+
Project-URL: Homepage, https://omnidesk.win
|
|
6
|
+
Project-URL: Documentation, https://omnidesk.win/en/api/
|
|
7
|
+
Author-email: Projectteamforyou <projectteamforyou@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: mcp>=2.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# omnidesk-mcp
|
|
15
|
+
|
|
16
|
+
An MCP server that lets Claude, Cursor and other agents convert files through
|
|
17
|
+
[OmniDesk](https://omnidesk.win) — 325 formats, routed through intermediate
|
|
18
|
+
formats when no direct converter exists.
|
|
19
|
+
|
|
20
|
+
## Setup
|
|
21
|
+
|
|
22
|
+
You need an OmniDesk API key (Team plan). Create one at
|
|
23
|
+
**app.omnidesk.win → Control panel → API keys**.
|
|
24
|
+
|
|
25
|
+
Add to your MCP client config (Claude Desktop: `claude_desktop_config.json`):
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"omnidesk": {
|
|
31
|
+
"command": "uvx",
|
|
32
|
+
"args": ["omnidesk-mcp"],
|
|
33
|
+
"env": { "OMNIDESK_API_KEY": "od_..." }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
| tool | what it does |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `convert_file` | convert a local file to another format; returns the result path |
|
|
44
|
+
| `list_formats` | the 325 supported formats by category |
|
|
45
|
+
| `find_route` | how a conversion would be routed, and alternatives |
|
|
46
|
+
| `file_action` | one of 33 file actions: extract tables, redact, OCR, trim, … |
|
|
47
|
+
| `list_actions` | the actions and their options |
|
|
48
|
+
|
|
49
|
+
Files are uploaded to your company drive, converted there, and the result is
|
|
50
|
+
downloaded back to a path you choose. Nothing is kept on the client beyond that.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# omnidesk-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that lets Claude, Cursor and other agents convert files through
|
|
4
|
+
[OmniDesk](https://omnidesk.win) — 325 formats, routed through intermediate
|
|
5
|
+
formats when no direct converter exists.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
You need an OmniDesk API key (Team plan). Create one at
|
|
10
|
+
**app.omnidesk.win → Control panel → API keys**.
|
|
11
|
+
|
|
12
|
+
Add to your MCP client config (Claude Desktop: `claude_desktop_config.json`):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"omnidesk": {
|
|
18
|
+
"command": "uvx",
|
|
19
|
+
"args": ["omnidesk-mcp"],
|
|
20
|
+
"env": { "OMNIDESK_API_KEY": "od_..." }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| tool | what it does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `convert_file` | convert a local file to another format; returns the result path |
|
|
31
|
+
| `list_formats` | the 325 supported formats by category |
|
|
32
|
+
| `find_route` | how a conversion would be routed, and alternatives |
|
|
33
|
+
| `file_action` | one of 33 file actions: extract tables, redact, OCR, trim, … |
|
|
34
|
+
| `list_actions` | the actions and their options |
|
|
35
|
+
|
|
36
|
+
Files are uploaded to your company drive, converted there, and the result is
|
|
37
|
+
downloaded back to a path you choose. Nothing is kept on the client beyond that.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""OmniDesk MCP server."""
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""OmniDesk MCP 서버.
|
|
2
|
+
|
|
3
|
+
AI 에이전트가 파일을 다룰 때 "이걸 PDF 로 바꿔" 라고 하면 실제로 바꿔 주는 도구.
|
|
4
|
+
Claude Desktop·Cursor·기타 MCP 클라이언트가 stdio 로 이 프로그램을 띄우고,
|
|
5
|
+
여기서는 OmniDesk API 를 부른다. 로컬 파일을 올려 변환하고 결과를 지정한 경로에
|
|
6
|
+
받아 온다.
|
|
7
|
+
|
|
8
|
+
키는 환경변수 OMNIDESK_API_KEY 로 받는다. 클라이언트 설정 파일에 적어 두면
|
|
9
|
+
된다. 키가 없으면 도구 목록은 보이되 부르면 그 이유를 말한다 — 설정이 잘못됐을
|
|
10
|
+
때 조용히 실패하는 것이 가장 나쁘다.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from mcp.server.mcpserver import MCPServer
|
|
24
|
+
|
|
25
|
+
BASE = os.environ.get("OMNIDESK_BASE", "https://app.omnidesk.win").rstrip("/")
|
|
26
|
+
KEY = os.environ.get("OMNIDESK_API_KEY", "").strip()
|
|
27
|
+
|
|
28
|
+
server = MCPServer("omnidesk",
|
|
29
|
+
instructions=("Convert files across 325 formats and run 33 file actions through OmniDesk. "
|
|
30
|
+
"Files are uploaded to the user's company drive and the result is written "
|
|
31
|
+
"back to a local path."))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _client() -> httpx.AsyncClient:
|
|
35
|
+
if not KEY:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"OMNIDESK_API_KEY is not set. Create a key at app.omnidesk.win → Control panel → "
|
|
38
|
+
"API keys (Team plan), then put it in the MCP server's env.")
|
|
39
|
+
return httpx.AsyncClient(base_url=BASE, headers={"X-API-Key": KEY}, timeout=120)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _wait_job(c: httpx.AsyncClient, job_id: str, timeout: float = 600) -> dict:
|
|
43
|
+
"""변환이 끝날 때까지. 큰 파일은 몇 분 걸릴 수 있다."""
|
|
44
|
+
t0 = time.time()
|
|
45
|
+
delay = 1.0
|
|
46
|
+
while time.time() - t0 < timeout:
|
|
47
|
+
r = await c.get(f"/api/jobs/{job_id}")
|
|
48
|
+
r.raise_for_status()
|
|
49
|
+
j = r.json()
|
|
50
|
+
if j["status"] in ("done", "failed"):
|
|
51
|
+
return j
|
|
52
|
+
await asyncio.sleep(delay)
|
|
53
|
+
delay = min(delay * 1.4, 6.0)
|
|
54
|
+
raise TimeoutError(f"job {job_id} did not finish in {timeout:.0f}s")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _download(c: httpx.AsyncClient, node_id: str, out_path: Path) -> Path:
|
|
58
|
+
r = await c.get(f"/api/drive/{node_id}/download")
|
|
59
|
+
r.raise_for_status()
|
|
60
|
+
url = r.json()["url"]
|
|
61
|
+
async with httpx.AsyncClient(timeout=300, follow_redirects=True) as raw:
|
|
62
|
+
d = await raw.get(url)
|
|
63
|
+
d.raise_for_status()
|
|
64
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
out_path.write_bytes(d.content)
|
|
66
|
+
return out_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _quality_lines(job: dict) -> str:
|
|
70
|
+
q = (job.get("quality") or {}).get("changes") or []
|
|
71
|
+
if not q:
|
|
72
|
+
return ""
|
|
73
|
+
return "\n".join(("⚠ " if x["level"] == "warn" else "· ") + x["text"] for x in q)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ───────────────────────────────────────────── tools
|
|
77
|
+
def _err(e: Exception) -> str:
|
|
78
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
79
|
+
code = e.response.status_code
|
|
80
|
+
if code == 401:
|
|
81
|
+
return "OmniDesk returned 401: API key rejected. Check OMNIDESK_API_KEY."
|
|
82
|
+
if code == 402:
|
|
83
|
+
return "OmniDesk returned 402: this account's plan does not include API access (Team plan required)."
|
|
84
|
+
if code == 429:
|
|
85
|
+
return "OmniDesk returned 429: monthly conversion quota exhausted."
|
|
86
|
+
return f"OmniDesk returned {code}: {e.response.text[:400]}"
|
|
87
|
+
return f"error: {e}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@server.tool()
|
|
91
|
+
async def convert_file(path: str, to: str, output_path: str | None = None,
|
|
92
|
+
route: list[str] | None = None) -> str:
|
|
93
|
+
"""Convert a local file to another format through OmniDesk.
|
|
94
|
+
|
|
95
|
+
325 formats; when no direct converter exists the file is routed through
|
|
96
|
+
intermediate formats. Returns the output path plus a ledger of what changed
|
|
97
|
+
(lost transparency, dropped audio track, substituted fonts, ...).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
path: Local path of the file to convert.
|
|
101
|
+
to: Target format, e.g. pdf, jpg, mp3.
|
|
102
|
+
output_path: Where to write the result. Defaults to the source path with the new extension.
|
|
103
|
+
route: Optional engine list from find_route to force a specific path.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
return await _convert({"path": path, "to": to, "output_path": output_path, "route": route})
|
|
107
|
+
except Exception as e:
|
|
108
|
+
return _err(e)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@server.tool()
|
|
112
|
+
async def file_action(path: str, action: str, options: dict | None = None,
|
|
113
|
+
output_path: str | None = None) -> str:
|
|
114
|
+
"""Run one of OmniDesk's 33 file actions on a local file.
|
|
115
|
+
|
|
116
|
+
Extract tables from a PDF to xlsx, redact phone numbers in an image, OCR a
|
|
117
|
+
scanned PDF, extract audio or subtitles from a video, trim, compress, add
|
|
118
|
+
signature fields, and more. Use list_actions to see codes and options.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: Local path of the file.
|
|
122
|
+
action: Action code, e.g. tables, redact, pdf_ocr, extract_audio.
|
|
123
|
+
options: Action options as key/value.
|
|
124
|
+
output_path: Where to write the result.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
return await _action({"path": path, "action": action, "options": options or {}, "output_path": output_path})
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return _err(e)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@server.tool()
|
|
133
|
+
async def find_route(src: str, dst: str) -> str:
|
|
134
|
+
"""Show how OmniDesk would convert one format to another, including alternative routes and the engines involved.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
src: Source format, e.g. docx.
|
|
138
|
+
dst: Target format, e.g. jpg.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
return await _route({"src": src, "dst": dst})
|
|
142
|
+
except Exception as e:
|
|
143
|
+
return _err(e)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@server.tool()
|
|
147
|
+
async def list_formats() -> str:
|
|
148
|
+
"""List the supported formats, grouped by category."""
|
|
149
|
+
try:
|
|
150
|
+
return await _formats()
|
|
151
|
+
except Exception as e:
|
|
152
|
+
return _err(e)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@server.tool()
|
|
156
|
+
async def list_actions() -> str:
|
|
157
|
+
"""List the available file actions with their accepted inputs and options."""
|
|
158
|
+
try:
|
|
159
|
+
return await _actions()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
return _err(e)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _convert(a: dict) -> str:
|
|
165
|
+
src = Path(a["path"]).expanduser()
|
|
166
|
+
if not src.is_file():
|
|
167
|
+
return f"no such file: {src}"
|
|
168
|
+
to = str(a["to"]).lower().lstrip(".")
|
|
169
|
+
out = Path(a.get("output_path") or src.with_suffix("." + to)).expanduser()
|
|
170
|
+
opts = {}
|
|
171
|
+
if a.get("route"):
|
|
172
|
+
opts["route"] = list(a["route"])
|
|
173
|
+
async with _client() as c:
|
|
174
|
+
with src.open("rb") as fh:
|
|
175
|
+
r = await c.post("/api/jobs/quick",
|
|
176
|
+
files={"file": (src.name, fh, "application/octet-stream")},
|
|
177
|
+
data={"dst_fmt": to, "options": json.dumps(opts)})
|
|
178
|
+
r.raise_for_status()
|
|
179
|
+
job = (r.json().get("jobs") or [r.json()["job"]])[0]
|
|
180
|
+
job = await _wait_job(c, job["id"])
|
|
181
|
+
if job["status"] != "done":
|
|
182
|
+
return f"conversion failed: {job.get('error') or 'unknown error'}"
|
|
183
|
+
await _download(c, job["result_node_id"], out)
|
|
184
|
+
lines = [f"converted {src.name} → {out}",
|
|
185
|
+
f"route: {(job.get('route') or {}).get('chain', '')}"]
|
|
186
|
+
ql = _quality_lines(job)
|
|
187
|
+
if ql:
|
|
188
|
+
lines.append("what changed:\n" + ql)
|
|
189
|
+
return "\n".join(lines)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _action(a: dict) -> str:
|
|
193
|
+
src = Path(a["path"]).expanduser()
|
|
194
|
+
if not src.is_file():
|
|
195
|
+
return f"no such file: {src}"
|
|
196
|
+
action = str(a["action"])
|
|
197
|
+
opts = dict(a.get("options") or {})
|
|
198
|
+
async with _client() as c:
|
|
199
|
+
with src.open("rb") as fh:
|
|
200
|
+
up = await c.post("/api/drive/upload",
|
|
201
|
+
files={"file": (src.name, fh, "application/octet-stream")},
|
|
202
|
+
data={"scope": "company"})
|
|
203
|
+
up.raise_for_status()
|
|
204
|
+
node_id = up.json()["id"]
|
|
205
|
+
r = await c.post("/api/jobs/action",
|
|
206
|
+
json={"action": action, "node_ids": [node_id], "options": opts, "scope": "company"})
|
|
207
|
+
r.raise_for_status()
|
|
208
|
+
job = await _wait_job(c, r.json()["created"][0]["id"])
|
|
209
|
+
if job["status"] != "done":
|
|
210
|
+
return f"action failed: {job.get('error') or 'unknown error'}"
|
|
211
|
+
ext = job.get("dst_fmt") or src.suffix.lstrip(".")
|
|
212
|
+
out = Path(a.get("output_path") or src.with_name(f"{src.stem}.{action}.{ext}")).expanduser()
|
|
213
|
+
await _download(c, job["result_node_id"], out)
|
|
214
|
+
return f"{action} on {src.name} → {out}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def _route(a: dict) -> str:
|
|
218
|
+
async with _client() as c:
|
|
219
|
+
r = await c.get("/api/catalog/routes", params={"src": a["src"], "dst": a["dst"]})
|
|
220
|
+
if r.status_code == 404:
|
|
221
|
+
return f"no route from {a['src']} to {a['dst']}"
|
|
222
|
+
r.raise_for_status()
|
|
223
|
+
items = r.json()["items"]
|
|
224
|
+
lines = [f"{a['src']} → {a['dst']}: {len(items)} route(s)"]
|
|
225
|
+
for i, it in enumerate(items):
|
|
226
|
+
tag = " (default)" if i == 0 else ""
|
|
227
|
+
lines.append(f" {it['hops']} step(s): {it['chain']}{tag} engines={it['engines']}")
|
|
228
|
+
return "\n".join(lines)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _formats() -> str:
|
|
232
|
+
async with _client() as c:
|
|
233
|
+
r = await c.get("/api/catalog/formats")
|
|
234
|
+
r.raise_for_status()
|
|
235
|
+
d = r.json()
|
|
236
|
+
cats = d.get("cats") or d.get("categories") or {}
|
|
237
|
+
lines = []
|
|
238
|
+
for cat, items in cats.items():
|
|
239
|
+
ids = [x["id"] if isinstance(x, dict) else x for x in items]
|
|
240
|
+
lines.append(f"{cat} ({len(ids)}): {' '.join(ids)}")
|
|
241
|
+
return "\n".join(lines) or json.dumps(d)[:2000]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _actions() -> str:
|
|
245
|
+
async with _client() as c:
|
|
246
|
+
r = await c.get("/api/catalog/actions", params={"lang": "en"})
|
|
247
|
+
r.raise_for_status()
|
|
248
|
+
acts = r.json().get("actions", [])
|
|
249
|
+
lines = []
|
|
250
|
+
for x in acts:
|
|
251
|
+
opts = ", ".join(f"{o['key']}" + (f"={o.get('default')}" if o.get("default") not in (None, "") else "")
|
|
252
|
+
for o in x.get("options", []))
|
|
253
|
+
lines.append(f"{x['code']}: {x.get('en') or x.get('name', '')} · inputs={len(x.get('inputs', []))}"
|
|
254
|
+
+ (f" · options: {opts}" if opts else ""))
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main():
|
|
259
|
+
server.run() # stdio
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "omnidesk-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "OmniDesk MCP server — let AI agents convert files across 325 formats"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Projectteamforyou", email = "projectteamforyou@gmail.com" }]
|
|
9
|
+
dependencies = ["mcp>=2.0", "httpx>=0.27"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
omnidesk-mcp = "omnidesk_mcp.server:main"
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://omnidesk.win"
|
|
16
|
+
Documentation = "https://omnidesk.win/en/api/"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["omnidesk_mcp"]
|