PATCH 2026-02-24


This is a good engineering problem. Let me think through all three concerns — the patch, the test strategy, and the output/path problem — before writing the prompt.

**Key insight on testing:** You don't need PyPI or Claude Desktop to verify the core fix. You can test the patched function locally in isolation, then test the MCP tool registration separately. The PyPI push is only the *delivery* mechanism, not the validation gate.

**Key insight on outputs:** The `/mnt/user-data/outputs/` directory is Claude Desktop's share surface. If `pdf_to_markdown` writes there (or accepts a target path), Claude can present the file and the user can download it. A `.md` file works for both humans and Claude.

---

## Claude Code Prompt

```
You are patching the `pdf_to_markdown` MCP tool (pdf-tools server) to handle 
two input modes: a real filesystem path, and base64-encoded PDF content passed 
directly. The current implementation fails silently when claude.ai uploads a 
file — the UI shows a path like /mnt/user-data/uploads/foo.pdf, but that path 
does not exist on the MCP server's filesystem.

## The Patch

Modify pdf_to_markdown to accept an optional `pdf_base64` argument alongside 
the existing `path` argument. If `pdf_base64` is provided, decode it to a 
tempfile and run Docling on that. If `path` is provided, use existing logic. 
Raise a clear ValueError if neither is given.

def pdf_to_markdown(
    path: str = None,
    pdf_base64: str = None,
    save_output: bool = False,
    output_dir: str = None
) -> str:
    """
    Convert a PDF to Markdown using Docling.
    
    Args:
        path: Absolute path to a local PDF file. Use this when the file 
              genuinely exists on disk.
        pdf_base64: Base64-encoded PDF bytes. Use this when the PDF arrives 
                    as an attachment (e.g. from claude.ai uploads).
        save_output: If True, write the markdown to a .md file.
        output_dir: Directory to write output. Defaults to same dir as input 
                    (path mode) or /tmp (base64 mode). Pass 
                    /mnt/user-data/outputs/ to make the file available to 
                    Claude Desktop's present_files tool.
    
    Returns:
        Markdown string, or output path if save_output=True.
    """
    import base64, tempfile, pathlib
    from docling.document_converter import DocumentConverter

    if pdf_base64:
        pdf_bytes = base64.b64decode(pdf_base64)
        tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
        tmp.write(pdf_bytes)
        tmp.close()
        source_path = pathlib.Path(tmp.name)
    elif path:
        source_path = pathlib.Path(path)
        if not source_path.exists():
            return f"Error: file not found: {path}"
    else:
        raise ValueError("Provide either path or pdf_base64.")

    converter = DocumentConverter()
    result = converter.convert(str(source_path))
    md = result.document.export_to_markdown()

    if save_output:
        if output_dir:
            out_dir = pathlib.Path(output_dir)
            out_dir.mkdir(parents=True, exist_ok=True)
        else:
            out_dir = source_path.parent
        out_path = out_dir / (source_path.stem + ".md")
        out_path.write_text(md, encoding="utf-8")
        return str(out_path)

    return md

## Output directory behavior

When Claude Desktop calls this tool and the user wants the file:
- Pass output_dir="/mnt/user-data/outputs/" and save_output=True
- The returned path can be passed to present_files
- The .md file is human-readable and Claude-readable

## MCP tool schema

Update the tool's input schema to expose all four arguments. Mark path and 
pdf_base64 as optional with clear descriptions explaining when to use each.

## Do not break existing behavior

The existing path-only call signature must continue to work unchanged. The 
new arguments are additive.
```

---

## Testing Strategy

The goal is to validate each layer independently, so a PyPI regression doesn't burn hours.

**Layer 1 — Unit test the function directly** (no MCP, no Claude)

```python
# test_pdf_to_markdown.py
import base64, pathlib, pytest
from pdf_tools.converter import pdf_to_markdown  # adjust import

SAMPLE_PDF = pathlib.Path("tests/fixtures/sample.pdf")

def test_path_mode():
    result = pdf_to_markdown(path=str(SAMPLE_PDF))
    assert isinstance(result, str)
    assert len(result) > 100

def test_base64_mode():
    b64 = base64.b64encode(SAMPLE_PDF.read_bytes()).decode()
    result = pdf_to_markdown(pdf_base64=b64)
    assert isinstance(result, str)
    assert len(result) > 100

def test_save_to_output_dir(tmp_path):
    result = pdf_to_markdown(path=str(SAMPLE_PDF), 
                              save_output=True, 
                              output_dir=str(tmp_path))
    out = pathlib.Path(result)
    assert out.exists()
    assert out.suffix == ".md"
    assert out.read_text(encoding="utf-8").strip()

def test_missing_file_returns_error():
    result = pdf_to_markdown(path="/nonexistent/path.pdf")
    assert result.startswith("Error:")

def test_neither_arg_raises():
    with pytest.raises(ValueError):
        pdf_to_markdown()
```

Run with `pytest -v` before touching PyPI.

**Layer 2 — Test the MCP tool registration locally**

```bash
# Start the MCP server locally
python -m pdf_tools.server

# In a second terminal, call the tool via MCP inspector or raw JSON-RPC
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pdf_to_markdown","arguments":{"pdf_base64":"'$(base64 -w0 tests/fixtures/sample.pdf)'"}}}' \
  | nc localhost <MCP_PORT>
```

This proves the schema is wired correctly before any install step.

**Layer 3 — Test Claude Desktop without a PyPI push**

Claude Desktop reads MCP servers from its config. Point it at your local dev install instead of the PyPI package:

```json
// claude_desktop_config.json
{
  "mcpServers": {
    "pdf-tools": {
      "command": "python",
      "args": ["-m", "pdf_tools.server"],
      "env": {
        "PYTHONPATH": "/path/to/your/local/pdf-tools/src"
      }
    }
  }
}
```

Restart Claude Desktop. Upload a PDF. If it works here, the PyPI push is just packaging — behavior is already confirmed.

**Layer 4 — Acceptance test in Claude Desktop**

Upload `tests/fixtures/sample.pdf` and ask:

> "Read this PDF and save the markdown to /mnt/user-data/outputs/"

Verify: the file appears via `present_files`, opens in a text editor, and the markdown is coherent.

---

## On the `/mnt` output problem

The outputs directory is Claude Desktop's only reliable share surface. Three formats make sense depending on consumer:

| Format | Good for |
|---|---|
| `.md` | Claude re-reading it later; human preview |
| `.txt` | Maximum compatibility, zero rendering assumptions |
| `.md` + symlink or copy to `/tmp` | If another tool needs a stable path |

The simplest contract: `pdf_to_markdown` always returns the markdown string. When `save_output=True`, it *also* writes the file and returns the path. Claude decides which to use. That keeps the function composable and avoids baking path assumptions into the library.