# image-mcp Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a FastMCP stdio MCP server with two tools — image understanding (Qwen VL) and OCR (PaddleOCR) — packaged as `image-mcp` for `uvx` execution.

**Architecture:** Single-file `main.py` with all tool definitions and helper functions. Environment variables for external service configuration. Two async tools sharing a common `resolve_image_input()` helper.

**Tech Stack:** Python 3.13+, fastmcp, openai, httpx, pytest, pytest-asyncio, uv/uvx

**Spec:** `docs/superpowers/specs/2026-03-27-image-mcp-design.md`

---

## File Structure

| File | Responsibility |
|------|---------------|
| `main.py` | FastMCP server, two tool definitions, image input resolver, validation, config |
| `pyproject.toml` | Package metadata, dependencies, entry point |
| `tests/test_main.py` | Unit tests for all functions |

---

### Task 1: Project setup — pyproject.toml and empty main.py entry point

**Files:**
- Modify: `pyproject.toml`
- Modify: `main.py`

- [ ] **Step 1: Update pyproject.toml**

```toml
[project]
name = "image-mcp"
version = "0.1.0"
description = "MCP server for image understanding and OCR"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
    "fastmcp",
    "openai",
    "httpx",
]

[project.scripts]
image-mcp = "main:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[dependency-groups]
dev = [
    "pytest",
    "pytest-asyncio",
]
```

- [ ] **Step 2: Write minimal main.py with FastMCP server skeleton and startup validation**

```python
import os
import sys

from fastmcp import FastMCP

mcp = FastMCP(name="image-mcp")


def main():
    if not os.environ.get("LLM_BASE_URL"):
        print(
            "错误: 环境变量 LLM_BASE_URL 未配置，请设置 Qwen VL API 地址",
            file=sys.stderr,
        )
        sys.exit(1)
    mcp.run()


if __name__ == "__main__":
    main()
```

- [ ] **Step 3: Verify server starts (with env var set)**

Run: `cd /home/hyc/mcp_server && LLM_BASE_URL=http://test uv run main.py`
Expected: Server starts and waits for stdio input (Ctrl+C to exit)

- [ ] **Step 4: Verify server rejects missing LLM_BASE_URL**

Run: `cd /home/hyc/mcp_server && uv run main.py 2>&1; echo "exit: $?"`
Expected: Prints error about LLM_BASE_URL, exits with code 1

- [ ] **Step 5: Commit**

```bash
git add main.py pyproject.toml
git commit -m "feat: project setup with FastMCP skeleton and startup validation"
```

---

### Task 2: Image input resolver — `resolve_image_input()`

Shared helper that takes a string input (HTTP URL / data URI / local path) and returns a standardized result. Includes file size validation and user-friendly error handling for HTTP downloads.

**Files:**
- Create: `tests/test_main.py`
- Modify: `main.py`

- [ ] **Step 1: Write failing tests for resolve_image_input**

