diff --git a/.gitignore b/.gitignore index 9c6a1374..d4bd87d4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ intermediate-findings/ playwright-report/ test-results/ __pycache__/ -*.pyc \ No newline at end of file +*.pyc +/examples/dicom-viewer-mcp-app/dicom diff --git a/examples/dicom-viewer-mcp-app/.gitignore b/examples/dicom-viewer-mcp-app/.gitignore new file mode 100644 index 00000000..3bdd52eb --- /dev/null +++ b/examples/dicom-viewer-mcp-app/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/examples/dicom-viewer-mcp-app/LICENSE b/examples/dicom-viewer-mcp-app/LICENSE new file mode 100644 index 00000000..35ce9845 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Thales Matheus Mendonça Santos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/dicom-viewer-mcp-app/README.md b/examples/dicom-viewer-mcp-app/README.md new file mode 100644 index 00000000..52387dc9 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/README.md @@ -0,0 +1,180 @@ +# DICOM Viewer MCP App + +A prototype MCP (Model Context Protocol) App that displays DICOM medical images directly in Claude Desktop. Built using the [MCP Apps SDK](https://github.com/modelcontextprotocol/ext-apps). + +![DICOM Viewer Screenshot](screenshot.png) + +## MCP Client Configuration + +Add to your MCP client configuration (stdio transport): + +```json +{ + "mcpServers": { + "dicom-viewer": { + "command": "npx", + "args": [ + "-y", + "--silent", + "--registry=https://registry.npmjs.org/", + "@modelcontextprotocol/server-dicom-viewer", + "--stdio" + ] + } + } +} +``` + +### Local Development + +To test local modifications, use this configuration (replace `~/code/ext-apps` with your clone path): + +```json +{ + "mcpServers": { + "dicom-viewer": { + "command": "bash", + "args": [ + "-c", + "cd ~/code/ext-apps/examples/dicom-viewer-mcp-app && npm run build >&2 && node dist/index.js --stdio" + ] + } + } +} +``` + +## Features + +- View DICOM medical images directly within Claude Desktop +- **Series navigation** - browse through multiple slices with slider, buttons, or scroll wheel +- **Keyboard shortcuts** - arrow keys for navigation, Home/End for first/last slice +- Pan and zoom controls for image inspection +- Displays image metadata (dimensions, bit depth, instance number) +- Server-side rendering to JPEG using `dicom-parser` and `sharp` +- Handles DICOM window/level and rescale slope/intercept +- Automatic sorting by Instance Number or Slice Location + +## How It Works + +1. The MCP server scans the `./dicom/` folder for all `.dcm` files +2. Parses each DICOM file using `dicom-parser` +3. Extracts slice metadata and sorts by Instance Number or Slice Location +4. Sends a compact HTML UI resource to the host (no embedded images) +5. The client requests slices on-demand via the `get-dicom-slice` tool +6. The server converts each slice to JPEG with proper windowing and returns it + +This approach avoids CSP (Content Security Policy) restrictions in Claude Desktop by doing all DICOM processing server-side. + +## Prerequisites + +- Node.js 18+ +- Claude Desktop with MCP support + +## Installation + +```bash +# From the ext-apps repository root +cd examples/dicom-viewer-mcp-app + +# Install dependencies (if not already done at repo root) +npm install + +# Build the project +npm run build +``` + +## Usage + +### 1. Add your DICOM files + +Place your DICOM files (`.dcm`) in the `./dicom/` folder: + +``` +./dicom/ +├── IM-0001-0001.dcm +├── IM-0001-0002.dcm +├── IM-0001-0003.dcm +└── ... +``` + +### 2. Configure Claude Desktop + +Add the server to your Claude Desktop configuration using the MCP Client Configuration section above. + +**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` + +### 3. Restart Claude Desktop + +After updating the configuration, fully quit and restart Claude Desktop. + +### 4. View DICOM images + +Ask Claude to "show me a DICOM study" or "use the view-dicom tool". + +## Controls + +| Action | Control | +| -------------------------- | ------------------------------------ | +| Navigate slices | Scroll wheel, slider, or ◀/▶ buttons | +| Navigate slices (keyboard) | Arrow keys (←/→ or ↑/↓) | +| First/Last slice | Home / End keys | +| Zoom | Ctrl + Scroll wheel, or +/− buttons | +| Pan | Click and drag | +| Reset view | Reset button | + +## Development + +```bash +# Build and run in development mode +npm start + +# Or run with hot reload +npm run dev +``` + +## Project Structure + +``` +dicom-viewer-mcp-app/ +├── dicom/ # Place DICOM files here (a single series) +│ └── .gitkeep +├── src/ +│ ├── mcp-app.tsx # React client component +│ ├── mcp-app.module.css # Styles +│ └── global.css +├── server.ts # MCP server with DICOM processing +├── main.ts # Entry point (stdio/HTTP transport) +├── mcp-app.html # HTML template +├── package.json +└── README.md +``` + +## Technical Details + +### DICOM Processing + +The server handles: + +- 8-bit and 16-bit pixel data (signed and unsigned) +- MONOCHROME1 and MONOCHROME2 photometric interpretations +- Rescale slope and intercept transformations +- Window center and window width adjustments +- Automatic slice sorting by Instance Number or Slice Location + +### Supported DICOM Transfer Syntaxes + +Currently supports uncompressed DICOM files (Explicit/Implicit VR Little Endian). Compressed transfer syntaxes (JPEG, JPEG 2000, etc.) are not yet supported. + +## Limitations + +- Uncompressed DICOM only (no JPEG/JPEG2000 compression) +- Slice images are fetched on demand (first load per slice can be slow) +- Single series at a time + +## Author + +**Thales Matheus** - [GitHub](https://github.com/ThalesMMS) + +## License + +MIT diff --git a/examples/dicom-viewer-mcp-app/dicom/.gitkeep b/examples/dicom-viewer-mcp-app/dicom/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/dicom-viewer-mcp-app/main.ts b/examples/dicom-viewer-mcp-app/main.ts new file mode 100644 index 00000000..8264f2e8 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/main.ts @@ -0,0 +1,93 @@ +/** + * Entry point for running the MCP server. + * Run with: npx @modelcontextprotocol/server-dicom-viewer + * Or: node dist/index.js [--stdio] + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; +import { createServer } from "./server.js"; + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + */ +export async function startStreamableHTTPServer( + createServer: () => McpServer, +): Promise { + const port = parseInt(process.env.PORT ?? "3001", 10); + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`MCP server listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +/** + * Starts an MCP server with stdio transport. + * + * @param createServer - Factory function that creates a new McpServer instance. + */ +export async function startStdioServer( + createServer: () => McpServer, +): Promise { + await createServer().connect(new StdioServerTransport()); +} + +async function main() { + if (process.argv.includes("--stdio")) { + await startStdioServer(createServer); + } else { + await startStreamableHTTPServer(createServer); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/dicom-viewer-mcp-app/mcp-app.html b/examples/dicom-viewer-mcp-app/mcp-app.html new file mode 100644 index 00000000..ef15a4c8 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/mcp-app.html @@ -0,0 +1,14 @@ + + + + + + + DICOM Viewer + + + +
+ + + diff --git a/examples/dicom-viewer-mcp-app/package.json b/examples/dicom-viewer-mcp-app/package.json new file mode 100644 index 00000000..4a6936c5 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/package.json @@ -0,0 +1,58 @@ +{ + "name": "@modelcontextprotocol/server-dicom-viewer", + "version": "1.0.0", + "type": "module", + "description": "A prototype MCP App that displays DICOM medical images in Claude Desktop", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/dicom-viewer-mcp-app" + }, + "license": "MIT", + "main": "dist/server.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"", + "watch": "cross-env INPUT=mcp-app.html vite build --watch", + "serve": "bun --watch main.ts", + "start": "cross-env NODE_ENV=development npm run build && npm run serve", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "cors": "^2.8.5", + "dicom-parser": "^1.8.21", + "express": "^5.1.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sharp": "^0.33.5", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "22.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + }, + "types": "dist/server.d.ts", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "bin": { + "mcp-server-dicom-viewer": "dist/index.js" + } +} diff --git a/examples/dicom-viewer-mcp-app/screenshot.png b/examples/dicom-viewer-mcp-app/screenshot.png new file mode 100644 index 00000000..ac1c6c33 Binary files /dev/null and b/examples/dicom-viewer-mcp-app/screenshot.png differ diff --git a/examples/dicom-viewer-mcp-app/server.ts b/examples/dicom-viewer-mcp-app/server.ts new file mode 100644 index 00000000..4fc85464 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/server.ts @@ -0,0 +1,535 @@ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; +import dicomParser from "dicom-parser"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { z } from "zod"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const IS_SOURCE = __filename.endsWith(".ts"); + +// Works both from source (server.ts) and compiled (dist/server.js) +const DIST_DIR = IS_SOURCE ? path.join(__dirname, "dist") : __dirname; + +// Path to the DICOM files (relative to project root) +const DICOM_DIR = IS_SOURCE + ? path.join(__dirname, "dicom") + : path.join(__dirname, "..", "dicom"); + +interface DicomImageInfo { + filename: string; + width: number; + height: number; + bitsAllocated: number; + bitsStored: number; + highBit: number; + pixelRepresentation: number; + samplesPerPixel: number; + photometricInterpretation: string; + windowCenter?: number; + windowWidth?: number; + rescaleSlope: number; + rescaleIntercept: number; + instanceNumber?: number; + sliceLocation?: number; + seriesDescription?: string; + patientName?: string; + studyDescription?: string; +} + +/** Max pixel dimension (width or height) for the output image. */ +const MAX_IMAGE_DIM = 1024; + +interface DicomSlice { + info: DicomImageInfo; + base64: string; +} + +interface DicomSeriesIndex { + files: string[]; + infos: DicomImageInfo[]; + key: string; +} + +let cachedSeriesIndex: DicomSeriesIndex | null = null; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function buildErrorHtml( + title: string, + message: string, + details: string[] = [], +): string { + const safeTitle = escapeHtml(title); + const safeMessage = escapeHtml(message); + const detailsMarkup = details.length + ? `` + : ""; + + return ` + + + + + + ${safeTitle} + + + +
+
+
${safeTitle}
+
${safeMessage}
+ ${detailsMarkup} +
+
+ +`; +} + +function injectRootFallback(html: string): string { + const fallbackMarkup = `
Loading DICOM Viewer...
If this message stays visible, the UI script may have failed to load or was blocked by CSP.
`; + return html.replace(/
]*>\s*<\/div>/, fallbackMarkup); +} + +async function loadDicomSeriesIndex(): Promise { + const files = await fs.readdir(DICOM_DIR); + const dcmFiles = files.filter((f) => f.toLowerCase().endsWith(".dcm")).sort(); + + if (dcmFiles.length === 0) { + throw new Error("No DICOM files found in ./dicom/ folder"); + } + + const key = dcmFiles.join("|"); + if (cachedSeriesIndex && cachedSeriesIndex.key === key) { + return cachedSeriesIndex; + } + + const entries: Array<{ filename: string; info: DicomImageInfo }> = []; + + for (const filename of dcmFiles) { + try { + const filePath = path.join(DICOM_DIR, filename); + const buffer = await fs.readFile(filePath); + const dataSet = dicomParser.parseDicom(new Uint8Array(buffer)); + const info = getDicomImageInfo(dataSet, filename); + entries.push({ filename, info }); + } catch (err) { + console.error(`Error reading DICOM metadata for ${filename}:`, err); + } + } + + if (entries.length === 0) { + throw new Error("No valid DICOM images could be processed"); + } + + entries.sort((a, b) => { + if ( + a.info.instanceNumber !== undefined && + b.info.instanceNumber !== undefined + ) { + return a.info.instanceNumber - b.info.instanceNumber; + } + if ( + a.info.sliceLocation !== undefined && + b.info.sliceLocation !== undefined + ) { + return a.info.sliceLocation - b.info.sliceLocation; + } + return a.info.filename.localeCompare(b.info.filename); + }); + + cachedSeriesIndex = { + key, + files: entries.map((entry) => entry.filename), + infos: entries.map((entry) => entry.info), + }; + + return cachedSeriesIndex; +} + +/** + * Extracts image info from a DICOM dataset + */ +function getDicomImageInfo( + dataSet: dicomParser.DataSet, + filename: string, +): DicomImageInfo { + return { + filename, + width: dataSet.uint16("x00280011") ?? 0, + height: dataSet.uint16("x00280010") ?? 0, + bitsAllocated: dataSet.uint16("x00280100") ?? 16, + bitsStored: dataSet.uint16("x00280101") ?? 12, + highBit: dataSet.uint16("x00280102") ?? 11, + pixelRepresentation: dataSet.uint16("x00280103") ?? 0, + samplesPerPixel: dataSet.uint16("x00280002") ?? 1, + photometricInterpretation: dataSet.string("x00280004") ?? "MONOCHROME2", + windowCenter: dataSet.floatString("x00281050"), + windowWidth: dataSet.floatString("x00281051"), + rescaleSlope: dataSet.floatString("x00281053") ?? 1, + rescaleIntercept: dataSet.floatString("x00281052") ?? 0, + instanceNumber: dataSet.intString("x00200013"), + sliceLocation: dataSet.floatString("x00201041"), + seriesDescription: dataSet.string("x0008103e"), + patientName: dataSet.string("x00100010"), + studyDescription: dataSet.string("x00081030"), + }; +} + +/** + * Converts DICOM pixel data to a JPEG image buffer + */ +async function dicomToPng( + dicomBuffer: Buffer, + filename: string, +): Promise { + // Parse the DICOM file + const byteArray = new Uint8Array(dicomBuffer); + const dataSet = dicomParser.parseDicom(byteArray); + + // Get image info + const info = getDicomImageInfo(dataSet, filename); + const { + width, + height, + bitsAllocated, + pixelRepresentation, + photometricInterpretation, + } = info; + + if (width === 0 || height === 0) { + throw new Error(`Invalid DICOM image dimensions in ${filename}`); + } + + // Get pixel data element + const pixelDataElement = dataSet.elements.x7fe00010; + if (!pixelDataElement) { + throw new Error(`No pixel data found in ${filename}`); + } + + // Extract raw pixel data + const pixelData = new Uint8Array( + dicomBuffer.buffer, + dicomBuffer.byteOffset + pixelDataElement.dataOffset, + pixelDataElement.length, + ); + + // Convert to 16-bit array if needed + let pixels: Int16Array | Uint16Array; + if (bitsAllocated === 16) { + if (pixelRepresentation === 1) { + pixels = new Int16Array( + pixelData.buffer, + pixelData.byteOffset, + pixelData.length / 2, + ); + } else { + pixels = new Uint16Array( + pixelData.buffer, + pixelData.byteOffset, + pixelData.length / 2, + ); + } + } else if (bitsAllocated === 8) { + pixels = new Uint16Array(pixelData.length); + for (let i = 0; i < pixelData.length; i++) { + pixels[i] = pixelData[i]; + } + } else { + throw new Error(`Unsupported bits allocated: ${bitsAllocated}`); + } + + // Apply rescale slope and intercept, find min/max + let minVal = Infinity; + let maxVal = -Infinity; + const rescaledPixels = new Float32Array(pixels.length); + + for (let i = 0; i < pixels.length; i++) { + const val = pixels[i] * info.rescaleSlope + info.rescaleIntercept; + rescaledPixels[i] = val; + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + } + + // Use window/level if available, otherwise use min/max + let windowMin: number; + let windowMax: number; + + if (info.windowCenter !== undefined && info.windowWidth !== undefined) { + windowMin = info.windowCenter - info.windowWidth / 2; + windowMax = info.windowCenter + info.windowWidth / 2; + } else { + windowMin = minVal; + windowMax = maxVal; + } + + const windowRange = windowMax - windowMin || 1; + + // Convert to 8-bit grayscale + const grayscale = new Uint8Array(width * height); + const isMonochrome1 = photometricInterpretation === "MONOCHROME1"; + + for (let i = 0; i < rescaledPixels.length && i < grayscale.length; i++) { + let normalized = (rescaledPixels[i] - windowMin) / windowRange; + normalized = Math.max(0, Math.min(1, normalized)); + + if (isMonochrome1) { + normalized = 1 - normalized; + } + + grayscale[i] = Math.round(normalized * 255); + } + + // Resize and convert to JPEG with sharp + const jpeg = await sharp(grayscale, { + raw: { + width, + height, + channels: 1, + }, + }) + .resize(MAX_IMAGE_DIM, MAX_IMAGE_DIM, { fit: "inside" }) + .jpeg({ quality: 90 }) + .toBuffer(); + + return { + info, + base64: jpeg.toString("base64"), + }; +} + +/** + * Creates a new MCP server instance with tools and resources registered. + */ +export function createServer(): McpServer { + const server = new McpServer({ + name: "DICOM Viewer MCP App Server", + version: "1.0.0", + }); + + const resourceUri = "ui://view-dicom/mcp-app.html"; + + registerAppTool( + server, + "view-dicom", + { + title: "View DICOM", + description: + "Display DICOM medical images from the ./dicom/ folder. Supports viewing entire series with navigation controls.", + inputSchema: {}, + _meta: { ui: { resourceUri } }, + }, + async (): Promise => { + return { + content: [ + { + type: "text", + text: "Displaying DICOM series images from ./dicom/ folder", + }, + ], + }; + }, + ); + + registerAppTool( + server, + "get-dicom-slice", + { + title: "Get DICOM slice", + description: "Fetch a single DICOM slice image by index.", + inputSchema: z.object({ + index: z.number().int().nonnegative(), + }), + _meta: { ui: { visibility: ["app"] } }, + }, + async (args): Promise => { + const rawIndex = + typeof args === "object" && args !== null + ? (args as any).index + : undefined; + const index = + typeof rawIndex === "number" + ? rawIndex + : Number.parseInt(String(rawIndex), 10); + + if (!Number.isInteger(index) || index < 0) { + return { + isError: true, + content: [ + { + type: "text", + text: "Invalid slice index. Expected a non-negative integer.", + }, + ], + }; + } + + let series: DicomSeriesIndex; + try { + series = await loadDicomSeriesIndex(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: message }], + }; + } + + if (index >= series.files.length) { + return { + isError: true, + content: [ + { + type: "text", + text: `Slice index out of range. Requested ${index}, but only ${series.files.length} slices are available.`, + }, + ], + }; + } + + const filename = series.files[index]; + try { + const buffer = await fs.readFile(path.join(DICOM_DIR, filename)); + const slice = await dicomToPng(buffer, filename); + return { + content: [{ type: "text", text: `Loaded slice ${index}` }], + structuredContent: { + dataUrl: `data:image/jpeg;base64,${slice.base64}`, + info: slice.info, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: message }], + }; + } + }, + ); + + // Register the HTML viewer resource; DICOM slices are converted to JPEGs on demand via get-dicom-slice + registerAppResource( + server, + resourceUri, + resourceUri, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + // Read the HTML template + let html: string; + const templatePath = path.join(DIST_DIR, "mcp-app.html"); + try { + html = await fs.readFile(templatePath, "utf-8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const errorHtml = buildErrorHtml( + "DICOM Viewer UI not built", + `Unable to read the UI bundle at ${templatePath}.`, + [ + "Run `npm run build` in examples/dicom-viewer-mcp-app to generate the UI bundle.", + "Ensure the server is running from the same project checkout you built.", + `Read error: ${message}`, + ], + ); + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }, + ], + }; + } + + // Ensure there's visible fallback content if scripts fail to run. + html = injectRootFallback(html); + + // Load DICOM metadata (no image data) + let series: DicomSeriesIndex; + try { + series = await loadDicomSeriesIndex(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const errorHtml = buildErrorHtml( + "Unable to load DICOM series", + message, + [ + "Place .dcm files in ./dicom/ (relative to the dicom-viewer-mcp-app folder).", + "Only uncompressed DICOM (Explicit/Implicit VR Little Endian) is supported.", + "Check that the process can read the files and that they are not corrupt.", + ], + ); + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }, + ], + }; + } + + // Extract series info from first slice + const seriesInfo = { + patientName: series.infos[0].patientName, + studyDescription: series.infos[0].studyDescription, + seriesDescription: series.infos[0].seriesDescription, + totalSlices: series.infos.length, + width: series.infos[0].width, + height: series.infos[0].height, + bitsStored: series.infos[0].bitsStored, + }; + + // Only inject lightweight metadata — full infos are fetched on demand + // via get-dicom-slice. Keep the payload small (Claude Desktop may have + // size-sensitive rendering). + const slimInfos = series.infos.map((info) => ({ + filename: info.filename, + instanceNumber: info.instanceNumber, + sliceLocation: info.sliceLocation, + })); + + // Inject the data + const dataScript = ``; + html = html.replace("", `${dataScript}`); + + return { + contents: [ + { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} diff --git a/examples/dicom-viewer-mcp-app/src/global.css b/examples/dicom-viewer-mcp-app/src/global.css new file mode 100644 index 00000000..18bae042 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/src/global.css @@ -0,0 +1,44 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + font-size: 1rem; + height: 100%; + min-height: 500px; + overflow: hidden; +} + +#root { + height: 100%; +} + +.fallback { + min-height: 500px; + display: flex; + align-items: center; + justify-content: center; + background: #000; + color: #fff; + text-align: center; + padding: 24px; +} + +.fallbackTitle { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; +} + +.fallbackMessage { + font-size: 12px; + color: #9ca3af; + max-width: 420px; +} diff --git a/examples/dicom-viewer-mcp-app/src/mcp-app.module.css b/examples/dicom-viewer-mcp-app/src/mcp-app.module.css new file mode 100644 index 00000000..1ec7b24c --- /dev/null +++ b/examples/dicom-viewer-mcp-app/src/mcp-app.module.css @@ -0,0 +1,271 @@ +.main { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 500px; + background: #000; + color: #fff; + font-family: + system-ui, + -apple-system, + sans-serif; +} + +.header { + padding: 8px 16px; + background: #1a1a2e; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 12px; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.title { + font-size: 14px; + font-weight: 600; + margin: 0; + white-space: nowrap; +} + +.seriesDesc { + font-size: 12px; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.info { + font-size: 11px; + color: #888; + text-align: right; + white-space: nowrap; +} + +.viewportContainer { + flex: 1; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; +} + +.image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform 0.05s ease-out; + user-select: none; +} + +.imagePlaceholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 24px; + color: #9ca3af; + font-size: 12px; +} + +.imagePlaceholderTitle { + font-size: 14px; + font-weight: 600; + color: #fff; + margin-bottom: 6px; +} + +.imagePlaceholderMessage { + font-size: 12px; + color: #9ca3af; + max-width: 320px; +} + +.sliceOverlay { + position: absolute; + top: 12px; + left: 12px; + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(0, 0, 0, 0.6); + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; +} + +.sliceNumber { + color: #fff; +} + +.instanceNumber { + font-size: 11px; + color: #888; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 500px; + color: #fff; + background: #000; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #333; + border-top-color: #0ea5e9; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 500px; + color: #ef4444; + background: #000; + padding: 20px; + text-align: center; +} + +.errorTitle { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; +} + +.errorMessage { + font-size: 14px; + color: #888; + max-width: 400px; +} + +.controls { + padding: 10px 16px; + background: #1a1a2e; + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + flex-shrink: 0; + flex-wrap: wrap; +} + +.sliceControls { + display: flex; + align-items: center; + gap: 8px; +} + +.slider { + width: 150px; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: #333; + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: #3b82f6; + border-radius: 50%; + cursor: pointer; + transition: background 0.2s; +} + +.slider::-webkit-slider-thumb:hover { + background: #2563eb; +} + +.slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: #3b82f6; + border: none; + border-radius: 50%; + cursor: pointer; +} + +.zoomControls { + display: flex; + align-items: center; + gap: 8px; +} + +.controls button { + padding: 6px 12px; + border: none; + border-radius: 4px; + background: #3b82f6; + color: #fff; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + min-width: 32px; +} + +.controls button:hover:not(:disabled) { + background: #2563eb; +} + +.controls button:active:not(:disabled) { + background: #1d4ed8; +} + +.controls button:disabled { + background: #374151; + color: #6b7280; + cursor: not-allowed; +} + +.zoomLevel { + font-size: 12px; + color: #888; + min-width: 50px; + text-align: center; +} + +.helpText { + padding: 6px 16px; + background: #0f0f1a; + font-size: 10px; + color: #666; + text-align: center; + flex-shrink: 0; +} diff --git a/examples/dicom-viewer-mcp-app/src/mcp-app.tsx b/examples/dicom-viewer-mcp-app/src/mcp-app.tsx new file mode 100644 index 00000000..cf601f9a --- /dev/null +++ b/examples/dicom-viewer-mcp-app/src/mcp-app.tsx @@ -0,0 +1,419 @@ +/** + * @file DICOM Viewer MCP App - displays server-rendered DICOM series + */ +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import { StrictMode, useCallback, useEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import styles from "./mcp-app.module.css"; + +// Get embedded data from global variables injected by the server +declare global { + interface Window { + __DICOM_IMAGES__?: string[]; + __DICOM_INFOS__?: Array<{ + filename: string; + width: number; + height: number; + bitsStored: number; + instanceNumber?: number; + sliceLocation?: number; + photometricInterpretation: string; + }>; + __SERIES_INFO__?: { + patientName?: string; + studyDescription?: string; + seriesDescription?: string; + totalSlices: number; + width: number; + height: number; + bitsStored: number; + }; + } +} + +function DicomViewerApp() { + const [hostContext, setHostContext] = useState< + McpUiHostContext | undefined + >(); + + const { app, error } = useApp({ + appInfo: { name: "DICOM Viewer", version: "1.0.0" }, + capabilities: {}, + onAppCreated: (app) => { + app.onteardown = async () => ({}); + app.onerror = console.error; + app.onhostcontextchanged = (params) => { + setHostContext((prev) => ({ ...prev, ...params })); + }; + }, + }); + + useEffect(() => { + if (app) { + setHostContext(app.getHostContext()); + } + }, [app]); + + if (error) { + return ( +
+
Connection Error
+
{error.message}
+
+ ); + } + + if (!app) { + return ( +
+
+
Connecting...
+
+ ); + } + + return ; +} + +interface DicomViewerInnerProps { + hostContext?: McpUiHostContext; + app: NonNullable["app"]>; +} + +function DicomViewerInner({ hostContext, app }: DicomViewerInnerProps) { + const containerRef = useRef(null); + const [currentSlice, setCurrentSlice] = useState(0); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0 }); + const positionStart = useRef({ x: 0, y: 0 }); + const [sliceError, setSliceError] = useState(null); + const [isLoadingSlice, setIsLoadingSlice] = useState(false); + + const initialImages = window.__DICOM_IMAGES__ ?? []; + const infos = window.__DICOM_INFOS__ ?? []; + const seriesInfo = window.__SERIES_INFO__; + const totalSlices = + seriesInfo?.totalSlices ?? infos.length ?? initialImages.length; + const [loadedImages, setLoadedImages] = useState>( + () => { + const map: Record = {}; + initialImages.forEach((img, index) => { + if (img) { + map[index] = img; + } + }); + return map; + }, + ); + const currentImage = loadedImages[currentSlice]; + + // Navigate to specific slice + const goToSlice = useCallback( + (index: number) => { + setCurrentSlice(Math.max(0, Math.min(totalSlices - 1, index))); + }, + [totalSlices], + ); + + useEffect(() => { + if (!app || totalSlices === 0 || currentImage) { + return; + } + + let cancelled = false; + setIsLoadingSlice(true); + setSliceError(null); + + app + .callServerTool({ + name: "get-dicom-slice", + arguments: { index: currentSlice }, + }) + .then((result: CallToolResult) => { + if (cancelled) return; + if (result.isError) { + setSliceError("Failed to load slice image."); + return; + } + const dataUrl = extractDataUrl(result); + if (!dataUrl) { + setSliceError("No image data returned from server."); + return; + } + setLoadedImages((prev) => ({ ...prev, [currentSlice]: dataUrl })); + }) + .catch((err) => { + if (cancelled) return; + setSliceError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) { + setIsLoadingSlice(false); + } + }); + + return () => { + cancelled = true; + }; + }, [app, currentSlice, currentImage, totalSlices]); + + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + goToSlice(currentSlice - 1); + } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + goToSlice(currentSlice + 1); + } else if (e.key === "Home") { + e.preventDefault(); + goToSlice(0); + } else if (e.key === "End") { + e.preventDefault(); + goToSlice(totalSlices - 1); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [currentSlice, goToSlice, totalSlices]); + + // Handle mouse wheel - scroll for navigation when not pressing Ctrl, zoom when pressing Ctrl + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + + if (e.ctrlKey || e.metaKey) { + // Zoom + const delta = e.deltaY > 0 ? 0.9 : 1.1; + setScale((s) => Math.min(10, Math.max(0.1, s * delta))); + } else { + // Navigate slices + if (e.deltaY > 0) { + goToSlice(currentSlice + 1); + } else { + goToSlice(currentSlice - 1); + } + } + }, + [currentSlice, goToSlice], + ); + + // Handle mouse down for pan + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button !== 0) return; + setIsDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY }; + positionStart.current = { ...position }; + }, + [position], + ); + + // Handle mouse move for pan + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isDragging) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setPosition({ + x: positionStart.current.x + dx, + y: positionStart.current.y + dy, + }); + }, + [isDragging], + ); + + // Handle mouse up + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + // Reset view + const handleReset = useCallback(() => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }, []); + + // Zoom controls + const handleZoomIn = useCallback(() => { + setScale((s) => Math.min(10, s * 1.2)); + }, []); + + const handleZoomOut = useCallback(() => { + setScale((s) => Math.max(0.1, s / 1.2)); + }, []); + + if (totalSlices === 0) { + return ( +
+
No Images
+
+ No DICOM images found in ./dicom/ folder +
+
+ ); + } + + const currentInfo = infos[currentSlice]; + const infoText = seriesInfo + ? `${seriesInfo.width} x ${seriesInfo.height} | ${seriesInfo.bitsStored}-bit` + : ""; + + return ( +
+
+
+

DICOM Viewer

+ {seriesInfo?.seriesDescription && ( + + {seriesInfo.seriesDescription} + + )} +
+ {infoText} +
+ +
+ {currentImage ? ( + {`DICOM + ) : ( +
+
+
+ {sliceError ? "Unable to load slice" : "Loading slice..."} +
+
+ {sliceError ?? + (isLoadingSlice + ? "Fetching image data from the server." + : "Waiting for image data.")} +
+
+
+ )} + + {/* Slice indicator overlay */} +
+ + {currentSlice + 1} / {totalSlices} + + {currentInfo?.instanceNumber !== undefined && ( + + Instance: {currentInfo.instanceNumber} + + )} +
+
+ +
+ {/* Slice navigation */} + {totalSlices > 1 && ( +
+ + goToSlice(parseInt(e.target.value, 10))} + className={styles.slider} + /> + +
+ )} + + {/* Zoom controls */} +
+ + {Math.round(scale * 100)}% + + +
+
+ + {/* Help text */} +
+ Scroll: navigate slices | Ctrl+Scroll: zoom | Drag: pan | Arrow keys: + navigate +
+
+ ); +} + +function extractDataUrl(result: CallToolResult): string | null { + const structured = result.structuredContent; + if ( + structured && + typeof structured === "object" && + "dataUrl" in structured && + typeof (structured as { dataUrl?: unknown }).dataUrl === "string" + ) { + return (structured as { dataUrl: string }).dataUrl; + } + + for (const block of result.content ?? []) { + if (block.type === "text") { + const text = (block as { text?: unknown }).text; + if (typeof text === "string" && text.startsWith("data:image/")) { + return text; + } + } + } + + return null; +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/dicom-viewer-mcp-app/src/vite-env.d.ts b/examples/dicom-viewer-mcp-app/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/dicom-viewer-mcp-app/tsconfig.json b/examples/dicom-viewer-mcp-app/tsconfig.json new file mode 100644 index 00000000..fc3c2101 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts"] +} diff --git a/examples/dicom-viewer-mcp-app/tsconfig.server.json b/examples/dicom-viewer-mcp-app/tsconfig.server.json new file mode 100644 index 00000000..05ddd8ec --- /dev/null +++ b/examples/dicom-viewer-mcp-app/tsconfig.server.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["server.ts"] +} diff --git a/examples/dicom-viewer-mcp-app/vite.config.ts b/examples/dicom-viewer-mcp-app/vite.config.ts new file mode 100644 index 00000000..0c39eb99 --- /dev/null +++ b/examples/dicom-viewer-mcp-app/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, +}); diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index 65924718..8821611d 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -118,6 +118,11 @@ const ALL_SERVERS = [ dir: "customer-segmentation-server", }, { key: "debug-server", name: "Debug MCP App Server", dir: "debug-server" }, + { + key: "dicom-viewer", + name: "DICOM Viewer MCP App Server", + dir: "dicom-viewer-mcp-app", + }, { key: "map-server", name: "CesiumJS Map Server", dir: "map-server" }, { key: "pdf-server", name: "PDF Server", dir: "pdf-server" }, { key: "qr-server", name: "QR Code Server", dir: "qr-server" }, diff --git a/tests/e2e/servers.spec.ts-snapshots/dicom-viewer.png b/tests/e2e/servers.spec.ts-snapshots/dicom-viewer.png new file mode 100644 index 00000000..d2dada36 Binary files /dev/null and b/tests/e2e/servers.spec.ts-snapshots/dicom-viewer.png differ