Skip to main content
Glama

DICOM-MCP

by shaunporwal
server.py15 kB
import asyncio import os import json from pathlib import Path from typing import Dict, List, Optional, Any, Union from mcp.server.models import InitializationOptions import mcp.types as types from mcp.server import NotificationOptions, Server from pydantic import AnyUrl, BaseModel import mcp.server.stdio # Import our DICOM processing tools from dicom_mcp.dicom_tools import ( scan_directory, load_dicom_image, load_dicom_seg, extract_dicom_metadata, crop_image, crop_itk_image, DicomSeries ) # Storage for DICOM series and other data notes: dict[str, str] = {} # Keep the notes functionality for backward compatibility dicom_data: Dict[str, DicomSeries] = {} # Store DICOM series info dicom_cache: Dict[str, Any] = {} # Cache for loaded images and metadata server = Server("DICOM-MCP") @server.list_resources() async def handle_list_resources() -> list[types.Resource]: """ List available resources including notes and DICOM series. Resources are exposed with appropriate URI schemes (note:// for notes, dicom:// for DICOM). """ resources = [ types.Resource( uri=AnyUrl(f"note://internal/{name}"), name=f"Note: {name}", description=f"A simple note named {name}", mimeType="text/plain", ) for name in notes ] # Add DICOM series as resources for series_uid, series in dicom_data.items(): modality = series.modality or "Unknown" description = series.description or f"DICOM Series {series_uid}" resources.append( types.Resource( uri=AnyUrl(f"dicom://series/{series_uid}"), name=f"{modality}: {description}", description=f"DICOM Series with {series.file_count} files. Patient ID: {series.patient_id or 'Unknown'}", mimeType="application/dicom", ) ) return resources @server.read_resource() async def handle_read_resource(uri: AnyUrl) -> Union[str, types.TextContent, types.ImageContent]: """ Read a specific resource's content by its URI. Supports both note:// and dicom:// URI schemes. """ scheme = uri.scheme path = uri.path.lstrip("/") if uri.path else "" if scheme == "note": if path in notes: return notes[path] raise ValueError(f"Note not found: {path}") elif scheme == "dicom": # Extract series UID from the path parts = path.split("/") if len(parts) < 2 or parts[0] != "series": raise ValueError(f"Invalid DICOM URI format: {uri}") series_uid = parts[1] if series_uid not in dicom_data: raise ValueError(f"DICOM series not found: {series_uid}") # For now, return metadata as JSON string series = dicom_data[series_uid] return types.TextContent( type="text", text=json.dumps(series.dict(), indent=2) ) else: raise ValueError(f"Unsupported URI scheme: {scheme}") @server.list_prompts() async def handle_list_prompts() -> list[types.Prompt]: """ List available prompts. Each prompt can have optional arguments to customize its behavior. """ return [ types.Prompt( name="summarize-notes", description="Creates a summary of all notes", arguments=[ types.PromptArgument( name="style", description="Style of the summary (brief/detailed)", required=False, ) ], ) ] @server.get_prompt() async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: """ Generate a prompt by combining arguments with server state. The prompt includes all current notes and can be customized via arguments. """ if name != "summarize-notes": raise ValueError(f"Unknown prompt: {name}") style = (arguments or {}).get("style", "brief") detail_prompt = " Give extensive details." if style == "detailed" else "" return types.GetPromptResult( description="Summarize the current notes", messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Here are the current notes to summarize:{detail_prompt}\n\n" + "\n".join( f"- {name}: {content}" for name, content in notes.items() ), ), ) ], ) @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """ List available tools including DICOM-specific tools. Each tool specifies its arguments using JSON Schema validation. """ return [ types.Tool( name="add-note", description="Add a new note", inputSchema={ "type": "object", "properties": { "name": {"type": "string"}, "content": {"type": "string"}, }, "required": ["name", "content"], }, ), types.Tool( name="scan-dicom-directory", description="Scan a directory for DICOM files and organize them into series", inputSchema={ "type": "object", "properties": { "directory": {"type": "string", "description": "Path to directory containing DICOM files"}, }, "required": ["directory"], }, ), types.Tool( name="extract-dicom-metadata", description="Extract detailed metadata from a DICOM file", inputSchema={ "type": "object", "properties": { "dicom_file": {"type": "string", "description": "Path to a DICOM file"}, }, "required": ["dicom_file"], }, ), types.Tool( name="load-dicom-series", description="Load a DICOM series into memory for processing", inputSchema={ "type": "object", "properties": { "series_uid": {"type": "string", "description": "Series UID of the DICOM series to load"}, }, "required": ["series_uid"], }, ), types.Tool( name="load-dicom-seg", description="Load a DICOM SEG file and associate it with a reference image", inputSchema={ "type": "object", "properties": { "seg_file": {"type": "string", "description": "Path to a DICOM SEG file"}, "reference_series_uid": {"type": "string", "description": "Series UID of the reference image"}, }, "required": ["seg_file"], }, ), types.Tool( name="crop-dicom-image", description="Crop a loaded DICOM image by removing boundary percentage", inputSchema={ "type": "object", "properties": { "series_uid": {"type": "string", "description": "Series UID of the loaded DICOM image"}, "boundary_percentage": {"type": "number", "description": "Percentage of image to crop from each boundary (0.0-0.5)"}, }, "required": ["series_uid"], }, ) ] @server.call_tool() async def handle_call_tool( name: str, arguments: dict | None ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: """ Handle tool execution requests for both basic note tools and DICOM-specific tools. Tools can modify server state and notify clients of changes. """ if not arguments: raise ValueError("Missing arguments") # Handle the original add-note tool if name == "add-note": note_name = arguments.get("name") content = arguments.get("content") if not note_name or not content: raise ValueError("Missing name or content") # Update server state notes[note_name] = content # Notify clients that resources have changed await server.request_context.session.send_resource_list_changed() return [ types.TextContent( type="text", text=f"Added note '{note_name}' with content: {content}", ) ] # DICOM Tools elif name == "scan-dicom-directory": directory = arguments.get("directory") if not directory or not os.path.isdir(directory): raise ValueError(f"Invalid directory: {directory}") # Scan the directory for DICOM files series_list = scan_directory(directory) # Update state with found series for series in series_list: dicom_data[series.series_uid] = series # Notify clients that resources have changed await server.request_context.session.send_resource_list_changed() return [ types.TextContent( type="text", text=f"Found {len(series_list)} DICOM series in {directory}:\n" + "\n".join([f"- {s.modality or 'Unknown'}: {s.description or s.series_uid} ({s.file_count} files)" for s in series_list]) ) ] elif name == "extract-dicom-metadata": dicom_file = arguments.get("dicom_file") if not dicom_file or not os.path.isfile(dicom_file): raise ValueError(f"Invalid DICOM file: {dicom_file}") # Extract metadata metadata = extract_dicom_metadata(dicom_file) return [ types.TextContent( type="text", text=json.dumps(metadata, indent=2, default=str) ) ] elif name == "load-dicom-series": series_uid = arguments.get("series_uid") if not series_uid or series_uid not in dicom_data: raise ValueError(f"Invalid or unknown series UID: {series_uid}") series = dicom_data[series_uid] # Load the DICOM series image_array, metadata = load_dicom_image(series.path) # Cache the loaded data dicom_cache[series_uid] = { "image": image_array, "metadata": metadata } # Return summary information shape = image_array.shape intensity_range = (float(image_array.min()), float(image_array.max())) return [ types.TextContent( type="text", text=f"Loaded DICOM series {series_uid}\n" + f"Shape: {shape}\n" + f"Intensity range: {intensity_range}\n" + f"Metadata: {json.dumps(metadata, indent=2, default=str)}" ) ] elif name == "load-dicom-seg": seg_file = arguments.get("seg_file") reference_series_uid = arguments.get("reference_series_uid") if not seg_file or not os.path.isfile(seg_file): raise ValueError(f"Invalid DICOM SEG file: {seg_file}") # Get reference image if provided reference_image = None if reference_series_uid: if reference_series_uid not in dicom_cache: raise ValueError(f"Reference series {reference_series_uid} not loaded. Use load-dicom-series first.") reference_image = dicom_cache[reference_series_uid]["image"] # Load the DICOM SEG seg_array, seg_metadata = load_dicom_seg(seg_file, reference_image) # Generate a unique ID for this segmentation seg_uid = f"seg_{Path(seg_file).stem}" # Cache the loaded data dicom_cache[seg_uid] = { "image": seg_array, "metadata": seg_metadata, "reference": reference_series_uid } # Return summary information shape = seg_array.shape segment_count = len(seg_metadata.get("segment_info", [])) return [ types.TextContent( type="text", text=f"Loaded DICOM SEG {seg_uid}\n" + f"Shape: {shape}\n" + f"Segments: {segment_count}\n" + f"Metadata: {json.dumps(seg_metadata, indent=2, default=str)}" ) ] elif name == "crop-dicom-image": series_uid = arguments.get("series_uid") boundary_percentage = float(arguments.get("boundary_percentage", 0.2)) if not series_uid or series_uid not in dicom_cache: raise ValueError(f"Invalid or not loaded series UID: {series_uid}. Use load-dicom-series first.") if boundary_percentage <= 0 or boundary_percentage >= 0.5: raise ValueError("Boundary percentage must be between 0 and 0.5") # Get the cached image cached_data = dicom_cache[series_uid] original_image = cached_data["image"] # Crop the image cropped_image = crop_image(original_image, boundary_percentage) # Cache the cropped image with a new ID cropped_uid = f"{series_uid}_cropped_{int(boundary_percentage*100)}" dicom_cache[cropped_uid] = { "image": cropped_image, "metadata": cached_data["metadata"], "original": series_uid, "crop_percentage": boundary_percentage } # Return summary information original_shape = original_image.shape cropped_shape = cropped_image.shape return [ types.TextContent( type="text", text=f"Cropped DICOM image {series_uid} to {cropped_uid}\n" + f"Original shape: {original_shape}\n" + f"Cropped shape: {cropped_shape}\n" + f"Boundary percentage: {boundary_percentage}" ) ] else: raise ValueError(f"Unknown tool: {name}") async def main(): # Run the server using stdin/stdout streams async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, InitializationOptions( server_name="DICOM-MCP", server_version="0.1.0", capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, ), ), )

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/shaunporwal/DICOM-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server