```python
# tests/test_main.py
import base64
import os

import httpx
import pytest
from unittest.mock import patch, AsyncMock, MagicMock


@pytest.mark.asyncio
async def test_resolve_http_url():
    """HTTP URL should be returned as-is with type 'url'."""
    from main import resolve_image_input

    result = await resolve_image_input("https://example.com/photo.jpg")
    assert result["type"] == "url"
    assert result["url"] == "https://example.com/photo.jpg"


@pytest.mark.asyncio
async def test_resolve_data_uri():
    """Data URI should be returned as-is with type 'data_uri'."""
    from main import resolve_image_input

    data_uri = "data:image/png;base64,iVBORw0KGgo="
    result = await resolve_image_input(data_uri)
    assert result["type"] == "data_uri"
    assert result["data_uri"] == data_uri


@pytest.mark.asyncio
async def test_resolve_invalid_data_uri():
    """Invalid data URI (no comma) should raise ValueError."""
    from main import resolve_image_input

    with pytest.raises(ValueError, match="data URI"):
        await resolve_image_input("data:invalid-no-comma")


@pytest.mark.asyncio
async def test_resolve_local_file(tmp_path):
    """Local file should be read and returned as base64 data URI."""
    from main import resolve_image_input

    img_file = tmp_path / "test.png"
    img_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

    result = await resolve_image_input(str(img_file))
    assert result["type"] == "data_uri"
    assert result["data_uri"].startswith("data:image/png;base64,")


@pytest.mark.asyncio
async def test_resolve_local_file_not_found():
    """Non-existent local file should raise ValueError."""
    from main import resolve_image_input

    with pytest.raises(ValueError, match="文件不存在"):
        await resolve_image_input("/nonexistent/path/image.png")


@pytest.mark.asyncio
async def test_resolve_local_file_too_large(tmp_path):
    """File exceeding max_size should raise ValueError."""
    from main import resolve_image_input

    big_file = tmp_path / "big.png"
    big_file.write_bytes(b"\x00" * (1024 * 1024 + 1))  # 1MB + 1 byte

    with pytest.raises(ValueError, match="文件过大"):
        await resolve_image_input(str(big_file), max_size_mb=1)


@pytest.mark.asyncio
async def test_resolve_http_url_for_ocr():
    """HTTP URL for OCR should download and return base64."""
    from main import resolve_image_input

    mock_response = MagicMock()
    mock_response.content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.get = AsyncMock(return_value=mock_response)
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("httpx.AsyncClient", return_value=mock_client):
        result = await resolve_image_input(
            "https://example.com/doc.png", force_base64=True
        )
    assert result["type"] == "base64"
    assert isinstance(result["data"], str)


@pytest.mark.asyncio
async def test_resolve_http_download_failure():
    """HTTP download failure should raise ValueError with status info."""
    from main import resolve_image_input

    mock_client = AsyncMock()
    mock_client.get = AsyncMock(
        side_effect=httpx.HTTPStatusError(
            "Not Found",
            request=MagicMock(),
            response=MagicMock(status_code=404),
        )
    )
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("httpx.AsyncClient", return_value=mock_client):
        with pytest.raises(ValueError, match="下载失败"):
            await resolve_image_input(
                "https://example.com/missing.png", force_base64=True
            )
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/test_main.py -v`
Expected: FAIL — `resolve_image_input` not defined

- [ ] **Step 3: Implement resolve_image_input in main.py**

```python
import base64
import mimetypes
import os
import sys
from pathlib import Path

import httpx


async def resolve_image_input(
    image_input: str,
    force_base64: bool = False,
    max_size_mb: float = 20,
) -> dict:
    """Resolve image input to a standardized format.

    Args:
        image_input: HTTP URL, data URI, or local file path.
        force_base64: If True, always return base64 data (for OCR API).
        max_size_mb: Maximum file size in MB.

    Returns:
        dict with keys depending on type:
        - {"type": "url", "url": str} for HTTP URLs (when not force_base64)
        - {"type": "data_uri", "data_uri": str} for data URIs
        - {"type": "base64", "data": str} for raw base64 data
    """
    # HTTP URL
    if image_input.startswith(("http://", "https://")):
        if not force_base64:
            return {"type": "url", "url": image_input}
        try:
            async with httpx.AsyncClient(timeout=30) as client:
                resp = await client.get(image_input)
                resp.raise_for_status()
        except httpx.HTTPStatusError as e:
            raise ValueError(
                f"下载失败: {image_input} (HTTP {e.response.status_code})"
            ) from e
        except httpx.RequestError as e:
            raise ValueError(f"下载失败: {image_input} ({e})") from e
        content = resp.content
        max_bytes = int(max_size_mb * 1024 * 1024)
        if len(content) > max_bytes:
            raise ValueError(
                f"文件过大: {len(content) / 1024 / 1024:.1f}MB，最大支持 {max_size_mb}MB"
            )
        return {"type": "base64", "data": base64.b64encode(content).decode()}

    # Data URI
    if image_input.startswith("data:"):
        if "," not in image_input:
            raise ValueError(f"无效的 data URI 格式，缺少 base64 数据")
        if force_base64:
            _, encoded = image_input.split(",", 1)
            return {"type": "base64", "data": encoded}
        return {"type": "data_uri", "data_uri": image_input}

    # Local file path
    path = Path(image_input)
    if not path.exists():
        raise ValueError(
            f"文件不存在: {image_input}。"
            "如果 MCP server 与文件不在同一台机器，请使用 HTTP URL 或 base64 data URI"
        )

    file_size = path.stat().st_size
    max_bytes = int(max_size_mb * 1024 * 1024)
    if file_size > max_bytes:
        raise ValueError(
            f"文件过大: {file_size / 1024 / 1024:.1f}MB，最大支持 {max_size_mb}MB"
        )

    file_bytes = path.read_bytes()
    mime_type = mimetypes.guess_type(image_input)[0] or "application/octet-stream"

    if force_base64:
        return {"type": "base64", "data": base64.b64encode(file_bytes).decode()}

    b64 = base64.b64encode(file_bytes).decode()
    data_uri = f"data:{mime_type};base64,{b64}"
    return {"type": "data_uri", "data_uri": data_uri}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/test_main.py -v`
