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.
@@ -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()