Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "pypandoc-hwpx",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"features": {
"ghcr.io/devcontainers-extra/features/pandoc:latest": {}
},
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python"
}
}
},
"postCreateCommand": "pip install -e '.[dev]' 2>/dev/null || pip install -e ."
}
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ venv/
ENV/

# IDE / Editor
.vscode/
.vscode/*
.idea/
*.swp
.DS_Store
Expand Down
29 changes: 29 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM python:3.12-slim

# Install pandoc and clean up apt cache in a single layer
RUN apt-get update \
&& apt-get install -y --no-install-recommends pandoc \
&& rm -rf /var/lib/apt/lists/*

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

# Copy the parent package first (required by server.py imports)
COPY setup.py README.md LICENSE ./
COPY pypandoc_hwpx/ pypandoc_hwpx/

# Install the parent package
RUN uv pip install --system -e .

# Copy MCP server project files and sync dependencies
COPY mcp/pyproject.toml mcp/uv.lock mcp/.python-version mcp/
WORKDIR /app/mcp
RUN uv sync --frozen --no-dev

# Copy MCP server source
COPY mcp/server.py .

# Default: stdio transport; pass --http [--port PORT] for Streamable HTTP
ENTRYPOINT ["uv", "run", "server.py"]
5 changes: 5 additions & 0 deletions mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.py[cod]

!.vscode/*
1 change: 1 addition & 0 deletions mcp/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
8 changes: 8 additions & 0 deletions mcp/.vscode/mcp.container.http.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"servers": {
"pypandoc-hwpx": {
"type": "http",
"url": "http://localhost:8000/mcp"
}
}
}
15 changes: 15 additions & 0 deletions mcp/.vscode/mcp.container.stdio.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"servers": {
"pypandoc-hwpx": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-v",
"${userHome}:${userHome}",
"pypandoc-hwpx-mcp:latest"
]
}
}
}
8 changes: 8 additions & 0 deletions mcp/.vscode/mcp.local.http.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"servers": {
"pypandoc-hwpx": {
"type": "http",
"url": "http://localhost:8000/mcp"
}
}
}
13 changes: 13 additions & 0 deletions mcp/.vscode/mcp.local.stdio.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"servers": {
"pypandoc-hwpx": {
"command": "uv",
"args": [
"run",
"--directory",
"${workspaceFolder}/mcp",
"server.py"
]
}
}
}
110 changes: 110 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# pypandoc-hwpx MCP 서버

[pypandoc-hwpx](../README.md)를 활용하여 `.docx` 문서를 `.hwpx` 문서로 변환하는 도구를 제공하는 [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) 서버입니다.

## 요구 사항

- Python 3.12+
- 시스템에 [Pandoc](https://pandoc.org/)이 설치되어 있어야 합니다
- [uv](https://docs.astral.sh/uv/) 패키지 매니저
- [Docker Desktop](https://docs.docker.com/desktop/) 또는 동등한 컨테이너 런타임

## 설치

```bash
cd mcp
uv sync
```

## 서버 실행 (VS Code 기준)

### `stdio` 전송 (기본값)

1. MCP 서버 설정을 복사합니다.

```bash
mkdir -p .vscode
cp mcp/.vscode/mcp.local.stdio.json .vscode/mcp.json
```

1. MCP 서버를 실행합니다.

### Streamable HTTP 전송

1. MCP 서버를 실행합니다.

```bash
cd mcp
uv run server.py --http --port 8000
```

> 포트 번호는 원하는 값으로 설정할 수 있습니다.

1. MCP 서버 설정을 복사합니다.

```bash
mkdir -p .vscode
cp mcp/.vscode/mcp.local.http.json .vscode/mcp.json
```

1. MCP 서버를 실행합니다.

### 컨테이너 내 `stdio` 전송

1. 컨테이너 이미지를 빌드합니다.

```bash
docker build -f Dockerfile -t pypandoc-hwpx-mcp:latest .
```

1. MCP 서버 설정을 복사합니다.

```bash
mkdir -p .vscode
cp mcp/.vscode/mcp.container.stdio.json .vscode/mcp.json
```

1. MCP 서버를 실행합니다.

### 컨테이너 내 Streamable HTTP 전송

1. 컨테이너 이미지를 빌드합니다.

```bash
docker build -f Dockerfile -t pypandoc-hwpx-mcp:latest .
```

1. MCP 서버를 실행합니다.

```bash
docker run -i --rm -p 8000:8000 -v $HOME:$HOME pypandoc-hwpx-mcp:latest --http --port 8000
```

> 포트 번호는 원하는 값으로 설정할 수 있습니다.

1. MCP 서버 설정을 복사합니다.

```bash
mkdir -p .vscode
cp mcp/.vscode/mcp.container.http.json .vscode/mcp.json
```

1. MCP 서버를 실행합니다.

## 제공 도구

| 도구 | 설명 |
|----------------|-------------------------------------------------------|
| `docx_to_hwpx` | 워드(`.docx`) 파일을 아래아 한글(`.hwpx`) 파일로 변환 |
| `html_to_hwpx` | HTML 파일을 아래아 한글(`.hwpx`) 파일로 변환 |
| `md_to_hwpx` | 마크다운 파일을 아래아 한글(`.hwpx`) 파일로 변환 |

### 도구 매개변수

세 도구 모두 동일한 매개변수를 사용합니다:

| 매개변수 | 타입 | 필수 여부 | 설명 |
|------------------|--------|-----------|------------------------------------------------------------------------------------|
| `input_path` | string | 예 | 원본 파일 경로 |
| `output_path` | string | 예 | `.hwpx` 출력 파일이 저장될 경로 |
| `reference_hwpx` | string | 아니오 | 스타일 참조용 `.hwpx` 파일 경로 (지정하지 않으면 내장된 `blank.hwpx`를 사용합니다) |
14 changes: 14 additions & 0 deletions mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[project]
name = "pypandoc-hwpx-mcp"
version = "0.1.0"
description = "MCP server for pypandoc-hwpx: convert DOCX, HTML, and Markdown files to HWPX format"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.26.0",
"pillow>=12.1.1",
"pypandoc>=1.17",
]

[project.scripts]
pypandoc-hwpx-mcp = "server:main"
143 changes: 143 additions & 0 deletions mcp/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import argparse
import json
import os
import sys
import tempfile

import pypandoc
from mcp.server.fastmcp import FastMCP

# ---------------------------------------------------------------------------
# Resolve the reference HWPX file bundled with pypandoc-hwpx.
# ---------------------------------------------------------------------------
_PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_BLANK_HWPX = os.path.join(_PACKAGE_ROOT, "pypandoc_hwpx", "blank.hwpx")

# Add the parent package to sys.path so that pypandoc_hwpx can be imported.
if _PACKAGE_ROOT not in sys.path:
sys.path.insert(0, _PACKAGE_ROOT)

from pypandoc_hwpx.PandocToHwpx import PandocToHwpx # noqa: E402

# ---------------------------------------------------------------------------
# MCP server instance
# ---------------------------------------------------------------------------
mcp = FastMCP("pypandoc-hwpx")


def _resolve_reference(reference_hwpx: str | None = None) -> str:
"""Return the path to the reference HWPX file to use for conversion."""
if reference_hwpx and os.path.isfile(reference_hwpx):
return reference_hwpx
if os.path.isfile(_BLANK_HWPX):
return _BLANK_HWPX
raise FileNotFoundError(
"No reference HWPX file found. Pass an explicit path via "
"'reference_hwpx' or ensure 'pypandoc_hwpx/blank.hwpx' exists."
)


def _convert(input_path: str, output_path: str, reference_hwpx: str | None = None) -> str:
"""Run the conversion and return the absolute output path."""
ref = _resolve_reference(reference_hwpx)
PandocToHwpx.convert_to_hwpx(input_path, output_path, ref)
return os.path.abspath(output_path)


# ---------------------------------------------------------------------------
# MCP tools
# ---------------------------------------------------------------------------

@mcp.tool()
def docx_to_hwpx(
input_path: str,
output_path: str,
reference_hwpx: str | None = None,
) -> str:
"""Convert a .docx file to a .hwpx file.

Args:
input_path: Path to the source .docx file.
output_path: Path where the resulting .hwpx file will be written.
reference_hwpx: Optional path to a reference .hwpx file for styles.

Returns:
The absolute path of the generated .hwpx file.
"""
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
return _convert(input_path, output_path, reference_hwpx)


@mcp.tool()
def html_to_hwpx(
input_path: str,
output_path: str,
reference_hwpx: str | None = None,
) -> str:
"""Convert an HTML file to a .hwpx file.

Args:
input_path: Path to the source HTML file.
output_path: Path where the resulting .hwpx file will be written.
reference_hwpx: Optional path to a reference .hwpx file for styles.

Returns:
The absolute path of the generated .hwpx file.
"""
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
return _convert(input_path, output_path, reference_hwpx)


@mcp.tool()
def md_to_hwpx(
input_path: str,
output_path: str,
reference_hwpx: str | None = None,
) -> str:
"""Convert a Markdown file to a .hwpx file.

Args:
input_path: Path to the source Markdown (.md) file.
output_path: Path where the resulting .hwpx file will be written.
reference_hwpx: Optional path to a reference .hwpx file for styles.

Returns:
The absolute path of the generated .hwpx file.
"""
if not os.path.isfile(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
return _convert(input_path, output_path, reference_hwpx)


# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------

def main():
parser = argparse.ArgumentParser(description="pypandoc-hwpx MCP server")
parser.add_argument(
"--http",
action="store_true",
default=False,
help="Run the server using Streamable HTTP transport instead of stdio",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port number for the Streamable HTTP transport (default: 8000)",
)
args = parser.parse_args()

if args.http:
mcp.settings.host = "0.0.0.0"
mcp.settings.port = args.port

transport = "streamable-http" if args.http else "stdio"
mcp.run(transport=transport)


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