Expected: All 9 tests PASS

- [ ] **Step 5: Commit**

```bash
git add main.py tests/test_main.py
git commit -m "feat: add resolve_image_input helper with validation and tests"
```

---

### Task 3: Tool — `analyze_image`

**Files:**
- Modify: `tests/test_main.py`
- Modify: `main.py`

- [ ] **Step 1: Write failing tests for analyze_image**

```python
# append to tests/test_main.py

@pytest.mark.asyncio
async def test_analyze_image_with_url():
    """analyze_image should call OpenAI vision API and return text."""
    from main import analyze_image

    mock_completion = MagicMock()
    mock_completion.choices = [MagicMock()]
    mock_completion.choices[0].message.content = "This is a cat."

    with patch("main.get_openai_client") as mock_client_fn:
        mock_client = AsyncMock()
        mock_client.chat.completions.create = AsyncMock(
            return_value=mock_completion
        )
        mock_client_fn.return_value = mock_client

        result = await analyze_image(
            prompt="What is in this image?",
            image_url="https://example.com/cat.jpg",
        )

    assert result == "This is a cat."
    # Verify URL was passed directly (not downloaded)
    call_args = mock_client.chat.completions.create.call_args
    messages = call_args.kwargs["messages"]
    image_part = messages[0]["content"][1]
    assert image_part["image_url"]["url"] == "https://example.com/cat.jpg"


@pytest.mark.asyncio
async def test_analyze_image_with_local_file(tmp_path):
    """analyze_image should convert local file to base64 data URI."""
    from main import analyze_image

    img_file = tmp_path / "test.png"
    img_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

    mock_completion = MagicMock()
    mock_completion.choices = [MagicMock()]
    mock_completion.choices[0].message.content = "A test image."

    with patch("main.get_openai_client") as mock_client_fn:
        mock_client = AsyncMock()
        mock_client.chat.completions.create = AsyncMock(
            return_value=mock_completion
        )
        mock_client_fn.return_value = mock_client

        result = await analyze_image(
            prompt="Describe", image_url=str(img_file)
        )

    assert result == "A test image."
    call_args = mock_client.chat.completions.create.call_args
    messages = call_args.kwargs["messages"]
    image_url = messages[0]["content"][1]["image_url"]["url"]
    assert image_url.startswith("data:image/png;base64,")


@pytest.mark.asyncio
async def test_analyze_image_unsupported_format(tmp_path):
    """analyze_image should reject unsupported image formats."""
    from main import analyze_image

    bmp_file = tmp_path / "test.bmp"
    bmp_file.write_bytes(b"BM" + b"\x00" * 100)

    with pytest.raises(ValueError, match="不支持的图片格式"):
        await analyze_image(prompt="Describe", image_url=str(bmp_file))
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/test_main.py::test_analyze_image_with_url -v`
Expected: FAIL — `analyze_image` not defined

- [ ] **Step 3: Implement analyze_image in main.py**

