dicom-mcp 1.2.1 → 1.2.7
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.
- package/CHANGELOG.md +67 -0
- package/README.md +6 -1
- package/dicom_download/common_utils.py +32 -5
- package/dicom_download/multi_download.py +12 -2
- package/dicom_download/password_manager.py +117 -0
- package/dicom_download/shdc_download_dicom.py +72 -25
- package/dicom_mcp/__pycache__/server.cpython-310.pyc +0 -0
- package/dicom_mcp/server.py +192 -10
- package/dicom_mcp/server.py.bak +752 -0
- package/package.json +1 -1
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""MCP server for DICOM image downloading."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import asyncio
|
|
6
|
+
import tempfile
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
from typing import Optional, Union, Dict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
# Resolve path to dicom_download - supports multiple deployment methods:
|
|
17
|
+
# 1. Local development: git clone后,dicom_download 在 dicom_mcp 的上级目录
|
|
18
|
+
# 2. NPM package: npx安装时,dicom_download 在node_modules同级
|
|
19
|
+
# 3. Installed package: 从 PyPI 安装时,dicom_download 作为依赖已安装
|
|
20
|
+
def _resolve_dicom_download_path() -> Path:
|
|
21
|
+
"""Resolve path to dicom_download module."""
|
|
22
|
+
current_dir = Path(__file__).parent
|
|
23
|
+
|
|
24
|
+
# Method 1: Local development - check parent directory
|
|
25
|
+
local_dev_path = current_dir.parent.parent / "dicom_download"
|
|
26
|
+
if local_dev_path.exists() and (local_dev_path / "multi_download.py").exists():
|
|
27
|
+
print(f"[dicom-mcp] Found dicom_download at: {local_dev_path}", file=sys.stderr)
|
|
28
|
+
return local_dev_path
|
|
29
|
+
|
|
30
|
+
# Method 2: NPM package - check in the same package directory
|
|
31
|
+
npm_pkg_path = current_dir.parent / "dicom_download"
|
|
32
|
+
if npm_pkg_path.exists() and (npm_pkg_path / "multi_download.py").exists():
|
|
33
|
+
print(f"[dicom-mcp] Found dicom_download at: {npm_pkg_path}", file=sys.stderr)
|
|
34
|
+
return npm_pkg_path
|
|
35
|
+
|
|
36
|
+
# Method 3: Check site-packages or installation location
|
|
37
|
+
try:
|
|
38
|
+
import dicom_download as dd_module
|
|
39
|
+
if dd_module.__file__:
|
|
40
|
+
dd_path = Path(dd_module.__file__).parent
|
|
41
|
+
if (dd_path / "multi_download.py").exists():
|
|
42
|
+
print(f"[dicom-mcp] Found dicom_download at: {dd_path}", file=sys.stderr)
|
|
43
|
+
return dd_path
|
|
44
|
+
except ImportError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
# Method 4: Try to find in Python path
|
|
48
|
+
for path_item in sys.path:
|
|
49
|
+
candidate = Path(path_item) / "dicom_download"
|
|
50
|
+
if candidate.exists() and (candidate / "multi_download.py").exists():
|
|
51
|
+
print(f"[dicom-mcp] Found dicom_download at: {candidate}", file=sys.stderr)
|
|
52
|
+
return candidate
|
|
53
|
+
|
|
54
|
+
# Fallback - return the most likely path with diagnostic message
|
|
55
|
+
print(f"[dicom-mcp] WARNING: Could not find dicom_download. Tried paths:", file=sys.stderr)
|
|
56
|
+
print(f" 1. {local_dev_path}", file=sys.stderr)
|
|
57
|
+
print(f" 2. {npm_pkg_path}", file=sys.stderr)
|
|
58
|
+
print(f" 3. Python path entries", file=sys.stderr)
|
|
59
|
+
return local_dev_path
|
|
60
|
+
|
|
61
|
+
DICOM_DOWNLOAD_PATH = _resolve_dicom_download_path()
|
|
62
|
+
if DICOM_DOWNLOAD_PATH.exists():
|
|
63
|
+
sys.path.insert(0, str(DICOM_DOWNLOAD_PATH))
|
|
64
|
+
|
|
65
|
+
mcp = FastMCP("dicom-downloader")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ============================================================================
|
|
69
|
+
# Configuration from environment variables
|
|
70
|
+
# ============================================================================
|
|
71
|
+
|
|
72
|
+
# 从环境变量读取配置,支持在 Claude Desktop 中预设默认值
|
|
73
|
+
_DEFAULT_OUTPUT_DIR = os.getenv("DICOM_DEFAULT_OUTPUT_DIR", "./dicom_downloads")
|
|
74
|
+
_DEFAULT_MAX_ROUNDS = int(os.getenv("DICOM_DEFAULT_MAX_ROUNDS", "3"))
|
|
75
|
+
_DEFAULT_STEP_WAIT_MS = int(os.getenv("DICOM_DEFAULT_STEP_WAIT_MS", "40"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ============================================================================
|
|
79
|
+
# Models
|
|
80
|
+
# ============================================================================
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DownloadRequest(BaseModel):
|
|
84
|
+
"""Request to download DICOM images from a URL."""
|
|
85
|
+
|
|
86
|
+
url: str = Field(description="Medical imaging viewer URL to download from")
|
|
87
|
+
output_dir: str = Field(
|
|
88
|
+
default=_DEFAULT_OUTPUT_DIR,
|
|
89
|
+
description="Directory to save downloaded DICOM files",
|
|
90
|
+
)
|
|
91
|
+
provider: Optional[str] = Field(
|
|
92
|
+
default="auto",
|
|
93
|
+
description="Provider type: auto, tz (天肿), fz (复肿), nyfy (宁夏总医院), or cloud",
|
|
94
|
+
)
|
|
95
|
+
mode: str = Field(
|
|
96
|
+
default="all",
|
|
97
|
+
description="Download mode: all, diag (diagnostic only), or nondiag",
|
|
98
|
+
)
|
|
99
|
+
headless: bool = Field(
|
|
100
|
+
default=True, description="Run browser in headless mode (no UI)"
|
|
101
|
+
)
|
|
102
|
+
password: Optional[str] = Field(
|
|
103
|
+
default=None, description="Password/share code if required by the site"
|
|
104
|
+
)
|
|
105
|
+
create_zip: bool = Field(
|
|
106
|
+
default=True, description="Create ZIP archive of downloaded files"
|
|
107
|
+
)
|
|
108
|
+
max_rounds: int = Field(
|
|
109
|
+
default=_DEFAULT_MAX_ROUNDS,
|
|
110
|
+
description="Maximum number of scan rounds (扫描次数,默认 3)",
|
|
111
|
+
)
|
|
112
|
+
step_wait_ms: int = Field(
|
|
113
|
+
default=_DEFAULT_STEP_WAIT_MS,
|
|
114
|
+
description="Delay between steps in milliseconds (延迟时间,默认 40ms)",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BatchDownloadRequest(BaseModel):
|
|
119
|
+
"""Request to download from multiple URLs.
|
|
120
|
+
|
|
121
|
+
密码支持三种模式:
|
|
122
|
+
1. 全局密码:password="1234",所有URL共用
|
|
123
|
+
2. URL密码映射:passwords={"url1": "pwd1", "url2": "pwd2"}
|
|
124
|
+
3. 自动读文件:密码通过 urls.txt 中 "URL 安全码:xxx" 格式指定
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
urls: list[str] = Field(description="List of URLs to download from")
|
|
128
|
+
output_parent: str = Field(
|
|
129
|
+
default=_DEFAULT_OUTPUT_DIR,
|
|
130
|
+
description="Parent directory for all downloads",
|
|
131
|
+
)
|
|
132
|
+
provider: str = Field(
|
|
133
|
+
default="auto", description="Provider type to use for all URLs"
|
|
134
|
+
)
|
|
135
|
+
mode: str = Field(default="all", description="Download mode")
|
|
136
|
+
headless: bool = Field(default=True, description="Run in headless mode")
|
|
137
|
+
password: Optional[str] = Field(
|
|
138
|
+
default=None,
|
|
139
|
+
description="[废弃] 全局密码(对所有URL生效)。建议改用 passwords 字典"
|
|
140
|
+
)
|
|
141
|
+
passwords: Optional[Dict[str, Optional[str]]] = Field(
|
|
142
|
+
default=None,
|
|
143
|
+
description="[推荐] URL到密码的映射字典。格式: {'url1': 'pwd1', 'url2': None, ...}"
|
|
144
|
+
)
|
|
145
|
+
create_zip: bool = Field(default=True, description="Create ZIP archives")
|
|
146
|
+
max_rounds: int = Field(
|
|
147
|
+
default=_DEFAULT_MAX_ROUNDS,
|
|
148
|
+
description="Maximum number of scan rounds (扫描次数,默认 3)",
|
|
149
|
+
)
|
|
150
|
+
step_wait_ms: int = Field(
|
|
151
|
+
default=_DEFAULT_STEP_WAIT_MS,
|
|
152
|
+
description="Delay between steps in milliseconds (延迟时间,默认 40ms)",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DownloadResult(BaseModel):
|
|
157
|
+
"""Result of a download operation."""
|
|
158
|
+
|
|
159
|
+
success: bool = Field(description="Whether download succeeded")
|
|
160
|
+
url: str = Field(description="Source URL")
|
|
161
|
+
output_dir: str = Field(description="Output directory path")
|
|
162
|
+
zip_path: Optional[str] = Field(default=None, description="Path to ZIP file if created")
|
|
163
|
+
message: str = Field(description="Status message or error details")
|
|
164
|
+
file_count: Optional[int] = Field(default=None, description="Number of files downloaded")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ProviderInfo(BaseModel):
|
|
168
|
+
"""Information about a supported provider."""
|
|
169
|
+
|
|
170
|
+
name: str = Field(description="Provider identifier")
|
|
171
|
+
display_name: str = Field(description="Human-readable name")
|
|
172
|
+
domains: list[str] = Field(description="Supported domains")
|
|
173
|
+
description: str = Field(description="Provider description")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ============================================================================
|
|
177
|
+
# Helper Functions
|
|
178
|
+
# ============================================================================
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def detect_provider(url: str) -> str:
|
|
182
|
+
"""Auto-detect provider from URL."""
|
|
183
|
+
host = urlparse(url).netloc.lower()
|
|
184
|
+
|
|
185
|
+
if "zlyy.tjmucih.cn" in host:
|
|
186
|
+
return "tz"
|
|
187
|
+
if "zhyl.nyfy.com.cn" in host:
|
|
188
|
+
return "nyfy"
|
|
189
|
+
if "shdc.org.cn" in host or "ylyyx.shdc.org.cn" in host:
|
|
190
|
+
return "fz"
|
|
191
|
+
if host.endswith(".medicalimagecloud.com"):
|
|
192
|
+
return "cloud"
|
|
193
|
+
|
|
194
|
+
cloud_hosts = [
|
|
195
|
+
"mdmis.cq12320.cn",
|
|
196
|
+
"qr.szjudianyun.com",
|
|
197
|
+
"zscloud.zs-hospital.sh.cn",
|
|
198
|
+
"app.ftimage.cn",
|
|
199
|
+
"yyx.ftimage.cn",
|
|
200
|
+
"m.yzhcloud.com",
|
|
201
|
+
"ss.mtywcloud.com",
|
|
202
|
+
"work.sugh.net",
|
|
203
|
+
"cloudpacs.jdyfy.com",
|
|
204
|
+
]
|
|
205
|
+
if host in cloud_hosts:
|
|
206
|
+
return "cloud"
|
|
207
|
+
|
|
208
|
+
return "fz" # Default fallback
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def count_files_recursive(directory: str) -> int:
|
|
212
|
+
"""Count total files in directory recursively."""
|
|
213
|
+
count = 0
|
|
214
|
+
try:
|
|
215
|
+
for root, dirs, files in os.walk(directory):
|
|
216
|
+
count += len(files)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
return count
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def _stream_output(stream, label: str) -> str:
|
|
223
|
+
"""Stream subprocess output in real time."""
|
|
224
|
+
output = []
|
|
225
|
+
try:
|
|
226
|
+
while True:
|
|
227
|
+
line = await stream.readline()
|
|
228
|
+
if not line:
|
|
229
|
+
break
|
|
230
|
+
text = line.decode("utf-8", errors="ignore").rstrip()
|
|
231
|
+
if text:
|
|
232
|
+
# Print key progress lines to stderr (not stdout, which is for MCP JSON)
|
|
233
|
+
if any(keyword in text for keyword in
|
|
234
|
+
["下载", "provider=", "URL", "成功", "失败", "文件",
|
|
235
|
+
">>>", "###", "错误", "Error", "WARNING"]):
|
|
236
|
+
print(f" {text}", file=sys.stderr)
|
|
237
|
+
output.append(text)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
return "\n".join(output)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def run_multi_download(
|
|
244
|
+
urls: list[str],
|
|
245
|
+
output_parent: str,
|
|
246
|
+
provider: str = "auto",
|
|
247
|
+
mode: str = "all",
|
|
248
|
+
headless: bool = True,
|
|
249
|
+
password: Optional[str] = None,
|
|
250
|
+
passwords: Optional[Dict[str, Optional[str]]] = None,
|
|
251
|
+
create_zip: bool = True,
|
|
252
|
+
max_rounds: int = 3,
|
|
253
|
+
step_wait_ms: int = 40,
|
|
254
|
+
) -> list[DownloadResult]:
|
|
255
|
+
"""
|
|
256
|
+
Run multi_download.py with given parameters.
|
|
257
|
+
|
|
258
|
+
✨ 改进:支持 passwords 字典,确保URL与密码的准确映射
|
|
259
|
+
|
|
260
|
+
参数说明:
|
|
261
|
+
- password: [废弃] 全局密码,对所有URL生效
|
|
262
|
+
- passwords: [推荐] URL->密码映射字典,确保一一对应
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
script_path = DICOM_DOWNLOAD_PATH / "multi_download.py"
|
|
266
|
+
if not script_path.exists():
|
|
267
|
+
return [
|
|
268
|
+
DownloadResult(
|
|
269
|
+
success=False,
|
|
270
|
+
url=urls[0] if urls else "unknown",
|
|
271
|
+
output_dir=output_parent,
|
|
272
|
+
message=f"multi_download.py not found at {script_path}",
|
|
273
|
+
)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
# 生成 urls.txt,注入密码信息(若有)
|
|
277
|
+
with tempfile.NamedTemporaryFile(
|
|
278
|
+
mode="w", suffix=".txt", delete=False, dir=output_parent
|
|
279
|
+
) as f:
|
|
280
|
+
for url in urls:
|
|
281
|
+
# ✨ 优先使用 passwords 字典中的密码
|
|
282
|
+
pwd = None
|
|
283
|
+
if passwords and url in passwords:
|
|
284
|
+
pwd = passwords[url]
|
|
285
|
+
elif password:
|
|
286
|
+
pwd = password
|
|
287
|
+
|
|
288
|
+
# 生成格式:
|
|
289
|
+
# - 有密码:url 安全码:password
|
|
290
|
+
# - 无密码:url
|
|
291
|
+
if pwd:
|
|
292
|
+
f.write(f"{url} 安全码:{pwd}\n")
|
|
293
|
+
else:
|
|
294
|
+
f.write(f"{url}\n")
|
|
295
|
+
urls_file = f.name
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
cmd = [
|
|
299
|
+
sys.executable,
|
|
300
|
+
str(script_path),
|
|
301
|
+
"--urls-file",
|
|
302
|
+
urls_file,
|
|
303
|
+
"--out-parent",
|
|
304
|
+
output_parent,
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
if provider != "auto":
|
|
308
|
+
cmd.extend(["--provider", provider])
|
|
309
|
+
|
|
310
|
+
cmd.extend(["--mode", mode])
|
|
311
|
+
|
|
312
|
+
if headless:
|
|
313
|
+
cmd.append("--headless")
|
|
314
|
+
else:
|
|
315
|
+
cmd.append("--no-headless")
|
|
316
|
+
|
|
317
|
+
if password:
|
|
318
|
+
# Use cloud-password for fz and cloud providers, others may not need password
|
|
319
|
+
cmd.extend(["--cloud-password", password])
|
|
320
|
+
|
|
321
|
+
if not create_zip:
|
|
322
|
+
cmd.append("--no-zip")
|
|
323
|
+
|
|
324
|
+
# Add scan rounds and delay parameters
|
|
325
|
+
cmd.extend(["--max-rounds", str(max_rounds)])
|
|
326
|
+
cmd.extend(["--step-wait-ms", str(step_wait_ms)])
|
|
327
|
+
|
|
328
|
+
# Show progress banner (to stderr, visible to Claude)
|
|
329
|
+
print("\n" + "=" * 70, file=sys.stderr)
|
|
330
|
+
print("🚀 DICOM 下载开始", file=sys.stderr)
|
|
331
|
+
print("=" * 70, file=sys.stderr)
|
|
332
|
+
print(f"📍 下载数量: {len(urls)} 个URL", file=sys.stderr)
|
|
333
|
+
print(f"📁 输出目录: {output_parent}", file=sys.stderr)
|
|
334
|
+
print(f"⚙️ 扫描次数: {max_rounds}, 帧间延迟: {step_wait_ms}ms", file=sys.stderr)
|
|
335
|
+
print("⏳ 请稍候,下载中... (可能需要 2-10 分钟)", file=sys.stderr)
|
|
336
|
+
print("", file=sys.stderr)
|
|
337
|
+
|
|
338
|
+
# Run subprocess with real-time output streaming
|
|
339
|
+
process = await asyncio.create_subprocess_exec(
|
|
340
|
+
*cmd,
|
|
341
|
+
stdout=asyncio.subprocess.PIPE,
|
|
342
|
+
stderr=asyncio.subprocess.PIPE,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Stream stdout in real time
|
|
346
|
+
task_stdout = asyncio.create_task(_stream_output(process.stdout, "stdout"))
|
|
347
|
+
task_stderr = asyncio.create_task(_stream_output(process.stderr, "stderr"))
|
|
348
|
+
|
|
349
|
+
returncode = await process.wait()
|
|
350
|
+
stdout = await task_stdout
|
|
351
|
+
stderr = await task_stderr
|
|
352
|
+
|
|
353
|
+
if returncode == 0:
|
|
354
|
+
print("\n" + "=" * 70, file=sys.stderr)
|
|
355
|
+
print("✅ 下载完成!", file=sys.stderr)
|
|
356
|
+
print("=" * 70, file=sys.stderr)
|
|
357
|
+
print("📊 处理结果中...", file=sys.stderr)
|
|
358
|
+
print("", file=sys.stderr)
|
|
359
|
+
|
|
360
|
+
# Parse output directories from stdout
|
|
361
|
+
results = []
|
|
362
|
+
for idx, url in enumerate(urls, 1):
|
|
363
|
+
# Extract share_id and construct output dir
|
|
364
|
+
from common_utils import extract_share_id
|
|
365
|
+
|
|
366
|
+
print(f"[{idx}/{len(urls)}] 处理: {url}", file=sys.stderr)
|
|
367
|
+
|
|
368
|
+
share_id = extract_share_id(url)
|
|
369
|
+
out_dir = os.path.join(output_parent, share_id)
|
|
370
|
+
file_count = count_files_recursive(out_dir)
|
|
371
|
+
zip_path = (
|
|
372
|
+
os.path.join(output_parent, f"{share_id}.zip")
|
|
373
|
+
if create_zip
|
|
374
|
+
else None
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
print(f" ✓ 已保存 {file_count} 个文件到: {out_dir}", file=sys.stderr)
|
|
378
|
+
|
|
379
|
+
results.append(
|
|
380
|
+
DownloadResult(
|
|
381
|
+
success=True,
|
|
382
|
+
url=url,
|
|
383
|
+
output_dir=out_dir,
|
|
384
|
+
zip_path=zip_path,
|
|
385
|
+
message=f"✅ 下载成功 ({file_count} 个文件)",
|
|
386
|
+
file_count=file_count,
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Final summary
|
|
391
|
+
total_files = sum(r.file_count or 0 for r in results)
|
|
392
|
+
print("=" * 70, file=sys.stderr)
|
|
393
|
+
print(f"📈 汇总: 共下载 {total_files} 个文件", file=sys.stderr)
|
|
394
|
+
print("=" * 70, file=sys.stderr)
|
|
395
|
+
print("", file=sys.stderr)
|
|
396
|
+
return results
|
|
397
|
+
else:
|
|
398
|
+
error_msg = stderr if stderr else "Unknown error"
|
|
399
|
+
print("\n" + "=" * 70, file=sys.stderr)
|
|
400
|
+
print("❌ 下载失败", file=sys.stderr)
|
|
401
|
+
print("=" * 70, file=sys.stderr)
|
|
402
|
+
print(f"错误信息: {error_msg}", file=sys.stderr)
|
|
403
|
+
print("", file=sys.stderr)
|
|
404
|
+
return [
|
|
405
|
+
DownloadResult(
|
|
406
|
+
success=False,
|
|
407
|
+
url=urls[0] if urls else "unknown",
|
|
408
|
+
output_dir=output_parent,
|
|
409
|
+
message=f"❌ 下载失败: {error_msg}",
|
|
410
|
+
)
|
|
411
|
+
]
|
|
412
|
+
finally:
|
|
413
|
+
# Clean up temporary file
|
|
414
|
+
try:
|
|
415
|
+
os.unlink(urls_file)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ============================================================================
|
|
421
|
+
# Helper Functions for Password Extraction
|
|
422
|
+
# ============================================================================
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _extract_password_from_url(url: str) -> tuple[str, Optional[str]]:
|
|
426
|
+
"""
|
|
427
|
+
Extract security code from URL string.
|
|
428
|
+
|
|
429
|
+
Supports multiple formats:
|
|
430
|
+
- URL 安全码:8492 or URL 安全码:8492
|
|
431
|
+
- URL 密码:8492 or URL 密码:8492
|
|
432
|
+
- URL password:8492 or URL password:8492
|
|
433
|
+
- URL code:8492 or URL code:8492
|
|
434
|
+
- URL 验证码:8492 or URL 验证码:8492
|
|
435
|
+
|
|
436
|
+
Returns: (clean_url, security_code)
|
|
437
|
+
"""
|
|
438
|
+
import re
|
|
439
|
+
|
|
440
|
+
# Pattern: look for various security code indicators with both half-width and full-width colons
|
|
441
|
+
patterns = [
|
|
442
|
+
r'\s*安全码[::]\s*(\d+)', # 安全码:8492 or 安全码:8492
|
|
443
|
+
r'\s*密码[::]\s*(\d+)', # 密码:8492 or 密码:8492
|
|
444
|
+
r'\s*验证码[::]\s*(\d+)', # 验证码:8492 or 验证码:8492
|
|
445
|
+
r'\s*password[::]\s*(\S+)', # password:8492 or password:8492
|
|
446
|
+
r'\s*code[::]\s*(\d+)', # code:8492 or code:8492
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
security_code = None
|
|
450
|
+
clean_url = url
|
|
451
|
+
|
|
452
|
+
for pattern in patterns:
|
|
453
|
+
match = re.search(pattern, url)
|
|
454
|
+
if match:
|
|
455
|
+
security_code = match.group(1)
|
|
456
|
+
# Remove security code from URL
|
|
457
|
+
clean_url = re.sub(pattern, '', url).strip()
|
|
458
|
+
print(f"[dicom-mcp] 提取安全码: {security_code}", file=sys.stderr)
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
return clean_url, security_code
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ============================================================================
|
|
465
|
+
# MCP Tools
|
|
466
|
+
# ============================================================================
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@mcp.tool()
|
|
470
|
+
async def download_dicom(request: DownloadRequest) -> DownloadResult:
|
|
471
|
+
"""
|
|
472
|
+
Download DICOM images from a single medical imaging viewer URL.
|
|
473
|
+
|
|
474
|
+
Supports multiple providers:
|
|
475
|
+
- tz: 天肿 (zlyy.tjmucih.cn)
|
|
476
|
+
- fz: 复肿 (ylyyx.shdc.org.cn)
|
|
477
|
+
- nyfy: 宁夏总医院 (zhyl.nyfy.com.cn)
|
|
478
|
+
- cloud: *.medicalimagecloud.com and other cloud-based systems
|
|
479
|
+
|
|
480
|
+
**密码支持**:
|
|
481
|
+
1. 显式指定:password="安全码" 参数
|
|
482
|
+
2. URL中提取:自动识别 "URL 安全码:8492"、"URL password:8492" 等格式
|
|
483
|
+
3. 优先级:显式指定 > URL中提取
|
|
484
|
+
|
|
485
|
+
**示例**:
|
|
486
|
+
```python
|
|
487
|
+
# 方式1:显式指定密码
|
|
488
|
+
request = DownloadRequest(
|
|
489
|
+
url="https://hospital.com/viewer?id=123",
|
|
490
|
+
password="8492"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# 方式2:从URL提取密码
|
|
494
|
+
request = DownloadRequest(
|
|
495
|
+
url="https://hospital.com/viewer?id=123 安全码:8492"
|
|
496
|
+
)
|
|
497
|
+
```
|
|
498
|
+
"""
|
|
499
|
+
# Auto-extract security code from URL if not explicitly provided
|
|
500
|
+
clean_url, extracted_code = _extract_password_from_url(request.url)
|
|
501
|
+
security_code = request.password or extracted_code
|
|
502
|
+
|
|
503
|
+
os.makedirs(request.output_dir, exist_ok=True)
|
|
504
|
+
|
|
505
|
+
# ✨ 改进:使用 passwords 字典保留映射关系
|
|
506
|
+
passwords_dict = {clean_url: security_code}
|
|
507
|
+
|
|
508
|
+
results = await run_multi_download(
|
|
509
|
+
[clean_url],
|
|
510
|
+
request.output_dir,
|
|
511
|
+
provider=request.provider or "auto",
|
|
512
|
+
mode=request.mode,
|
|
513
|
+
headless=request.headless,
|
|
514
|
+
passwords=passwords_dict,
|
|
515
|
+
create_zip=request.create_zip,
|
|
516
|
+
max_rounds=request.max_rounds,
|
|
517
|
+
step_wait_ms=request.step_wait_ms,
|
|
518
|
+
)
|
|
519
|
+
return results[0] if results else DownloadResult(
|
|
520
|
+
success=False,
|
|
521
|
+
url=request.url,
|
|
522
|
+
output_dir=request.output_dir,
|
|
523
|
+
message="Unknown error",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@mcp.tool()
|
|
528
|
+
async def batch_download_dicom(request: BatchDownloadRequest) -> list[DownloadResult]:
|
|
529
|
+
"""
|
|
530
|
+
Download DICOM images from multiple URLs in batch.
|
|
531
|
+
|
|
532
|
+
**多链接+密码映射支持**(确保URL与密码的准确匹配)
|
|
533
|
+
|
|
534
|
+
Each URL gets its own subdirectory with its corresponding password.
|
|
535
|
+
Supports auto-detection of provider based on domain, or manual provider specification.
|
|
536
|
+
|
|
537
|
+
**密码配置方式**(按优先级):
|
|
538
|
+
1. passwords 字典映射(推荐):URLs 与密码一一对应
|
|
539
|
+
- 格式:passwords={"url1": "pwd1", "url2": "pwd2", "url3": None}
|
|
540
|
+
- 优势:清晰明确,不易出错,最安全
|
|
541
|
+
- 最佳实践:生产环境强烈推荐
|
|
542
|
+
|
|
543
|
+
2. password 全局密码:所有 URLs 共用同一密码
|
|
544
|
+
- 格式:password="1234"
|
|
545
|
+
- 适用场景:所有URL需要同一密码
|
|
546
|
+
|
|
547
|
+
3. URL中嵌入密码:自动提取
|
|
548
|
+
- 格式:"URL 安全码:8492"、"URL password:8492"
|
|
549
|
+
- 自动处理,无需额外配置
|
|
550
|
+
|
|
551
|
+
**密码优先级**(高→低):
|
|
552
|
+
passwords字典 > password全局 > URL中提取 > None(无密码)
|
|
553
|
+
|
|
554
|
+
**示例**:
|
|
555
|
+
```python
|
|
556
|
+
# ✨ 推荐:多URL多密码精确映射
|
|
557
|
+
request = BatchDownloadRequest(
|
|
558
|
+
urls=[
|
|
559
|
+
"https://hospital1.com/viewer?id=A",
|
|
560
|
+
"https://hospital2.com/viewer?id=B",
|
|
561
|
+
"https://hospital3.com/viewer?id=C"
|
|
562
|
+
],
|
|
563
|
+
passwords={
|
|
564
|
+
"https://hospital1.com/viewer?id=A": "password_A",
|
|
565
|
+
"https://hospital2.com/viewer?id=B": "password_B",
|
|
566
|
+
"https://hospital3.com/viewer?id=C": None # 无密码
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
# 结果:URL_A + password_A、URL_B + password_B、URL_C + None
|
|
570
|
+
```
|
|
571
|
+
"""
|
|
572
|
+
# ========== 密码处理逻辑 ==========
|
|
573
|
+
clean_urls = []
|
|
574
|
+
url_password_dict: Dict[str, Optional[str]] = {}
|
|
575
|
+
|
|
576
|
+
for url in request.urls:
|
|
577
|
+
clean_url, code = _extract_password_from_url(url)
|
|
578
|
+
clean_urls.append(clean_url)
|
|
579
|
+
|
|
580
|
+
# 优先级:passwords字典 > password全局 > URL中提取的密码
|
|
581
|
+
if request.passwords and clean_url in request.passwords:
|
|
582
|
+
pwd = request.passwords[clean_url]
|
|
583
|
+
elif request.passwords and url in request.passwords:
|
|
584
|
+
pwd = request.passwords[url]
|
|
585
|
+
elif request.password:
|
|
586
|
+
pwd = request.password
|
|
587
|
+
else:
|
|
588
|
+
pwd = code
|
|
589
|
+
|
|
590
|
+
url_password_dict[clean_url] = pwd
|
|
591
|
+
pwd_display = f"({len(pwd)} 位)" if pwd else "(无密码)"
|
|
592
|
+
print(
|
|
593
|
+
f"[batch_download_dicom] {clean_url[:50]}... -> {pwd_display}",
|
|
594
|
+
file=sys.stderr
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
os.makedirs(request.output_parent, exist_ok=True)
|
|
598
|
+
return await run_multi_download(
|
|
599
|
+
clean_urls,
|
|
600
|
+
request.output_parent,
|
|
601
|
+
provider=request.provider,
|
|
602
|
+
mode=request.mode,
|
|
603
|
+
headless=request.headless,
|
|
604
|
+
password=security_code,
|
|
605
|
+
create_zip=request.create_zip,
|
|
606
|
+
max_rounds=request.max_rounds,
|
|
607
|
+
step_wait_ms=request.step_wait_ms,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@mcp.tool()
|
|
612
|
+
def detect_provider_from_url(url: str) -> dict:
|
|
613
|
+
"""
|
|
614
|
+
Detect which DICOM provider a URL belongs to.
|
|
615
|
+
|
|
616
|
+
Returns the detected provider and related information.
|
|
617
|
+
"""
|
|
618
|
+
provider = detect_provider(url)
|
|
619
|
+
providers_info = {
|
|
620
|
+
"tz": ProviderInfo(
|
|
621
|
+
name="tz",
|
|
622
|
+
display_name="天肿 (圆心云影)",
|
|
623
|
+
domains=["zlyy.tjmucih.cn"],
|
|
624
|
+
description="Tianjin Medical University Cancer Institute DICOM viewer",
|
|
625
|
+
),
|
|
626
|
+
"fz": ProviderInfo(
|
|
627
|
+
name="fz",
|
|
628
|
+
display_name="复肿 (复旦肿瘤医院)",
|
|
629
|
+
domains=["ylyyx.shdc.org.cn"],
|
|
630
|
+
description="Fudan University Cancer Hospital DICOM viewer",
|
|
631
|
+
),
|
|
632
|
+
"nyfy": ProviderInfo(
|
|
633
|
+
name="nyfy",
|
|
634
|
+
display_name="宁夏总医院",
|
|
635
|
+
domains=["zhyl.nyfy.com.cn"],
|
|
636
|
+
description="Ningxia General Hospital DICOM viewer with WebSocket support",
|
|
637
|
+
),
|
|
638
|
+
"cloud": ProviderInfo(
|
|
639
|
+
name="cloud",
|
|
640
|
+
display_name="Cloud DICOM Services",
|
|
641
|
+
domains=[
|
|
642
|
+
"*.medicalimagecloud.com",
|
|
643
|
+
"mdmis.cq12320.cn",
|
|
644
|
+
"qr.szjudianyun.com",
|
|
645
|
+
"zscloud.zs-hospital.sh.cn",
|
|
646
|
+
"app.ftimage.cn",
|
|
647
|
+
"yyx.ftimage.cn",
|
|
648
|
+
],
|
|
649
|
+
description="Cloud-based DICOM image systems (Medical Image Cloud and others)",
|
|
650
|
+
),
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
info = providers_info.get(provider)
|
|
654
|
+
return {
|
|
655
|
+
"url": url,
|
|
656
|
+
"detected_provider": provider,
|
|
657
|
+
"provider_info": info.model_dump() if info else None,
|
|
658
|
+
"is_auto_detected": True,
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@mcp.tool()
|
|
663
|
+
def list_supported_providers() -> list[ProviderInfo]:
|
|
664
|
+
"""
|
|
665
|
+
List all supported DICOM providers and their capabilities.
|
|
666
|
+
|
|
667
|
+
Returns information about each provider including supported domains
|
|
668
|
+
and download modes.
|
|
669
|
+
"""
|
|
670
|
+
return [
|
|
671
|
+
ProviderInfo(
|
|
672
|
+
name="tz",
|
|
673
|
+
display_name="天肿 (圆心云影)",
|
|
674
|
+
domains=["zlyy.tjmucih.cn"],
|
|
675
|
+
description="Tianjin Medical University Cancer Institute DICOM viewer. Supports diag/nondiag/all modes",
|
|
676
|
+
),
|
|
677
|
+
ProviderInfo(
|
|
678
|
+
name="fz",
|
|
679
|
+
display_name="复肿 (复旦肿瘤医院)",
|
|
680
|
+
domains=["ylyyx.shdc.org.cn"],
|
|
681
|
+
description="Fudan University Cancer Hospital DICOM viewer. Supports high-definition switching and frame-by-frame playback",
|
|
682
|
+
),
|
|
683
|
+
ProviderInfo(
|
|
684
|
+
name="nyfy",
|
|
685
|
+
display_name="宁夏总医院",
|
|
686
|
+
domains=["zhyl.nyfy.com.cn"],
|
|
687
|
+
description="Ningxia General Hospital DICOM viewer. Uses WebSocket metadata and h5Cache for pixel data",
|
|
688
|
+
),
|
|
689
|
+
ProviderInfo(
|
|
690
|
+
name="cloud",
|
|
691
|
+
display_name="Cloud DICOM Services",
|
|
692
|
+
domains=[
|
|
693
|
+
"*.medicalimagecloud.com",
|
|
694
|
+
"mdmis.cq12320.cn",
|
|
695
|
+
"qr.szjudianyun.com",
|
|
696
|
+
"zscloud.zs-hospital.sh.cn",
|
|
697
|
+
"app.ftimage.cn",
|
|
698
|
+
"yyx.ftimage.cn",
|
|
699
|
+
"m.yzhcloud.com",
|
|
700
|
+
"ss.mtywcloud.com",
|
|
701
|
+
"work.sugh.net",
|
|
702
|
+
"cloudpacs.jdyfy.com",
|
|
703
|
+
],
|
|
704
|
+
description="Cloud-based DICOM image systems including Medical Image Cloud and hospital cloud systems",
|
|
705
|
+
),
|
|
706
|
+
]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@mcp.tool()
|
|
710
|
+
def validate_url(url: str) -> dict:
|
|
711
|
+
"""
|
|
712
|
+
Validate if a URL is from a supported DICOM provider.
|
|
713
|
+
|
|
714
|
+
Returns validation status and suggested provider.
|
|
715
|
+
"""
|
|
716
|
+
try:
|
|
717
|
+
parsed = urlparse(url)
|
|
718
|
+
if not parsed.scheme or not parsed.netloc:
|
|
719
|
+
return {
|
|
720
|
+
"valid": False,
|
|
721
|
+
"url": url,
|
|
722
|
+
"error": "Invalid URL format",
|
|
723
|
+
"suggestion": "URL must include scheme (http/https) and domain",
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
provider = detect_provider(url)
|
|
727
|
+
return {
|
|
728
|
+
"valid": True,
|
|
729
|
+
"url": url,
|
|
730
|
+
"provider": provider,
|
|
731
|
+
"message": f"URL belongs to {provider} provider",
|
|
732
|
+
}
|
|
733
|
+
except Exception as e:
|
|
734
|
+
return {
|
|
735
|
+
"valid": False,
|
|
736
|
+
"url": url,
|
|
737
|
+
"error": str(e),
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ============================================================================
|
|
742
|
+
# Server Entry Point
|
|
743
|
+
# ============================================================================
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def main():
|
|
747
|
+
"""Start the MCP server."""
|
|
748
|
+
mcp.run(transport="stdio")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
if __name__ == "__main__":
|
|
752
|
+
main()
|