```python
from typing import Annotated
from pydantic import Field
from openai import AsyncOpenAI

ANALYZE_IMAGE_FORMATS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
ANALYZE_IMAGE_MAX_SIZE_MB = 20


def get_openai_client() -> AsyncOpenAI:
    base_url = os.environ.get("LLM_BASE_URL")
    if not base_url:
        raise ValueError("环境变量 LLM_BASE_URL 未配置，请设置 Qwen VL API 地址")
    return AsyncOpenAI(
        base_url=base_url,
        api_key=os.environ.get("LLM_API_KEY", "no-key"),
    )


def _validate_image_format(image_input: str) -> None:
    """Validate file format for analyze_image. Only checks local paths."""
    if image_input.startswith(("http://", "https://", "data:")):
        return
    suffix = Path(image_input).suffix.lower()
    if suffix and suffix not in ANALYZE_IMAGE_FORMATS:
        raise ValueError(
            f"不支持的图片格式: {suffix}。"
            f"支持格式: {', '.join(sorted(ANALYZE_IMAGE_FORMATS))}"
        )


@mcp.tool
async def analyze_image(
    prompt: Annotated[str, Field(description="对图片的提问或分析要求")],
    image_url: Annotated[
        str,
        Field(
            description="图片来源，支持 HTTP/HTTPS URL、base64 data URI 或本地文件路径。"
            "支持格式：JPEG、PNG、GIF、WebP（最大 20MB）"
        ),
    ],
) -> str:
    """分析和理解图片内容。支持多种图片输入方式。"""
    _validate_image_format(image_url)
    resolved = await resolve_image_input(
        image_url, max_size_mb=ANALYZE_IMAGE_MAX_SIZE_MB
    )

    if resolved["type"] == "url":
        image_content_url = resolved["url"]
    else:
        image_content_url = resolved["data_uri"]

    client = get_openai_client()
    model = os.environ.get("LLM_MODEL", "Qwen3.5-27B")

    try:
        response = await client.chat.completions.create(
            model=model,
            messages=[
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": prompt},
                        {
                            "type": "image_url",
                            "image_url": {"url": image_content_url},
                        },
                    ],
                }
            ],
            timeout=60,
        )
    except Exception as e:
        raise ValueError(f"LLM API 调用失败: {e}") from e

    return response.choices[0].message.content
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/test_main.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
git add main.py tests/test_main.py
git commit -m "feat: add analyze_image tool with format validation"
```

---

### Task 4: Tool — `ocr`

**Files:**
- Modify: `tests/test_main.py`
- Modify: `main.py`

- [ ] **Step 1: Write failing tests for ocr**

```python
# append to tests/test_main.py

OCR_FORMATS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp", ".pdf"}


@pytest.mark.asyncio
async def test_ocr_with_local_file(tmp_path):
    """ocr should call PaddleOCR API and return markdown."""
    from main import ocr

    img_file = tmp_path / "doc.png"
    img_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

    mock_response = MagicMock()
    mock_response.json = MagicMock(
        return_value={"result": "# Title\n\nSome text content"}
    )
    mock_response.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.post = AsyncMock(return_value=mock_response)
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("httpx.AsyncClient", return_value=mock_client):
        with patch.dict(
            os.environ, {"OCR_API_URL": "http://localhost:8080/layout-parsing"}
        ):
            result = await ocr(file_path=str(img_file))

    assert "Title" in result


@pytest.mark.asyncio
async def test_ocr_with_http_url():
    """ocr should download file then call PaddleOCR API."""
    from main import ocr

    mock_download_resp = MagicMock()
    mock_download_resp.content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
    mock_download_resp.raise_for_status = MagicMock()

    mock_ocr_resp = MagicMock()
    mock_ocr_resp.json = MagicMock(
        return_value={"result": "# OCR Result\n\nExtracted text"}
    )
    mock_ocr_resp.raise_for_status = MagicMock()

    mock_client = AsyncMock()
    mock_client.get = AsyncMock(return_value=mock_download_resp)
    mock_client.post = AsyncMock(return_value=mock_ocr_resp)
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("httpx.AsyncClient", return_value=mock_client):
        with patch.dict(
            os.environ,
            {"OCR_API_URL": "http://localhost:8080/layout-parsing"},
        ):
            result = await ocr(file_path="https://example.com/doc.png")

    assert "OCR Result" in result


@pytest.mark.asyncio
async def test_ocr_unsupported_format(tmp_path):
    """ocr should reject unsupported file formats."""
    from main import ocr

    txt_file = tmp_path / "doc.txt"
    txt_file.write_text("hello")

    with pytest.raises(ValueError, match="不支持的文件格式"):
        await ocr(file_path=str(txt_file))


@pytest.mark.asyncio
async def test_ocr_api_error(tmp_path):
    """ocr should return user-friendly error when OCR API fails."""
    from main import ocr

    img_file = tmp_path / "doc.png"
    img_file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

    mock_client = AsyncMock()
    mock_client.post = AsyncMock(
        side_effect=httpx.ConnectError("Connection refused")
    )
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("httpx.AsyncClient", return_value=mock_client):
        with patch.dict(
            os.environ, {"OCR_API_URL": "http://localhost:8080/layout-parsing"}
        ):
            with pytest.raises(ValueError, match="OCR API"):
                await ocr(file_path=str(img_file))
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/test_main.py::test_ocr_with_local_file -v`
Expected: FAIL — `ocr` not defined

- [ ] **Step 3: Implement ocr in main.py**

```python
OCR_FORMATS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp", ".pdf"}
OCR_MAX_SIZE_MB = 50


def _validate_ocr_format(file_input: str) -> None:
    """Validate file format for OCR. Only checks local paths."""
    if file_input.startswith(("http://", "https://", "data:")):
        return
    suffix = Path(file_input).suffix.lower()
    if suffix and suffix not in OCR_FORMATS:
        raise ValueError(
            f"不支持的文件格式: {suffix}。"
            f"支持格式: {', '.join(sorted(OCR_FORMATS))}"
        )


def _detect_file_type(file_input: str) -> str:
    """Detect file type from input string."""
    if file_input.startswith("data:"):
        mime_part = file_input.split(";")[0].split("/")[-1]
        return mime_part
    suffix = Path(file_input.split("?")[0]).suffix.lstrip(".")
    return suffix.lower() if suffix else "png"


@mcp.tool
async def ocr(
    file_path: Annotated[
        str,
        Field(
            description="文件来源，支持 HTTP/HTTPS URL、base64 data URI 或本地文件路径。"
            "支持格式：PNG、JPG、BMP、TIFF、WebP、PDF（最大 50MB）"
        ),
    ],
) -> str:
    """OCR 文字识别，将图片或 PDF 转为结构化 Markdown 文本。"""
    _validate_ocr_format(file_path)
    resolved = await resolve_image_input(
        file_path, force_base64=True, max_size_mb=OCR_MAX_SIZE_MB
    )
    b64_data = resolved["data"]
    file_type = _detect_file_type(file_path)

    ocr_url = os.environ.get(
        "OCR_API_URL", "http://192.168.25.212:8080/layout-parsing"
    )

    try:
        async with httpx.AsyncClient(timeout=300) as client:
            resp = await client.post(
                ocr_url,
                json={"image": b64_data, "file_type": file_type},
            )
            resp.raise_for_status()
            data = resp.json()
    except httpx.HTTPStatusError as e:
        raise ValueError(
            f"OCR API 返回错误: HTTP {e.response.status_code}"
        ) from e
    except httpx.RequestError as e:
        raise ValueError(
            f"OCR API 请求失败: {e}。请检查 OCR_API_URL 配置 ({ocr_url})"
        ) from e

    if isinstance(data, str):
        return data
    return data.get("result", str(data))
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/test_main.py -v`
Expected: All tests PASS

- [ ] **Step 5: Commit**

```bash
git add main.py tests/test_main.py
git commit -m "feat: add ocr tool with format validation and error handling"
```

---

### Task 5: End-to-end verification

**Files:**
- None (verification only)

- [ ] **Step 1: Run full test suite**

Run: `cd /home/hyc/mcp_server && uv run pytest tests/ -v`
Expected: All tests PASS

- [ ] **Step 2: Verify server starts and lists tools**

Run: `cd /home/hyc/mcp_server && LLM_BASE_URL=http://test uv run fastmcp dev main.py`

Expected: FastMCP dev UI opens, showing two tools: `analyze_image` and `ocr`

- [ ] **Step 3: Verify uvx entry point works**

Run: `cd /home/hyc/mcp_server && LLM_BASE_URL=http://test uv run image-mcp`
Expected: Server starts via entry point

- [ ] **Step 4: Final commit if any cleanup needed**

```bash
git add -A
git commit -m "chore: final cleanup and verification"
```
