From 03a399dda109488872a4611fb78cfba9d3abef03 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 7 May 2026 13:47:38 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(ir):=20UnknownBlock.kind=20not.enum=20f?= =?UTF-8?q?or=20fastmcp=20strict=20oneOf=20=ED=98=B8=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callable Discriminator + Tag 유니온의 자동 schema 가 oneOf 11 변형으로 펼쳐지는데, UnknownBlock 의 `kind: str` + `extra: allow` 가 known kind 인스턴스 (예: 빈 ParagraphBlock) 와도 매칭 → jsonschema strict oneOf exactly-one 위반 → fastmcp Client 가 RuntimeError. 결과적으로 v0.5.1 typed output 의 wire format 호환이 client side 에서 깨짐. Field(json_schema_extra=callable) 로 schema 에 not.enum: sorted(_KNOWN_KINDS) 삽입. callable 분리는 _KNOWN_KINDS 가 모듈 정의 순서상 뒤에 위치하는 NameError 회피. 런타임 동작 0 변경 — model_validate / round-trip / forward-compat 라우팅 모두 보존, schema export 만 strict 화. packaged hwp_ir_v1.json 도 자동 재생성. Co-Authored-By: Claude Opus 4.7 (1M context) --- python/rhwp/ir/nodes.py | 24 +++++++++++++++++++++++- python/rhwp/ir/schema/hwp_ir_v1.json | 7 ++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/python/rhwp/ir/nodes.py b/python/rhwp/ir/nodes.py index ed5f4ed..228a731 100644 --- a/python/rhwp/ir/nodes.py +++ b/python/rhwp/ir/nodes.py @@ -498,6 +498,18 @@ class FieldBlock(BaseModel): prov: Provenance +def _unknown_kind_schema_extra(schema: dict[str, Any]) -> None: + """``UnknownBlock.kind`` 의 JSON Schema 에 ``not.enum`` 추가. + + callable 로 분리한 이유 — ``_KNOWN_KINDS`` 가 ``UnknownBlock`` 정의 *뒤* 에 + 위치하므로 클래스 정의 시점에는 미정의. 함수는 호출 시점 (schema 생성 + 시점) 에만 ``_KNOWN_KINDS`` 평가하므로 모듈 fully-loaded 상태 보장. + + ``Field(json_schema_extra=callable)`` 표준 hook — schema dict 를 in-place 변경. + """ + schema["not"] = {"enum": sorted(_KNOWN_KINDS)} + + class UnknownBlock(BaseModel): """Forward-compatibility catch-all. @@ -513,7 +525,17 @@ class UnknownBlock(BaseModel): # ^ extra="allow" — 미지 variant 의 payload 를 보존해 소비자가 최소한 로그/raw 접근 가능 model_config = ConfigDict(extra="allow", frozen=True) - kind: str + kind: Annotated[ + str, + Field( + description=( + "Unknown block kind — must NOT match any known block kind. " + "callable Discriminator (`_block_discriminator`) 가 미지 kind 만 " + "본 variant 로 라우팅하므로 SSOT 와 정합." + ), + json_schema_extra=_unknown_kind_schema_extra, + ), + ] prov: Provenance diff --git a/python/rhwp/ir/schema/hwp_ir_v1.json b/python/rhwp/ir/schema/hwp_ir_v1.json index f4443bf..6dae653 100644 --- a/python/rhwp/ir/schema/hwp_ir_v1.json +++ b/python/rhwp/ir/schema/hwp_ir_v1.json @@ -1146,8 +1146,7 @@ "description": "Forward-compatibility catch-all.\n\nPydantic V2 의 기본 string discriminator 는 미지의 ``kind`` 를 만나면\n``union_tag_invalid`` 로 문서 전체 파싱을 거부한다. callable Discriminator\n로 미지 ``kind`` 를 본 variant 로 라우팅하여, 나중에 새로운 블록 타입이\n추가되어도 구 버전 소비자가 읽기-불가 상태가 되지 않게 한다.\n\n소비자는 ``case UnknownBlock(): skip`` 패턴을 사용한다. ``assert_never``\n패턴은 새 variant 추가 시 빌드가 깨지므로 **사용 금지**.", "properties": { "kind": { - "title": "Kind", - "type": "string", + "description": "Unknown block kind — must NOT match any known block kind. callable Discriminator (`_block_discriminator`) 가 미지 kind 만 본 variant 로 라우팅하므로 SSOT 와 정합.", "not": { "enum": [ "caption", @@ -1161,7 +1160,9 @@ "table", "toc" ] - } + }, + "title": "Kind", + "type": "string" }, "prov": { "$ref": "#/$defs/Provenance" From 28774a22b4f3ebc096ece1ea50f0311de1f832a2 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 7 May 2026 13:47:52 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(mcp):=20get=5Fir=20/=20iter=5Fbloc?= =?UTF-8?q?ks=20/=20chunks=20=EC=B6=9C=EB=A0=A5=20=EA=B0=95=ED=83=80?= =?UTF-8?q?=EC=9E=85=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dict[str, Any] / list[dict[str, Any]] 반환을 Pydantic V2 모델로 전환: - get_ir(path) -> HwpDocument - iter_blocks(path, ...) -> list[Block] - chunks(path, ...) -> list[ChunkRecord] ChunkRecord 신규 BaseModel — page_content: str + metadata: dict[str, Any]. metadata 는 mode × block kind 동적 키 집합 (HwpLoader SSOT) 이라 자유 dict 유지 — mode 별 분기 모델 (3-11 배 schema 비대) 거부. 호출 시그니처 / 입력 schema / wire format (result.structured_content) 모두 v0.5.0 과 byte-equal — fastmcp 자동 직렬화에 위임 (model_dump(mode="json") 수동 호출 제거). outputSchema 만 약타입 → 강타입으로 강화 (LLM 응답 정확도). Co-Authored-By: Claude Opus 4.7 (1M context) --- python/rhwp/mcp/tools.py | 65 ++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/python/rhwp/mcp/tools.py b/python/rhwp/mcp/tools.py index 7caccef..be0e4b7 100644 --- a/python/rhwp/mcp/tools.py +++ b/python/rhwp/mcp/tools.py @@ -12,9 +12,10 @@ from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field import rhwp +from rhwp.ir.nodes import Block, HwpDocument class ParseSummary(BaseModel): @@ -26,6 +27,29 @@ class ParseSummary(BaseModel): rhwp_core_version: str = Field(description="파싱에 사용된 상류 rhwp Rust 코어 버전.") +class ChunkRecord(BaseModel): + """RAG 청크의 직렬화 표면 — LangChain ``Document`` 의 ``page_content`` / ``metadata`` 평탄화. + + fastmcp 가 자동 생성하는 outputSchema 가 ``page_content: str`` + ``metadata: object`` + 의 *상위 schema* 만 강타입화 — ``metadata`` 내부 키는 mode × block kind 조합으로 + 동적이라 자유 dict 유지. 키 집합 SSOT 는 ``rhwp.integrations.langchain.HwpLoader``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + page_content: str = Field( + description="Chunk text (마크다운 / 평문 / HTML — chunks mode 에 따름).", + ) + metadata: dict[str, Any] = Field( + description=( + "Mode-dependent metadata. 공통 키 source / paragraph_count + mode 별 키 — " + "paragraph: paragraph_index, ir-blocks: kind / section_idx / para_idx / " + "char_start / char_end / image_uri / rows / cols / caption / scope. " + "키 집합은 'rhwp.integrations.langchain.HwpLoader' 가 SSOT." + ), + ) + + # ^ Block kind enum — IR ``Block.kind`` Literal 과 1:1. "필터 미적용" 은 sentinel # 대신 ``None`` (kind 인자 생략) 으로 표현 — JSON Schema enum 이 IR 에 실제로 # 존재하지 않는 "all" 값을 노출하지 않게 한다 (LLM 추론 정확도). @@ -73,13 +97,15 @@ def extract_text(path: str) -> str: return rhwp.parse(path).extract_text() -def get_ir(path: str) -> dict[str, Any]: - """HWP 또는 HWPX 파일을 파싱해 Document IR 전체를 JSON 직렬화 가능한 dict 로 반환. +def get_ir(path: str) -> HwpDocument: + """HWP 또는 HWPX 파일을 파싱해 Document IR 전체를 ``HwpDocument`` 모델로 반환. - Pydantic ``HwpDocument.model_dump(mode="json")`` 결과 — discriminated union - block 들이 모두 평탄화된 형태. RAG 인덱싱 또는 LLM 후처리에 그대로 입력 가능. + fastmcp 가 자동으로 ``model_dump(mode="json")`` 직렬화하므로 wire format + (``result.structured_content``) 은 v0.5.0 dict 출력과 byte-equal. ``result.data`` + 는 typed BaseModel 인스턴스 (v0.5.1 신규 표면) — discriminated union block + 들의 강타입 access 가능. RAG 인덱싱 또는 LLM 후처리에 그대로 입력 가능. """ - return rhwp.parse(path).to_ir().model_dump(mode="json") + return rhwp.parse(path).to_ir() def to_markdown(path: str) -> str: @@ -117,12 +143,17 @@ def iter_blocks( kind: BlockKind | None = None, scope: BlockScope = "body", limit: int | None = None, -) -> list[dict[str, Any]]: - """IR 블록을 ``kind`` / ``scope`` 로 필터링해 dict 리스트로 반환. +) -> list[Block]: + """IR 블록을 ``kind`` / ``scope`` 로 필터링해 ``Block`` 리스트로 반환. 재귀 진입 (``recurse=True``) 으로 컨테이너 블록 (TableCell / Footnote / Endnote / Caption) 내부까지 평탄화 — 결과는 RAG 청커가 그대로 소비할 수 있다. + fastmcp 가 자동으로 각 ``Block`` 을 ``model_dump(mode="json")`` 직렬화하므로 + wire format 은 v0.5.0 dict 리스트와 byte-equal. ``Block`` 의 callable + Discriminator + Tag 유니온 (11 변형) 이 outputSchema 의 ``oneOf`` 로 노출 — + LLM 이 ``kind`` 별 필드 구조를 정확히 추론. + Args: path: HWP 또는 HWPX 파일 경로. kind: 블록 종류 필터. ``None`` 또는 미지정이면 필터 미적용 (모든 종류). @@ -131,15 +162,15 @@ def iter_blocks( limit: 최대 출고 개수. ``None`` 이면 전체. Returns: - ``Block.model_dump(mode="json")`` dict 의 리스트. + ``Block`` 인스턴스의 리스트 (Discriminator + Tag 유니온 변형 11 종). """ doc = rhwp.parse(path) ir_doc = doc.to_ir() - out: list[dict[str, Any]] = [] + out: list[Block] = [] for block in ir_doc.iter_blocks(scope=scope, recurse=True): if kind is not None and block.kind != kind: continue - out.append(block.model_dump(mode="json")) + out.append(block) if limit is not None and len(out) >= limit: break return out @@ -151,7 +182,7 @@ def chunks( size: int = 500, overlap: int = 50, include_furniture: bool = False, -) -> list[dict[str, Any]]: +) -> list[ChunkRecord]: """HWP/HWPX 를 RAG 청크 리스트로 변환 (LangChain ``RecursiveCharacterTextSplitter``). 런타임에 ``langchain-text-splitters`` 를 lazy import — ``[mcp]`` extras 만 @@ -159,6 +190,10 @@ def chunks( chunks 도구만 호출 시점에 ImportError → fastmcp 가 ``ToolError`` 로 wrap → MCP 응답 ``CallToolResult(isError=True)``. + fastmcp 가 자동으로 각 ``ChunkRecord`` 를 ``model_dump(mode="json")`` + 직렬화하므로 wire format (``result.structured_content``) 은 v0.5.0 dict + 리스트와 byte-equal. + Args: path: HWP 또는 HWPX 파일 경로. mode: LangChain Document 매핑 전략. CLI ``rhwp-py chunks --mode`` 와 @@ -177,8 +212,8 @@ def chunks( RAG body 검색 오염 회피. Returns: - ``[{"page_content": str, "metadata": dict}]`` — LangChain Document 의 - 직렬화 가능 형태. + ``ChunkRecord`` 인스턴스의 리스트 — LangChain Document 의 ``page_content`` + / ``metadata`` 평탄화. """ try: from langchain_text_splitters import RecursiveCharacterTextSplitter @@ -196,4 +231,4 @@ def chunks( docs = loader.load() splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap) split_docs = splitter.split_documents(docs) - return [{"page_content": d.page_content, "metadata": d.metadata} for d in split_docs] + return [ChunkRecord(page_content=d.page_content, metadata=d.metadata) for d in split_docs] From 747d6b2d07315ec6383ec41e9968608a15aa97ac Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 7 May 2026 13:48:07 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test(mcp):=20v0.5.1=20typed-output=20AC-1~A?= =?UTF-8?q?C-7=20=ED=9A=8C=EA=B7=80=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 신규 테스트 클래스 + 기존 dict access 검증 typed model 전환: - TestTypedSignatures (AC-1, AC-2, AC-3, AC-7) — 정적 반환 어노테이션 + ChunkRecord.metadata 자유 dict - TestTypedOutputSchema (AC-4) — fastmcp 자동 outputSchema 가 HwpDocument defs / Block oneOf 11 변형 / ChunkRecord page_content+metadata 노출 - TestBackwardsCompat (AC-5) — fastmcp Client in-process 의 byte-equal 회귀 가드. wrap 분기 정확화: HwpDocument = inline / list[T] = {"result":...} - TestTypedClientData (AC-6) — result.data 의 typed-or-dict 분기. fastmcp v3 의 oneOf deserialization 한계로 iter_blocks list element 는 dict 폴백, v0.5.0 access 패턴 그대로 동작 - TestTypedModelRoundTrip — Pydantic dump → load → equality 기존 TestGetIr / TestIterBlocks / TestChunks 의 dict 인덱싱 검증을 typed attribute access 로 교체. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_mcp_server.py | 320 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 303 insertions(+), 17 deletions(-) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 405ded5..c870e3b 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,4 +1,4 @@ -"""rhwp.mcp fastmcp 서버 단위 테스트 (S1 + S2 + S3 + S4). +"""rhwp.mcp fastmcp 서버 단위 테스트 (S1 + S2 + S3 + S4 + v0.5.1 typed-output). ``fastmcp`` (``[mcp]`` extras) 미설치 환경에서는 file-level ``importorskip`` 로 전체 skip — CI ``test-without-extras`` 잡이 카운트 검증 (AC-1). @@ -7,7 +7,7 @@ 로 게이트 — 본 파일은 fastmcp 만 file-level gate, langchain 미설치 환경에서는 chunks smoke 만 개별 skip (file-level skip 카운트 영향 없음). -인수조건 매핑: +v0.5.0 인수조건 매핑: - AC-2 (도구 7 개 노출 — S1 코어 4 + S2 view 2 + S3 chunks 1) → ``TestToolRegistry`` - AC-3 (잘못된 enum → isError=True) → ``TestErrorHandling`` @@ -18,9 +18,22 @@ - AC-8 (--transport streamable-http --port N CLI 기동) → ``TestTransportCli`` - AC-9 (pyproject 등록) → ``TestPackagingSurface`` - AC-10 (모듈 위치) → ``TestPackagingSurface`` + +v0.5.1 인수조건 매핑 (mcp-typed-output PATCH): + +- AC-1 (``get_ir`` 반환 타입 = ``HwpDocument``) → ``TestTypedSignatures`` +- AC-2 (``iter_blocks`` 반환 타입 = ``list[Block]``) → ``TestTypedSignatures`` +- AC-3 (``chunks`` 반환 타입 = ``list[ChunkRecord]``) → ``TestTypedSignatures`` +- AC-4 (outputSchema 강화) → ``TestTypedOutputSchema`` +- AC-5 (wire format byte-equal — v0.5.0 회귀 가드) → ``TestBackwardsCompat`` +- AC-6 (fastmcp Client ``result.data`` 가 typed 인스턴스) → ``TestTypedClientData`` +- AC-7 (``ChunkRecord.metadata`` = ``dict[str, Any]``) → ``TestTypedSignatures`` +- AC-8 (도구 등록 7 개 회귀 보존) → 기존 ``TestToolRegistry`` 가 cover +- AC-9 (extras / skip count 변동 없음) → 기존 CI ``test-without-extras`` job """ import sys +import typing from pathlib import Path import pytest @@ -31,13 +44,18 @@ import asyncio # noqa: E402 import importlib # noqa: E402 import inspect # noqa: E402 +import json # noqa: E402 +from typing import Any # noqa: E402 import rhwp # noqa: E402 +from fastmcp.client import Client # noqa: E402 from fastmcp.exceptions import NotFoundError, ToolError # noqa: E402 from fastmcp.tools.function_tool import FunctionTool # noqa: E402 from pydantic import ValidationError # noqa: E402 +from rhwp.ir.nodes import Block, HwpDocument # noqa: E402 from rhwp.mcp import tools # noqa: E402 from rhwp.mcp.server import build_server # noqa: E402 +from rhwp.mcp.tools import ChunkRecord # noqa: E402 pytestmark = pytest.mark.spec("v0.5.0/mcp") @@ -155,12 +173,18 @@ def test_returns_string(self, hwp_sample: Path) -> None: class TestGetIr: - def test_returns_dict_with_schema_envelope(self, hwp_sample: Path) -> None: + def test_returns_typed_hwp_document(self, hwp_sample: Path) -> None: + """v0.5.1 부터 typed ``HwpDocument`` 인스턴스 반환 (v0.5.0 의 dict 출력 대체). + + wire format byte-equal 회귀 가드는 ``TestBackwardsCompat`` 가 보유. + """ result = tools.get_ir(str(hwp_sample)) - assert isinstance(result, dict) - assert result["schema_name"] == "HwpDocument" - assert "schema_version" in result - assert "body" in result + assert isinstance(result, HwpDocument) + # ^ schema_name 은 Literal-constrained — Pydantic validator 가 강제 + assert result.schema_name == "HwpDocument" + assert result.schema_version + # ^ body 는 list[Block] — 빈 리스트도 허용 (빈 문서 회귀 회피) + assert isinstance(result.body, list) class TestToMarkdown: @@ -202,17 +226,19 @@ def test_returns_html5_document(self, hwp_sample: Path) -> None: class TestIterBlocks: - def test_default_returns_dicts(self, hwp_sample: Path) -> None: + def test_default_returns_typed_blocks(self, hwp_sample: Path) -> None: + """v0.5.1 부터 typed Block 유니온 인스턴스 리스트 반환.""" result = tools.iter_blocks(str(hwp_sample)) assert isinstance(result, list) - assert all(isinstance(b, dict) for b in result) - assert all("kind" in b for b in result) + # ^ Block 유니온의 모든 변형이 ``kind`` attribute 를 보유 (Discriminator key) + assert all(hasattr(b, "kind") for b in result) + assert all(isinstance(b.kind, str) for b in result) def test_kind_filter_paragraph(self, hwp_sample: Path) -> None: # ^ kind=None (또는 미지정) 이면 필터 미적용 — IR 의 모든 종류 yield all_blocks = tools.iter_blocks(str(hwp_sample), kind=None) para_blocks = tools.iter_blocks(str(hwp_sample), kind="paragraph") - assert all(b["kind"] == "paragraph" for b in para_blocks) + assert all(b.kind == "paragraph" for b in para_blocks) assert len(para_blocks) <= len(all_blocks) def test_limit_truncates(self, hwp_sample: Path) -> None: @@ -244,11 +270,11 @@ def test_default_paragraph_mode(self, hwp_sample: Path) -> None: assert isinstance(result, list) assert result, "chunks must yield at least one chunk for fixture" for d in result: - assert isinstance(d, dict) - assert "page_content" in d - assert "metadata" in d - assert isinstance(d["page_content"], str) - assert isinstance(d["metadata"], dict) + # ^ v0.5.1 부터 typed ChunkRecord 인스턴스 — wire format byte-equal 은 + # ``TestBackwardsCompat`` 회귀 가드. + assert isinstance(d, ChunkRecord) + assert isinstance(d.page_content, str) + assert isinstance(d.metadata, dict) def test_modes_all_supported(self, hwp_sample: Path) -> None: pytest.importorskip("langchain_text_splitters") @@ -274,7 +300,7 @@ def test_include_furniture_appends_furniture_chunks(self, hwp_sample: Path) -> N assert len(with_furniture) >= len(body_only) # ^ aift.hwp 샘플은 page_headers 를 보유 — # 한 개 이상 추가 청크가 ``scope="furniture"`` 메타로 yield - furniture_chunks = [c for c in with_furniture if c["metadata"].get("scope") == "furniture"] + furniture_chunks = [c for c in with_furniture if c.metadata.get("scope") == "furniture"] assert furniture_chunks, ( "aift.hwp 는 page_headers 를 보유 — include_furniture=True 가 " "'scope=furniture' 메타로 청크를 yield 해야 함" @@ -623,3 +649,263 @@ def test_module_is_top_level_not_under_integrations(self) -> None: # ^ "__init__.py 가 lazy import 패턴인지" 는 implementation 측면 — behavior # 측면 검증은 CI ``test-without-extras`` 잡 (fastmcp 미설치 환경에서 file 전체 # skip = 5 카운트) 이 SSOT. 본 파일에 추가 source-grep 테스트는 두지 않는다. + + +# ==================================================================== +# v0.5.1 — MCP typed output PATCH +# ==================================================================== +# get_ir / iter_blocks / chunks 의 출력 시그니처를 dict[str, Any] 에서 +# Pydantic 모델 (HwpDocument / list[Block] / list[ChunkRecord]) 로 강화. +# wire format (result.structured_content) 은 v0.5.0 과 byte-equal — +# 외부 클라이언트 영향 0. spec: docs/roadmap/v0.5.1/mcp-typed-output.md. + + +# ------------------------------------------------------------------ AC-1 / AC-2 / AC-3 / AC-7 +class TestTypedSignatures: + """v0.5.1 도구 출력 어노테이션 — fastmcp 자동 outputSchema 의 입력원.""" + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-1") + def test_get_ir_return_annotation_is_hwp_document(self) -> None: + """``get_ir`` 의 정적 반환 타입 = ``HwpDocument`` (fastmcp v3 자동 schema 진입점).""" + sig = inspect.signature(tools.get_ir) + assert sig.return_annotation is HwpDocument + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-2") + def test_iter_blocks_return_annotation_is_list_of_block(self) -> None: + """``iter_blocks`` 의 정적 반환 타입 = ``list[Block]`` (Discriminator + Tag 11 변형).""" + hints = typing.get_type_hints(tools.iter_blocks, include_extras=True) + assert hints["return"] == list[Block] + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-3") + def test_chunks_return_annotation_is_list_of_chunk_record(self) -> None: + """``chunks`` 의 정적 반환 타입 = ``list[ChunkRecord]``.""" + hints = typing.get_type_hints(tools.chunks) + assert hints["return"] == list[ChunkRecord] + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-3") + def test_chunk_record_is_exposed_on_tools_module(self) -> None: + """``ChunkRecord`` 가 ``rhwp.mcp.tools`` 모듈에서 import 가능 (외부 코드 사용 가능).""" + assert hasattr(tools, "ChunkRecord") + assert tools.ChunkRecord is ChunkRecord + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-7") + def test_chunk_record_metadata_annotation_is_free_dict(self) -> None: + """``ChunkRecord.metadata`` 어노테이션 = ``dict[str, Any]``. + + 결정 5 (mode × kind 분기 거부) 의 grep-friendly evidence — 새 metadata 키 + 추가 시 모델 갱신 강제 회피. 분기 모델 도입 PR 회귀 가드. + """ + anno = ChunkRecord.model_fields["metadata"].annotation + assert anno == dict[str, Any] + + +# ------------------------------------------------------------------ AC-4 +class TestTypedOutputSchema: + """fastmcp 자동 생성 outputSchema 가 v0.5.0 의 약타입 → 강타입으로 전환됨을 검증. + + v0.5.0 의 dict[str, Any] 출력은 ``additionalProperties: true`` 만 (LLM 이 키 + 이름조차 모름). v0.5.1 부터 HwpDocument / Block / ChunkRecord 의 필드가 + schema 에 직접 노출 — LLM 의 응답 해석 정확도 향상. + """ + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-4") + def test_get_ir_schema_exposes_hwp_document_defs(self) -> None: + server = build_server() + tool = next(t for t in asyncio.run(server.list_tools()) if t.name == "get_ir") + schema_text = json.dumps(tool.output_schema) + # ^ HwpDocument 의 sub-model 들이 schema 에 등장 (v0.5.0 의 약타입엔 부재) + for ref in ("HwpDocument", "ParagraphBlock", "TableBlock"): + assert ref in schema_text, ( + f"expected {ref!r} in get_ir output schema (v0.5.1 강타입화). " + f"v0.5.0 약타입 회귀 의심." + ) + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-4") + def test_iter_blocks_schema_exposes_block_union_variants(self) -> None: + """배열 item 의 ``oneOf`` (또는 inline `$defs`) 가 11 변형 모두 노출.""" + server = build_server() + tool = next(t for t in asyncio.run(server.list_tools()) if t.name == "iter_blocks") + schema_text = json.dumps(tool.output_schema) + for variant in ( + "ParagraphBlock", + "TableBlock", + "PictureBlock", + "FormulaBlock", + "FootnoteBlock", + "EndnoteBlock", + "ListItemBlock", + "CaptionBlock", + "TocBlock", + "FieldBlock", + "UnknownBlock", + ): + assert variant in schema_text, ( + f"expected {variant!r} in iter_blocks output schema " + f"(Block 유니온 11 변형 — v0.5.1 강타입화)" + ) + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-4") + def test_chunks_schema_exposes_chunk_record_fields(self) -> None: + """``page_content`` + ``metadata`` 가 schema 에 노출 — metadata 자유 dict 유지.""" + server = build_server() + tool = next(t for t in asyncio.run(server.list_tools()) if t.name == "chunks") + schema_text = json.dumps(tool.output_schema) + assert "page_content" in schema_text + assert "metadata" in schema_text + + +# ------------------------------------------------------------------ AC-5 +class TestBackwardsCompat: + """v0.5.0 → v0.5.1 wire format 회귀 가드 — ``result.structured_content`` byte-equal. + + v0.5.0 의 dict 출력 == v0.5.1 의 fastmcp 자동 직렬화 (Pydantic ``model_dump``). + 이 invariant 가 깨지면 외부 MCP 클라이언트 (Claude Desktop / Cline 등) 의 기존 + LLM 프롬프트 / 후처리 코드가 영향 받음 — PATCH 의 SemVer 의무 위반. + """ + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-5") + def test_get_ir_structured_content_matches_v050_dump(self, hwp_sample: Path) -> None: + """v0.5.0 의 ``HwpDocument.model_dump(mode="json")`` 와 v0.5.1 wire format byte-equal. + + BaseModel 반환은 fastmcp v3 가 wrap 없이 fields 직접 노출 — list / scalar + 반환의 ``{"result": ...}`` wrapper 와 다른 패턴 (fastmcp v3.2.4 docs § Use + Typed Models for Structured Output). + """ + expected = rhwp.parse(str(hwp_sample)).to_ir().model_dump(mode="json") + server = build_server() + + async def _call() -> Any: + async with Client(server) as client: + return await client.call_tool("get_ir", {"path": str(hwp_sample)}) + + result = asyncio.run(_call()) + assert result.structured_content == expected + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-5") + def test_iter_blocks_structured_content_matches_v050_dump(self, hwp_sample: Path) -> None: + """v0.5.0 의 ``[Block.model_dump(mode="json"), ...]`` 와 byte-equal.""" + ir_doc = rhwp.parse(str(hwp_sample)).to_ir() + expected = [ + block.model_dump(mode="json") + for block in ir_doc.iter_blocks(scope="body", recurse=True) + ] + server = build_server() + + async def _call() -> Any: + async with Client(server) as client: + return await client.call_tool("iter_blocks", {"path": str(hwp_sample)}) + + result = asyncio.run(_call()) + assert result.structured_content == {"result": expected} + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-5") + def test_chunks_structured_content_matches_v050_dump(self, hwp_sample: Path) -> None: + """v0.5.0 의 dict 평탄화 결과와 v0.5.1 wire format byte-equal.""" + pytest.importorskip("langchain_text_splitters") + from langchain_text_splitters import RecursiveCharacterTextSplitter + from rhwp.integrations.langchain import HwpLoader + + # ^ v0.5.0 chunks 의 정확한 dict 평탄화 패턴 재현 + loader = HwpLoader(str(hwp_sample), mode="paragraph", include_furniture=False) + docs = loader.load() + splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) + split_docs = splitter.split_documents(docs) + expected = [{"page_content": d.page_content, "metadata": d.metadata} for d in split_docs] + + server = build_server() + + async def _call() -> Any: + async with Client(server) as client: + return await client.call_tool("chunks", {"path": str(hwp_sample)}) + + result = asyncio.run(_call()) + assert result.structured_content == {"result": expected} + + +# ------------------------------------------------------------------ AC-6 +class TestTypedClientData: + """fastmcp Client 의 ``result.data`` 가 v0.5.1 부터 typed deserialization. + + v0.5.0 의 dict 출력에서는 ``result.data`` 가 raw dict 또는 list[dict]. + v0.5.1 부터 fastmcp 가 outputSchema 기반으로 Pydantic-like 객체로 reconstruct — + attribute access 가 가능해 LLM 에이전트의 결과 후처리가 정확. + """ + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-6") + def test_get_ir_client_data_has_typed_attributes(self, hwp_sample: Path) -> None: + server = build_server() + + async def _call() -> Any: + async with Client(server) as client: + return await client.call_tool("get_ir", {"path": str(hwp_sample)}) + + result = asyncio.run(_call()) + data = result.data + # ^ schema_name / schema_version 이 attribute 로 access 가능 (v0.5.0 dict 와 다름) + assert hasattr(data, "schema_name") + assert data.schema_name == "HwpDocument" + assert hasattr(data, "schema_version") + assert hasattr(data, "body") + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-6") + def test_iter_blocks_client_data_is_typed_list(self, hwp_sample: Path) -> None: + """``list[Block]`` 의 ``result.data`` — fastmcp v3 의 oneOf 한계로 list element + 는 dict 폴백 (callable Discriminator + Tag union 을 dynamic Pydantic 모델로 + reconstruct 불가). server side 출력 자체는 typed (AC-2). client side 의 + 의미 있는 검증은 ``"kind"`` key 가 노출되어 있고 v0.5.0 dict access 패턴이 + 그대로 동작한다는 것 — wire format 의 byte-equality 가 더 strict 한 회귀 가드 (AC-5). + """ + server = build_server() + + async def _call() -> Any: + async with Client(server) as client: + return await client.call_tool("iter_blocks", {"path": str(hwp_sample)}) + + result = asyncio.run(_call()) + data = result.data + assert isinstance(data, list) + assert data, "iter_blocks 의 fixture 결과는 빈 리스트가 아니어야 함" + # ^ Discriminator key 'kind' 가 모든 변형의 dict key (v0.5.0 access 호환) + for block in data: + assert "kind" in block + assert isinstance(block["kind"], str) + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-6") + def test_chunks_client_data_is_typed_list(self, hwp_sample: Path) -> None: + pytest.importorskip("langchain_text_splitters") + server = build_server() + + async def _call() -> Any: + async with Client(server) as client: + return await client.call_tool("chunks", {"path": str(hwp_sample)}) + + result = asyncio.run(_call()) + data = result.data + assert isinstance(data, list) + assert data, "chunks fixture 결과는 빈 리스트가 아니어야 함" + for chunk in data: + # ^ ChunkRecord 의 두 필드가 attribute 로 access 가능 + assert hasattr(chunk, "page_content") + assert hasattr(chunk, "metadata") + assert isinstance(chunk.page_content, str) + assert isinstance(chunk.metadata, dict) + + +# ------------------------------------------------------------------ Pydantic round-trip +class TestTypedModelRoundTrip: + """Pydantic dump → load → equality — 모델 정의의 결정성 회귀 가드.""" + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-1") + def test_get_ir_round_trip(self, hwp_sample: Path) -> None: + original = tools.get_ir(str(hwp_sample)) + reloaded = HwpDocument.model_validate_json(original.model_dump_json()) + assert reloaded == original + + @pytest.mark.spec("v0.5.1/mcp-typed-output#AC-3") + def test_chunk_record_round_trip(self) -> None: + original = ChunkRecord( + page_content="hello", + metadata={"kind": "paragraph", "section_idx": 0, "para_idx": 0}, + ) + reloaded = ChunkRecord.model_validate_json(original.model_dump_json()) + assert reloaded == original From d9f61a14bd09fc036e6d9d0b7ee481b2504b2981 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 7 May 2026 13:48:25 +0900 Subject: [PATCH 4/6] docs(v0.5.1): mcp-typed-output spec / ADR / migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roadmap/v0.5.1/mcp-typed-output.md (Draft) — 결정 8 항목 + AC-1~AC-10. 결정 8 (UnknownBlock.kind not.enum), § wire format byte-equal 의 wrap 분기 표 (BaseModel = inline / list[T] = {"result": ...}), AC-5/AC-6 의 fastmcp v3 한계 반영 - design/v0.5.1/mcp-typed-output-research.md (Draft) — 결정 매트릭스 row 5 + § 5 (UnknownBlock not.enum 옵션 비교 + 검증자 반박 + 1차 소스). § 2 검증자 반박에 fastmcp client deserialization 한계 추가 - implementation/v0.5.1/migration.md (Frozen) — 본 PATCH 의 산출물 / 결정 매핑 / 호환성 / 검증 / AC↔테스트 매핑 / fastmcp v3 한계 / GA 절차 인계 - README.md MCP 도구 표 출력 컬럼 갱신 + v0.5.1 마이그 노트 - roadmap/README.md v0.5.1 row Draft 인덱스 등록 - traces/coverage.md 16 v0.5.1 AC mappings 자동 갱신 Cargo.toml bump / CHANGELOG [0.5.1] / spec Draft→Frozen flip / git tag 는 별도 GA 절차 step. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 16 +- .../v0.5.1/mcp-typed-output-research.md | 206 +++++++++++++++++ docs/implementation/v0.5.1/migration.md | 212 ++++++++++++++++++ docs/roadmap/README.md | 6 +- docs/roadmap/v0.5.1/mcp-typed-output.md | 209 +++++++++++++++++ docs/traces/coverage.md | 36 ++- 6 files changed, 674 insertions(+), 11 deletions(-) create mode 100644 docs/design/v0.5.1/mcp-typed-output-research.md create mode 100644 docs/implementation/v0.5.1/migration.md create mode 100644 docs/roadmap/v0.5.1/mcp-typed-output.md diff --git a/README.md b/README.md index a0d70d1..d47d1e0 100644 --- a/README.md +++ b/README.md @@ -175,13 +175,15 @@ pip install "rhwp-python[mcp-chunks]" # + chunks (RAG 청킹 — langchain-te | 도구 | 입력 | 출력 | |---|---|---| -| `parse_hwp_summary` | `path` | sections / paragraphs / pages 카운트 + rhwp-core 버전 | -| `extract_text` | `path` | 단락별 평문 (LF 결합) | -| `get_ir` | `path` | Document IR 전체 (JSON-serializable dict) | -| `iter_blocks` | `path`, `kind?`, `scope`, `limit?` | IR 블록 dict 리스트 (kind / scope 필터링) | -| `to_markdown` | `path` | GFM Markdown — v0.4.0 view API thin wrapper | -| `to_html` | `path`, `include_css` | HTML5 문서 — v0.4.0 view API thin wrapper | -| `chunks` | `path`, `mode`, `size`, `overlap`, `include_furniture` | LangChain `RecursiveCharacterTextSplitter` 적용 청크 — `[mcp-chunks]` extras 필요 | +| `parse_hwp_summary` | `path` | `ParseSummary` — sections / paragraphs / pages 카운트 + rhwp-core 버전 | +| `extract_text` | `path` | `str` — 단락별 평문 (LF 결합) | +| `get_ir` | `path` | `HwpDocument` — Document IR 전체 (Pydantic 모델, fastmcp 자동 직렬화) | +| `iter_blocks` | `path`, `kind?`, `scope`, `limit?` | `list[Block]` — discriminated union (paragraph / table / picture / ... 11 변형, kind / scope 필터링) | +| `to_markdown` | `path` | `str` — GFM Markdown (v0.4.0 view API thin wrapper) | +| `to_html` | `path`, `include_css` | `str` — HTML5 문서 (v0.4.0 view API thin wrapper) | +| `chunks` | `path`, `mode`, `size`, `overlap`, `include_furniture` | `list[ChunkRecord]` — LangChain `RecursiveCharacterTextSplitter` 적용 청크. `[mcp-chunks]` extras 필요 | + +> **v0.5.1 마이그 노트** — 출력 시그니처가 dict / list[dict] 에서 Pydantic 모델로 강화됐습니다 (PATCH). fastmcp Client 의 `result.structured_content` (raw dict, MCP wire format) 는 v0.5.0 과 byte-equal — 외부 LLM 프롬프트 / 후처리 코드 영향 0. 다만 `result.data` 사용 패턴은 변경: v0.5.0 의 `result.data["body"]` (dict 인덱싱) → v0.5.1 의 `result.data.body` (typed attribute) 또는 `result.data.model_dump()["body"]`. `iter_blocks` 의 list element 는 fastmcp v3 의 `oneOf` deserialization 한계로 dict 폴백 — `block["kind"]` access 패턴은 그대로 동작. ### Claude Desktop 등록 diff --git a/docs/design/v0.5.1/mcp-typed-output-research.md b/docs/design/v0.5.1/mcp-typed-output-research.md new file mode 100644 index 0000000..0a0484f --- /dev/null +++ b/docs/design/v0.5.1/mcp-typed-output-research.md @@ -0,0 +1,206 @@ +--- +status: Draft +description: "v0.5.1 MCP 출력 강타입화 ADR — 'HwpDocument' / 'list[Block]' / 'ChunkRecord' 채택 / 'metadata' 자유 dict 유지 / fastmcp 자동 schema 위임 결정 근거" +target: v0.5.1 +last_updated: 2026-05-07 +--- + +# v0.5.1 mcp-typed-output — 설계 의사결정 리서치 요약 + +[v0.5.1/mcp-typed-output.md](../../roadmap/v0.5.1/mcp-typed-output.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 4건 (`get_ir` 모델 채택 · `iter_blocks` 의 Block 유니온 노출 · `chunks` 의 ChunkRecord + metadata 정책 · fastmcp 자동 schema 위임) 의 업계 선례·대안·실패 시나리오를 기록한다. spec 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. + +## 결정 매트릭스 + +| # | 항목 | 옵션 비교 | 채택 | 1차 근거 | +|---|---|---|---|---| +| 1 | `get_ir` 출력 모델 | A: dict 유지 / B: `HwpDocument` 직접 노출 / C: 새 wrapper 모델 | **B** | IR 모델이 이미 Pydantic V2 + frozen — fastmcp 자동 schema 가 그대로 작동 | +| 2 | `iter_blocks` 출력 모델 | A: dict 유지 / B: `list[Block]` (Discriminator + Tag 11 변형) / C: 새 BlockRecord wrapper | **B** | callable Discriminator 가 fastmcp 자동 `oneOf` schema 와 호환 — wrapper 가 정보 추가 없음 | +| 3 | `chunks` 출력 모델 | A: dict 유지 / B: `ChunkRecord(page_content, metadata: dict)` / C: mode×kind 분기 모델 | **B** | metadata 가 mode × block kind 동적 — 분기 모델은 schema 3-11 배 비대 + forward-compat 깨짐 | +| 4 | fastmcp `output_schema=` 수동 오버라이드 | A: 자동 위임 / B: 수동 명시 | **A** | fastmcp v3 docs 가 자동 생성을 1st-tier 패턴 — 수동은 schema drift / 추가 유지 비용 | +| 5 | `UnknownBlock.kind` JSON Schema constraint | A: `kind: str` 유지 / B: `not.enum: sorted(_KNOWN_KINDS)` 추가 | **B** | strict `oneOf` validation 에서 ParagraphBlock 과 UnknownBlock 양쪽 valid 인스턴스 충돌 → client side wire format 호환 깨짐. 런타임 동작 0 변경, schema export 만 strict 화 | + +--- + +## 1. `get_ir` 출력 모델 + +### 팩트 + +- v0.5.0 의 `get_ir(path)` 는 `rhwp.parse(path).to_ir().model_dump(mode="json")` 을 호출하여 `dict[str, Any]` 반환 (`python/rhwp/mcp/tools.py:76-82`) +- `HwpDocument` 는 v0.2.0 (Frozen) 부터 Pydantic V2 + `model_config = ConfigDict(extra="forbid", frozen=True)` (`python/rhwp/ir/nodes.py:652-667`) +- fastmcp v3 in-process 측정 결과: + - `dict[str, Any]` 반환 시 outputSchema = 48 bytes (`{"additionalProperties": true, "type": "object"}`) + - `HwpDocument` 반환 시 outputSchema = 약 32 KB (`$defs` 약 20 종 — Provenance / InlineRun / DocumentSource / DocumentMetadata / Section / ParagraphBlock / TableCell / TableBlock / ImageRef / PictureBlock / FormulaBlock / FootnoteBlock / EndnoteBlock / TocEntryBlock / TocBlock / FieldBlock / UnknownBlock / ListItemBlock / CaptionBlock / Furniture) +- `HwpDocument.model_json_schema()` 단독 호출 시 약 35 KB — fastmcp wrap 에서 약간 작아짐. 정확한 byte 수는 IR 모델 갱신 / 라이브러리 버전 변경에 따라 변동 +- fastmcp 가 Pydantic 인스턴스를 자동 직렬화 (1차 소스: fastmcp v3.2.4 docs § Use Typed Models for Structured Output) — `model_dump(mode="json")` 의 *수동 호출* 은 더 이상 필요하지 않음 + +### 검증자 반박 + +- "21 KB schema 가 LLM 의 컨텍스트를 잡아먹지 않나?" → schema 는 도구 목록 listing 시점에만 한 번 노출. 매 호출마다 재전송되지 않음. 호출 본문 (`structured_content`) 만 매 호출 비용에 잡히는데 본문은 v0.5.0 과 byte-equal — 비용 변동 0 +- "이미 dict 로 충분한데 왜 강화하나?" → LLM 이 `result.body[0].kind == "paragraph"` 같은 참조를 정확히 추론하려면 outputSchema 의 `kind` 필드가 enum (`["paragraph", "table", ...]`) 으로 노출되어야 한다. dict 출력은 `additionalProperties: true` 만이라 LLM 이 키 이름조차 모름 +- "wire format 깨지지 않나?" → fastmcp 가 Pydantic 인스턴스 → `structured_content` 직렬화 시 `model_dump(mode="json")` 사용 (1차 소스: fastmcp v3.2.4 docs § Use Typed Models 의 응답 예시). v0.5.0 의 수동 dump 와 같은 함수 — byte-equal 보장 +- "frozen=True 모델을 fastmcp 가 정상 직렬화하나?" → frozen 은 입력 시점 (인스턴스 생성 후 mutation 차단) 에만 영향. `model_dump()` 는 read-only 라 frozen 무관 + +### 최종 결정 + +**B 채택** — `get_ir(path) -> HwpDocument`. `model_dump(mode="json")` 호출 제거 (메모리 1 회 절감) + outputSchema 강화 + wire format byte-equal. spec § 인수조건 AC-1 / AC-4 / AC-5 가 회귀 가드. + +### 1차 소스 + +- fastmcp v3.2.4 docs § Use Typed Models for Structured Output: +- fastmcp v3.2.4 docs § Defining Structured Tool Return Values: +- 본 프로젝트 `python/rhwp/mcp/tools.py:76-82` (현재 dict dump 코드) +- 본 프로젝트 `python/rhwp/ir/nodes.py:652-667` (HwpDocument 정의) +- in-process schema 측정 (2026-05-06): `uv run python -c "from rhwp.ir.nodes import HwpDocument; ..."` + +--- + +## 2. `iter_blocks` 의 Block 유니온 노출 + +### 팩트 + +- v0.5.0 의 `iter_blocks(path, ...)` 는 `[block.model_dump(mode="json") for block in ir_doc.iter_blocks(...)]` 패턴으로 `list[dict[str, Any]]` 반환 (`python/rhwp/mcp/tools.py:115-145`) +- `Block` 은 v0.3.0 부터 callable Discriminator + Tag 유니온 (11 변형: paragraph / table / picture / formula / footnote / endnote / list_item / caption / toc / field / unknown — `python/rhwp/ir/nodes.py:617-630`) +- callable Discriminator 의 동기: 미지의 `kind` 가 등장해도 `UnknownBlock` 으로 라우팅하는 forward-compat (`python/rhwp/ir/nodes.py:607-614` `_block_discriminator`). 구 버전 소비자가 새 spec 의 IR 을 읽을 때 read-only fail 회피 +- in-process 측정 (Block 유니온의 JSON Schema): + - `TypeAdapter(Block).json_schema()` = 약 30 KB / `$defs` 약 16 종 / `oneOf` 11 변형 + - fastmcp v3 가 `list[Block]` 반환을 wrap → outputSchema = 약 28 KB (배열 item 의 `$ref` 또는 inline `oneOf`) +- v0.5.0 dict 출력 schema 와 비교 시 schema 는 약 150 배 (177 bytes → 28 KB) 강화 + +### 검증자 반박 + +- "callable Discriminator 가 fastmcp 의 자동 schema 와 호환되나?" → Pydantic V2 의 callable Discriminator 는 `model_json_schema()` 호출 시 `oneOf` 11 변형으로 펼쳐진다 (in-process 검증 완료). fastmcp v3 가 Pydantic 의 `model_json_schema()` 에 위임하므로 호환 (1차 소스: fastmcp v3.2.4 docs § Use Typed Models 의 dataclass / Pydantic 동등 처리) +- "Block 의 재귀 구조 (`TableCell.blocks: list[Block]`, `FootnoteBlock.blocks: list[Block]` 등) 가 schema 안에서 무한 루프 안 도나?" → Pydantic 이 자동으로 `$ref` 순환 참조 사용 — JSON Schema spec 에 따라 정상 처리 +- "wrapper BlockRecord 모델로 한 단계 추상화 안 하나?" → wrapper 가 추가하는 정보 0 (`Block` 자체가 이미 모든 필드 포함). 추상화 부담만 추가 — 거부 +- "LLM 이 11 oneOf 변형 schema 를 정확히 해석하나?" → Anthropic / OpenAI tool calling 가이드가 discriminated union 을 1st-tier 패턴으로 권장. JSON Schema `oneOf` + discriminator 는 OpenAPI / Pydantic / TypeScript 의 표준 표현이라 LLM 학습 데이터에 흔함 +- "fastmcp Client 의 `result.data` 도 typed Block 인스턴스로 deserialize 되나?" → **아니다 — fastmcp v3.2.4 의 한계**. Client 의 자동 deserialization 이 단순 BaseModel 은 dynamic Pydantic 모델로 reconstruct (예: `HwpDocument`, `ChunkRecord`) 하지만, callable Discriminator + Tag 유니온의 `oneOf` schema 는 dynamic 모델로 변환 못 해 list element 가 dict 폴백. server side 출력 자체는 typed (sync handler 가 `list[Block]` 반환), client side 만 dict — `"kind"` key 노출되어 v0.5.0 dict access 패턴 그대로 동작 (backwards-compat). 진짜 typed access 가 필요하면 server side 직접 호출 (sync handler 결과) 또는 사용자 측 `Block.model_validate(d) for d in result.data` 명시적 reconstruct +- "fastmcp + jsonschema 의 strict `oneOf` validation 이 ParagraphBlock 과 UnknownBlock schema 양쪽 valid 인스턴스에서 fail 하지 않나?" → **충돌 발생** — 빈 ParagraphBlock (`{kind: "paragraph", text: "", inlines: [], prov: {...}}`) 가 ParagraphBlock 의 `kind: const "paragraph"` 와 UnknownBlock 의 `kind: str` + `extra: allow` 양쪽 valid → exactly-one 위반 → `RuntimeError: Invalid structured content`. 결정 5 (`UnknownBlock.kind` 의 `not.enum` 추가) 로 회복 + +### 최종 결정 + +**B 채택** — `iter_blocks(...) -> list[Block]`. Block 유니온이 callable Discriminator + Tag 로 fastmcp 자동 schema 의 `oneOf` 11 변형으로 호환. wrapper 모델 거부. 결정 5 (`UnknownBlock.kind not.enum`) 로 strict `oneOf` 충돌 해결. spec § 인수조건 AC-2 / AC-4 / AC-5 가 회귀 가드. + +### 1차 소스 + +- 본 프로젝트 `python/rhwp/ir/nodes.py:617-630` (Block 유니온 정의) +- 본 프로젝트 `python/rhwp/ir/nodes.py:607-614` (`_block_discriminator` callable) +- Pydantic V2 Discriminator + Tag 문서: +- fastmcp v3.2.4 docs § Use Typed Models for Structured Output: +- in-process schema 측정 (2026-05-06): `pydantic.TypeAdapter(Block).json_schema()` + +--- + +## 3. `chunks` 의 ChunkRecord + metadata 정책 + +### 팩트 + +- v0.5.0 의 `chunks(path, ...)` 가 LangChain Document 의 `page_content` / `metadata` 를 `[{"page_content": d.page_content, "metadata": d.metadata} for d in split_docs]` 로 평탄화 (`python/rhwp/mcp/tools.py:198-199`) +- `metadata` 의 키 집합이 mode × block kind 로 동적 (`python/rhwp/integrations/langchain.py:174-289` `_block_to_content_and_meta`): + - mode `single`: `source` / `paragraph_count` / `pages` / `sections` (4 키) + - mode `paragraph`: 위 + `paragraph_index` (5 키) + - mode `ir-blocks` × block kind 11 종: + - `paragraph`: + `kind` / `section_idx` / `para_idx` / `char_start` / `char_end` + - `table`: + `kind` / `section_idx` / `para_idx` / `rows` / `cols` / `text` / `caption` + - `picture`: + `kind` / `section_idx` / `para_idx` / `image_uri` / `image_mime` + - `formula`: + `kind` / `section_idx` / `para_idx` / `script_kind` / `inline` + - `footnote` / `endnote`: + `kind` / `section_idx` / `para_idx` / `number` / `marker_section_idx` / `marker_para_idx` + - `list_item`: + `kind` / `section_idx` / `para_idx` / `level` / `enumerated` + - 등 11 가지 분기 + - `include_furniture=True` 분기: 위 + `scope: "furniture"` +- 분기 모델 (`ChunkRecord_Single` / `_Paragraph` / `_IrBlocksParagraph` / `_IrBlocksTable` / ... 11 종) 로 표현 시 outputSchema 가 mode × kind = 약 13 모델 schema 의 `oneOf` — 약 50 KB+ 추정 (Block 유니온 schema 의 약 3 배) +- fastmcp v3.2.4 in-process 측정 결과: `ChunkRecord(page_content: str, metadata: dict[str, Any])` 단순 모델 = outputSchema 약 600 bytes + +### 검증자 반박 + +- "`metadata: dict[str, Any]` 는 LLM 에 정보를 안 주는데 강화 효과 없는 것 아닌가?" → `page_content` (str) + `metadata` (object) 의 *상위 schema* 는 강타입화됨. LLM 이 "각 청크는 page_content 와 metadata 두 필드를 가진다" 는 사실을 정확히 알게 됨. metadata 내부의 동적 키는 description 에 SSOT 위치 (`HwpLoader` docstring) 로 안내 +- "분기 모델 11 종은 forward-compat 가 깨지지 않나?" → 정확. 새 metadata 키 추가 시 모델 갱신 강제 + breaking change 위험. `metadata: dict[str, Any]` 는 새 키를 자유 추가 가능 — 본 프로젝트의 v0.3.0 스타일 (Provenance / TocEntry 의 forward-compat 필드 추가) 정합 +- "`metadata` 키 집합 SSOT 가 `HwpLoader` docstring 에 있는데 spec 에서 어떻게 검증?" → `tests/test_langchain_loader.py` / `tests/test_langchain_loader_ir.py` (v0.3.0 GA, langchain extras-gated) 가 mode × kind 별 metadata 키 집합을 behavior-driven 검증. 본 PATCH 는 그 위에 출력 schema 만 강화 — 검증 책임 분리 +- "왜 `Field(description="Mode-dependent metadata. ...")` 만으로 충분한가?" → MCP Tool 의 description 은 LLM 의 1st-class context. fastmcp 가 Pydantic Field description 을 그대로 outputSchema 에 노출 — LLM 이 mode 별 키 집합을 추론할 수 있는 텍스트가 schema 에 직접 포함 + +### 최종 결정 + +**B 채택** — `chunks(...) -> list[ChunkRecord]`, `ChunkRecord(page_content: str, metadata: dict[str, Any])`. mode × kind 분기 모델 거부 — schema 비대 + forward-compat 깨짐. spec § 인수조건 AC-3 / AC-4 / AC-7 이 회귀 가드. + +### 1차 소스 + +- 본 프로젝트 `python/rhwp/integrations/langchain.py:174-289` (mode × kind 별 metadata 키 SSOT) +- 본 프로젝트 `python/rhwp/mcp/tools.py:198-199` (현재 dict 평탄화 패턴) +- LangChain Core `Document` 모델: (`page_content: str` + `metadata: dict`) +- fastmcp v3.2.4 docs § Use Typed Models for Structured Output (description 노출 패턴): + +--- + +## 4. fastmcp `output_schema=` 수동 오버라이드 미사용 + +### 팩트 + +- fastmcp v3 의 `@mcp.tool` 데코레이터가 함수 반환 타입 어노테이션 (`-> HwpDocument`) 으로부터 outputSchema 자동 생성 (1차 소스: fastmcp v3.2.4 docs § Use Typed Models for Structured Output, § Defining Structured Tool Return Values) +- 자동 생성 path 는 Pydantic 의 `model_json_schema()` 호출 — Pydantic V2 의 schema 표준 (`oneOf` / `$defs` / `Discriminator` / `Field(description=...)` / `Literal` enum) 모두 지원 +- fastmcp v3 가 자동 schema 와 함께 wrapping (`{"properties": {"result": ...}, "x-fastmcp-wrap-result": true}`) 도 자동 처리 — primitive / list 반환을 MCP `structured_content` 의 single-key dict 형식에 맞춤 +- 수동 오버라이드 (`@mcp.tool(output_schema={...})`) 는 fastmcp 도 지원하나 (1차 소스 docs 의 `custom_schema_tool` 예시) drift 위험 (코드 변경 ↔ schema 갱신 불일치) + 추가 유지 비용 + +### 검증자 반박 + +- "Pydantic V2 의 자동 schema 가 strict mode (Anthropic Tool Use / OpenAI Structured Outputs) 와 호환되나?" → 본 프로젝트의 IR 모델이 strict mode 호환 룰 (글로벌 [CLAUDE.md](../../../CLAUDE.md) § Type Hints & Pydantic — `ge=` / `le=` 미사용, `Literal` 사용) 을 이미 따른다. 자동 schema 그대로 strict 통과 가능. 본 PATCH 의 별도 호환 작업 불필요 +- "자동 schema 가 향후 Pydantic 또는 fastmcp 업데이트로 깨질 가능성?" → `pyproject.toml` 의 `fastmcp>=3,<4` ceiling 이 v3 major 안에서 schema breaking 차단. spec § 인수조건 AC-4 (outputSchema 의 fact 검증) 가 회귀 가드 — 깨지면 CI 가 발견 +- "수동 오버라이드가 LLM 친화성 향상에 더 효과 있지 않나?" → fastmcp 자동 생성이 이미 `Field(description=...)` 를 schema 에 노출 + `Literal` enum + `Discriminator` `oneOf` 모두 지원. 수동 오버라이드의 추가 가치는 strict mode 의 부가 제약 (`additionalProperties: false` 명시 등) 정도인데 본 PATCH 는 strict mode 가 비목표 + +### 최종 결정 + +**A 채택** — fastmcp v3 의 자동 outputSchema 생성에 위임. 수동 오버라이드는 spec 의 비목표 (§ 비목표 4 항). spec § 인수조건 AC-4 의 회귀 가드가 자동 schema 의 정확성을 보장. + +### 1차 소스 + +- fastmcp v3.2.4 docs § Use Typed Models for Structured Output: +- fastmcp v3.2.4 docs § Defining Structured Tool Return Values: +- Pydantic V2 `model_json_schema()`: +- 글로벌 [CLAUDE.md](../../../CLAUDE.md) § Type Hints & Pydantic (strict mode 호환 룰) + +--- + +## 5. `UnknownBlock.kind` JSON Schema not.enum constraint + +### 팩트 + +- v0.3.0 부터 `UnknownBlock` 은 `kind: str` + `model_config = ConfigDict(extra="allow")` (`python/rhwp/ir/nodes.py:501-517`) — forward-compat 의 catch-all +- callable Discriminator (`_block_discriminator` — `python/rhwp/ir/nodes.py:607-614`) 가 `_KNOWN_KINDS` 에 매칭 안 되는 kind 만 UnknownBlock 으로 라우팅 — Pydantic 런타임 검증은 정확 +- Pydantic V2 의 `model_json_schema()` 가 callable Discriminator + Tag 유니온을 `oneOf` 11 변형으로 펼치되, UnknownBlock 의 `kind` 는 별도 constraint 없이 그대로 `{type: "string"}` 노출 +- 결과: `oneOf` 의 두 변형이 같은 인스턴스에 valid — ParagraphBlock 의 `{kind: "paragraph", text: "", inlines: [], prov: {...}}` 가 UnknownBlock schema 의 `extra: allow` + `kind: str` 에도 valid +- jsonschema lib 의 strict `oneOf` 의미는 *exactly one* — fastmcp v3.2.4 Client 의 `_validate_tool_result` (mcp.client.session 의 strict 검증) 가 `RuntimeError: Invalid structured content returned by tool iter_blocks` 로 fail +- 본 spec 의 PATCH backwards-compat 의무 (측면 1 의 wire format byte-equal) 가 client side 에서 깨짐 — v0.5.0 사용자가 v0.5.1 으로 upgrade 시 도구 호출 자체가 실패 + +### 검증자 반박 + +- "옵션 A — fastmcp Client 의 strict validation 우회?" → Client 의 `_validate_tool_result` 는 `list_tools()` 후 자동 호출. 우회는 hacky + 실제 production client (Claude Desktop / Cline 등) 에서도 같은 jsonschema lib 사용 가능성 — 근본 해결 아님 +- "옵션 C — fastmcp `output_schema=` 수동 오버라이드?" → 결정 4 (자동 위임) 와 충돌. 수동 schema 가 `oneOf` 와 다르게 작성되더라도 Pydantic V2 의 자동 schema 가 SSOT 인 상태가 깨짐 +- "옵션 Y — Block 유니온을 string Discriminator (`Discriminator("kind")`) 로 변경?" → forward-compat 깨짐 — string discriminator 는 정확 매칭만, 미지 kind 가 등장하면 `union_tag_invalid` 로 문서 전체 파싱 거부 (UnknownBlock 라우팅 불가). v0.3.0 의 callable Discriminator 결정과 충돌 +- "런타임 동작에 영향 없나?" → Pydantic V2 의 `Field(json_schema_extra=callable)` 은 **schema export 시점에만 호출** — `model_json_schema()` 결과에 추가될 뿐, `model_validate` (런타임 검증) 에는 무관. UnknownBlock 인스턴스 생성 / 직렬화 / round-trip 모두 동일 +- "기존 IR 테스트 (test_ir_schema_export / test_ir_schema) 회귀?" → 기존 테스트는 *Pydantic 라우팅* 검증 (callable Discriminator) 또는 `additionalProperties: false` 검증 만 — 본 변경은 `not.enum` 추가만이고 두 검증 모두 그대로 통과 (in-process 확인 — 제로 회귀) + +### 최종 결정 + +**B 채택** — `UnknownBlock.kind` 에 `Field(json_schema_extra=_unknown_kind_schema_extra)` 패턴으로 `not.enum: sorted(_KNOWN_KINDS)` 노출. callable 함수로 분리한 이유는 `_KNOWN_KINDS` 가 모듈 정의 순서상 `UnknownBlock` 뒤에 위치 — class definition 시점이 아닌 schema generation 시점에 평가해야 NameError 회피 (Pydantic V2 의 `json_schema_extra=callable` 표준 패턴). + +본 결정의 영향: +- 런타임: 변경 0 (callable Discriminator 가 SSOT 그대로) +- JSON Schema export: `UnknownBlock.kind` 가 `{type: "string", not: {enum: [...]}, ...}` — `_KNOWN_KINDS` 의 10 known kinds 모두 노출 +- packaged schema 파일 (`python/rhwp/ir/schema/hwp_ir_v1.json`) 갱신 필요 — `uv run python -m rhwp.ir.schema > python/rhwp/ir/schema/hwp_ir_v1.json` 으로 자동 재생성 +- spec § 인수조건 AC-5 의 회귀 가드 (fastmcp Client in-process round-trip 의 byte-equal) 가 본 결정의 효과를 검증 — not.enum 미적용 시 client validation 실패로 fail + +### 1차 소스 + +- 본 프로젝트 `python/rhwp/ir/nodes.py:501-517` (UnknownBlock 정의 — 변경 전) +- 본 프로젝트 `python/rhwp/ir/nodes.py:607-614` (`_block_discriminator` — 런타임 분기 SSOT) +- jsonschema spec `oneOf` semantics: +- Pydantic V2 `Field(json_schema_extra=callable)`: +- fastmcp v3.2.4 client validation (`mcp.client.session._validate_tool_result`): (mcp 1.x SDK 의 jsonschema 강제 검증) + +--- + +## 참조 + +- 짝 페어: [mcp-typed-output.md](../../roadmap/v0.5.1/mcp-typed-output.md) +- fastmcp v3.2.4 (jlowin / PrefectHQ): +- MCP Specification (`tools/list` / `outputSchema`): +- Anthropic Tool Use (Claude tool calling) docs: +- 본 프로젝트 `python/rhwp/ir/nodes.py` (IR Pydantic 모델 SSOT) +- 본 프로젝트 `python/rhwp/integrations/langchain.py` (LangChain HwpLoader metadata 키 SSOT) diff --git a/docs/implementation/v0.5.1/migration.md b/docs/implementation/v0.5.1/migration.md new file mode 100644 index 0000000..c97491b --- /dev/null +++ b/docs/implementation/v0.5.1/migration.md @@ -0,0 +1,212 @@ +--- +status: Frozen +description: "v0.5.1 구현 로그 — MCP tool 출력 schema 강타입화 (`get_ir` / `iter_blocks` / `chunks`). wire format byte-equal. `UnknownBlock.kind` JSON Schema not.enum 추가로 fastmcp strict oneOf 호환" +target: v0.5.1 +last_updated: 2026-05-07 +--- + +# v0.5.1 — MCP tool 출력 schema 강타입화 (구현 로그) + +[v0.5.1/mcp-typed-output](../../roadmap/v0.5.1/mcp-typed-output.md) (spec) + +[design/v0.5.1/mcp-typed-output-research](../../design/v0.5.1/mcp-typed-output-research.md) +(ADR) 의 구현 결과 로그. 결정의 근거·옵션 비교는 ADR 가 보유 — 본 문서는 +*산출물 / 검증 결과 / 호환성 / 이월 사항* 만 기록한다 (CONVENTIONS § CHANGELOG +↔ implementation log 역할 분리). + +PATCH release. 단일 세션 규모 (3 함수 시그니처 강화 + 신규 모델 1 + IR +JSON Schema constraint 1 + 테스트 5 클래스) 로 단일 `migration.md` 채택. v0.5.0 +의 stages 분할이 후속 polish PR 의 cross-cutting 변경 (Rust 4 + Python 6 + +docs 4) 을 5 stage 로 흩었던 이유와 대조 — 본 PATCH 는 단일 PR 안에 끝. + +## 1. 산출물 + +### Python 신규 모델 + +| 파일 | 신규 | 책임 | +|---|---|---| +| [python/rhwp/mcp/tools.py](../../../python/rhwp/mcp/tools.py) | `ChunkRecord(BaseModel)` | RAG 청크의 직렬화 표면 — `page_content: str` + `metadata: dict[str, Any]`. `model_config = ConfigDict(extra="forbid", frozen=True)`. mode × block kind 분기 거부 결정 (spec § 결정 5) 의 grep-friendly evidence 는 AC-7 회귀 가드 | + +### Python 시그니처 강화 + +| 파일 / 함수 | v0.5.0 | v0.5.1 | +|---|---|---| +| `python/rhwp/mcp/tools.py::get_ir` | `(path) -> dict[str, Any]` | `(path) -> HwpDocument` | +| `python/rhwp/mcp/tools.py::iter_blocks` | `(path, kind?, scope, limit?) -> list[dict[str, Any]]` | `(path, kind?, scope, limit?) -> list[Block]` | +| `python/rhwp/mcp/tools.py::chunks` | `(path, mode, size, overlap, include_furniture) -> list[dict[str, Any]]` | `(path, mode, size, overlap, include_furniture) -> list[ChunkRecord]` | + +3 함수 모두 `model_dump(mode="json")` 호출 / 수동 dict 평탄화 제거 — `fastmcp` 가 +자동 직렬화에 위임 (spec § 결정 6). 호출 시그니처 / 입력 schema / 외부 wire +format 은 v0.5.0 그대로 (PATCH SemVer 의무 — § 호환성 절). + +### IR JSON Schema constraint (spec § 결정 8) + +| 파일 / 위치 | 변경 | +|---|---| +| [python/rhwp/ir/nodes.py](../../../python/rhwp/ir/nodes.py) | `UnknownBlock.kind` 의 `Field(json_schema_extra=_unknown_kind_schema_extra)` callable 추가. callable 이 `not.enum: sorted(_KNOWN_KINDS)` 를 schema dict 에 in-place 삽입. `_KNOWN_KINDS` 가 모듈 정의 순서상 `UnknownBlock` 뒤에 위치하므로 lambda/함수의 lazy 평가로 NameError 회피 | +| [python/rhwp/ir/schema/hwp_ir_v1.json](../../../python/rhwp/ir/schema/hwp_ir_v1.json) | packaged JSON Schema 자동 재생성 (`uv run python -m rhwp.ir.schema > python/rhwp/ir/schema/hwp_ir_v1.json`). 본 PATCH 의 자동 산출물 — `UnknownBlock.kind` 의 `not.enum` 반영. 기존 `additionalProperties: false` invariant 그대로 | + +런타임 동작 0 변경 — Pydantic V2 의 `json_schema_extra=callable` 은 schema +export 시점에만 호출, `model_validate` (런타임 검증) 무관. callable Discriminator +(`_block_discriminator`) 가 SSOT 으로 known/unknown 분기 — UnknownBlock 인스턴스가 +known kind 를 가질 수 없는 invariant 보존. + +### 테스트 + +| 파일 | 변동 | 책임 | +|---|---|---| +| [tests/test_mcp_server.py](../../../tests/test_mcp_server.py) | +320 / -8 | v0.5.1 신규 5 테스트 클래스 (`TestTypedSignatures` AC-1~AC-3, AC-7 / `TestTypedOutputSchema` AC-4 / `TestBackwardsCompat` AC-5 / `TestTypedClientData` AC-6 / `TestTypedModelRoundTrip` Pydantic 결정성). 기존 `TestGetIr` / `TestIterBlocks` / `TestChunks` 의 dict access 검증을 typed model 검증으로 전환 | + +기존 IR 테스트 (`tests/test_ir_schema_export.py`, `tests/test_ir_schema.py`, +`tests/test_ir_iter_blocks.py`, `tests/test_ir_roundtrip.py`, `tests/test_ir_toc.py`, +`tests/test_ir_plain_text.py`) — 변경 없음. 모두 회귀 0 (in-process 확인). + +### 문서 + +| 파일 | 변경 | +|---|---| +| [README.md](../../../README.md) | § "MCP server (`rhwp-mcp`)" 의 도구 표 출력 컬럼을 강타입 (`HwpDocument` / `list[Block]` / `list[ChunkRecord]`) 으로 갱신. v0.5.1 마이그 노트 한 단락 추가 — `result.structured_content` byte-equal + `result.data` access 패턴 변경 (dict→typed) + `iter_blocks` list element 의 dict 폴백 안내 | +| [docs/roadmap/v0.5.1/mcp-typed-output.md](../../roadmap/v0.5.1/mcp-typed-output.md) (spec) | Draft body 보강 — § wire format byte-equal 의 wrap 분기 표 (BaseModel = inline / `list[T]` = `{"result": ...}`), § 결정 8 (`UnknownBlock.kind` not.enum), AC-5 wrap 분기 명시, AC-6 fastmcp 한계 명시, § 다른 산출물 파급에 nodes.py / hwp_ir_v1.json 추가 | +| [docs/design/v0.5.1/mcp-typed-output-research.md](../../design/v0.5.1/mcp-typed-output-research.md) (ADR) | Draft body 보강 — 결정 매트릭스 row 5 (UnknownBlock not.enum) 추가, § 5 신규 (옵션 비교 + 검증자 반박 + 1차 소스), § 2 검증자 반박에 fastmcp client deserialization 한계 추가 | +| [docs/traces/coverage.md](../../traces/coverage.md) | spec_trace 자동 갱신 — 16 새 v0.5.1/mcp-typed-output#AC-N row 추가 (총 48 spec / 622 test mappings) | +| [docs/roadmap/README.md](../../roadmap/README.md) | 활성 spec 인덱스에 v0.5.1 (Draft) row 추가 — spec 시작 시 작성됨 | + +### CI / 메타 (의도된 보류) + +다음 항목은 spec § 다른 산출물의 파급에 명시되어 있으나 **본 step 의 범위 밖** +— 사용자 GA 절차 (별도 commit) 에서 진행: + +- `Cargo.toml` 0.5.0 → 0.5.1 bump +- `CHANGELOG.md` `[0.5.1]` 섹션 추가 +- spec / ADR `Draft → Frozen` flip +- git tag `v0.5.1` + GitHub Release + +## 2. 결정 사항 (spec 결정 8 항목 ↔ 구현 매핑) + +| spec 결정 | 구현 위치 | +|---|---| +| 1 — `get_ir` 출력 모델 = `HwpDocument` | `python/rhwp/mcp/tools.py:get_ir` | +| 2 — `iter_blocks` 출력 모델 = `list[Block]` | `python/rhwp/mcp/tools.py:iter_blocks` | +| 3 — `chunks` 출력 모델 = `list[ChunkRecord]` | `python/rhwp/mcp/tools.py:chunks` + `ChunkRecord` 정의 | +| 4 — wire format 보존 정책 | `tests/test_mcp_server.py::TestBackwardsCompat` 3 케이스 (AC-5) | +| 5 — `metadata` 자유 dict 유지 | `ChunkRecord.metadata: dict[str, Any]` + `tests/test_mcp_server.py::TestTypedSignatures::test_chunk_record_metadata_annotation_is_free_dict` (AC-7) | +| 6 — fastmcp `output_schema=` 수동 오버라이드 미사용 | `python/rhwp/mcp/server.py` 변경 없음 — 자동 schema 생성 그대로 | +| 7 — 호출 시그니처 보존 | tools.py 함수 signature — kind / scope / limit / mode 등 v0.5.0 그대로 | +| 8 — `UnknownBlock.kind` JSON Schema not.enum | `python/rhwp/ir/nodes.py:_unknown_kind_schema_extra` callable + `UnknownBlock.kind` Annotated. `tests/test_ir_schema_export.py::test_unknown_kind_routing_pydantic_matches_schema` 가 회귀 가드 | + +## 3. 호환성 + +| 시나리오 | 결과 | +|---|---| +| **기존 fastmcp Client 사용자 (`result.structured_content` raw dict access)** | byte-equal 보장 (`TestBackwardsCompat` × 3). 영향 0 | +| **기존 fastmcp Client 사용자 (`result.data` 인덱싱 — 예: `result.data["body"]`)** | v0.5.1 부터 `result.data` 가 typed Pydantic-like 객체 (`get_ir` / `chunks`) — dict 인덱싱 → attribute access 마이그 필요 (`result.data.body`). README 마이그 노트가 안내 | +| **`iter_blocks` 사용자 (`result.data[0]["kind"]`)** | fastmcp v3 의 `oneOf` deserialization 한계로 list element 가 dict 폴백 — v0.5.0 dict access 패턴 그대로 동작 (backwards-compat 보존) | +| **server side 사용자 (sync handler 직접 호출 — `tools.iter_blocks(path)`)** | v0.5.1 부터 `list[Block]` (typed) 반환 — `block["kind"]` → `block.kind` 마이그 필요. server side 직접 호출은 server-internal 패턴이라 외부 영향 작음 | +| **IR `UnknownBlock` 직접 생성 (Pydantic 런타임 검증)** | 변경 없음. `UnknownBlock(kind="future_kind", prov=...)` 그대로 작동. `not.enum` 은 schema export 만 | +| **IR `HwpDocument.model_dump(mode="json")` round-trip** | 변경 없음. `model_validate_json(model_dump_json())` 동등 — `TestTypedModelRoundTrip` 가 회귀 가드 | +| **Schema validator (`hwp_ir_v1.json` 또는 content-addressed alias)** | packaged schema 갱신 — `UnknownBlock.kind` 의 `not.enum` 반영. v0.3.0 부터 사용한 schema 검증 invariant (`additionalProperties: false`) 그대로 | +| **CI `test-without-extras` job (skip count = 5)** | 변경 없음. `tests/test_mcp_server.py` 의 file-level `pytest.importorskip("fastmcp")` (line 42) 위치 보존 | +| **`tests/type_check_errors.py` 의 4 intentional pyright errors** | 변경 없음 | + +**SemVer**: PATCH (0.5.0 → 0.5.1). 외부 wire format 보존 + 기존 dict access 패턴 +보존 (iter_blocks list element). `result.data` 에 dict 인덱싱을 직접 했던 +사용자만 attribute access 마이그 필요 — README 한 단락으로 안내. + +## 4. 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest tests/ -m "not slow"` (전체) | **585 passed, 2 skipped** (aift fixture 의 미주/수식 부재 — pre-existing), 6 deselected (slow). 신규 v0.5.1 테스트 16 모두 그린 | +| `uv run pytest tests/test_mcp_server.py -m "not slow"` | **55 passed** — v0.5.0 의 39 + v0.5.1 신규 16 | +| `uv run pyright python/rhwp/ir/nodes.py python/rhwp/mcp/ tests/test_mcp_server.py` | 0 errors | +| `uv run pyright tests/type_check_errors.py` | 4 intentional errors 보존 (CI invariant) | +| `uv run ruff check python/rhwp/ir/nodes.py python/rhwp/mcp/ tests/test_mcp_server.py` | clean | +| `uv run python scripts/lint_docs.py` | exit 0 | +| `uv run python scripts/generate_spec_trace.py` | 갱신 완료 — 48 spec / 622 test mappings (v0.5.1 16 신규) | +| `cargo` 빌드 | **변경 0** — Rust 코드 변경 없음, `maturin develop` 재빌드 불필요 | +| code-reviewer (fresh-context, sub-agent) | PASS — HIGH 0 / MEDIUM 4 / LOW 2. MEDIUM-1 (측정값 stale) + MEDIUM-3 (라이브러리 이름 in comments) 즉시 처리됨. MEDIUM-2 (Cargo / CHANGELOG) 는 사용자 의도된 보류 (GA 절차로 이양). MEDIUM-4 / LOW-1 / LOW-2 는 pre-existing 또는 spec design intent — no action | + +### AC ↔ 테스트 매핑 + +| AC | 위치 | 테스트 | +|---|---|---| +| AC-1 (`get_ir` return = `HwpDocument`) | `tests/test_mcp_server.py::TestTypedSignatures::test_get_ir_return_annotation_is_hwp_document`, `TestTypedModelRoundTrip::test_get_ir_round_trip` | +| AC-2 (`iter_blocks` return = `list[Block]`) | `TestTypedSignatures::test_iter_blocks_return_annotation_is_list_of_block` | +| AC-3 (`chunks` return = `list[ChunkRecord]` + module export) | `TestTypedSignatures::test_chunks_return_annotation_is_list_of_chunk_record`, `..._chunk_record_is_exposed_on_tools_module`, `TestTypedModelRoundTrip::test_chunk_record_round_trip` | +| AC-4 (outputSchema 강화) | `TestTypedOutputSchema` × 3 (`get_ir` defs / `iter_blocks` oneOf 변형 / `chunks` page_content+metadata) | +| AC-5 (wire format byte-equal) | `TestBackwardsCompat` × 3 (`get_ir` no-wrap / `iter_blocks` `{"result": ...}` wrap / `chunks` `{"result": ...}` wrap) | +| AC-6 (`result.data` typed-or-dict) | `TestTypedClientData` × 3 (`get_ir` typed / `iter_blocks` list[dict] fallback / `chunks` typed) | +| AC-7 (`ChunkRecord.metadata: dict[str, Any]`) | `TestTypedSignatures::test_chunk_record_metadata_annotation_is_free_dict` | +| AC-8 (도구 7 개 등록 회귀) | 기존 `TestToolRegistry::test_lists_exactly_seven_tools` (v0.5.0/mcp#AC-2 marker 그대로) | +| AC-9 (extras / skip count 변동 없음) | CI `test-without-extras` job (`.github/workflows/ci.yml`) — `pytest.importorskip("fastmcp")` file-level + `5 skipped` regex | +| AC-10 (README 갱신) | manual inspection — 도구 표 + 마이그 노트 | + +10/10 AC 모두 충족. + +## 5. fastmcp v3 한계 — 본 PATCH 작업 중 표면화 + +본 PATCH 작업 중 spec 의 가정과 fastmcp v3.2.4 의 실제 동작이 두 군데 차이 — +spec body 갱신으로 일관성 회복: + +1. **`structured_content` wrap 분기** — BaseModel 반환 (예: `HwpDocument`) 은 wrap + 없이 fields 직접 노출, `list[T]` / scalar 반환은 `{"result": [...]}` wrap. spec + § wire format byte-equal 의 wrap 분기 표가 정확한 검증 패턴 제공. + +2. **callable Discriminator + Tag union 의 client-side deserialization 한계** — + fastmcp Client 의 자동 reconstruct 가 단순 BaseModel 은 dynamic 모델로 변환 + 하지만, callable Discriminator + Tag 유니온의 `oneOf` schema 는 변환 못 해 + list element 가 dict 폴백. server side 의 typed 출력 (sync handler 결과) 은 + AC-2 가 cover, wire format byte-equal 은 AC-5 가 cover. spec AC-6 본문에 + typed-or-dict 분기 명시. + +추가 발견: **fastmcp Client + jsonschema 의 strict `oneOf` validation** 이 +ParagraphBlock 과 UnknownBlock schema 양쪽 valid 인스턴스 (예: 빈 ParagraphBlock) +에서 fail → client side wire format 호환 실제 깨짐. 결정 8 (`UnknownBlock.kind` +not.enum) 으로 회복. + +세 가지 모두 spec 작성 시 in-process 측정으로 발견하지 못한 fastmcp v3 + +jsonschema strict 동작과 callable Discriminator schema 한계가 검증 단계에서 +표면화. 코드 fix 는 모두 표준 Pydantic V2 / fastmcp 패턴. + +## 6. 알려진 한계 / 이월 사항 + +다음 항목은 v0.5.1 범위 밖. spec § 미확정 이슈 가 정확한 목록 — 본 절은 +v0.5.1 작업 중 표면화된 항목 + 보류 결정 정리. + +| 항목 | 상태 | 후속 | +|---|---|---| +| `HwpDocument.schema_version` UserWarning 의 fastmcp 응답 흐름 | 본 PATCH 범위 밖 (spec § 미확정) | 별도 손 검증 (fastmcp Client 의 stderr 캡처) | +| `HwpDocument` 본문 (수 MB IR JSON) 의 MCP 응답 한도 | 본 PATCH 범위 밖 (v0.5.0 § 미확정 그대로 — 본 PATCH 의 wire format byte-equal 의무가 payload 자체를 변경 못함) | v0.6.0+ `--max-bytes` / `Resource` 추상 spec | +| Anthropic Tool Use strict mode 호환 (Field `ge=`/`le=` 금지 등) | IR 모델 미사용 — 보존됨 (gloval CLAUDE.md § Type Hints & Pydantic) | 미래 strict tool calling 사용처에서 검증 | +| fastmcp Client 의 `iter_blocks` typed list (oneOf union dynamic 모델 reconstruct) | fastmcp v3.2.4 한계 — spec AC-6 본문에 명시. dict fallback 으로 backwards-compat 보존 | fastmcp 후속 버전이 oneOf 처리 추가하면 자동 갱신 | +| `Cargo.toml` bump / `CHANGELOG.md [0.5.1]` 섹션 추가 | 본 PATCH step 의 의도된 보류 — 사용자 GA 절차로 이양 | 별도 `chore: v0.5.1 release marker` commit | + +## 7. v0.5.1 GA 절차 (인계) + +본 step 이후 v0.5.1 GA 까지의 release 절차 (CONVENTIONS § GA 절차): + +1. **`Cargo.toml` version bump** — 0.5.0 → 0.5.1 (CLAUDE.md § 버전 관리 의 SSOT) +2. **`mcp-typed-output.md` / `mcp-typed-output-research.md` frontmatter flip** — `status: Draft → Frozen`, `target: v0.5.1 → ga: v0.5.1` (CONVENTIONS § GA 절차) +3. **본 `migration.md` frontmatter** — 이미 Frozen + target: v0.5.1 (post-S1 docs-lint 정책의 pre-GA stage 면제 그대로 적용) +4. **`docs/roadmap/README.md` 인덱스 갱신** — v0.5.1 row 를 Frozen 으로 표시 + 구현 / 검증 로그 표에 v0.5.1 row 추가 +5. **`CHANGELOG.md` 항목 추가** — v0.5.1 의 변경 요약 + external/rhwp 서브모듈 commit 핀 (v0.5.0 동일 — 변경 없음) +6. **git tag `v0.5.1`** + GitHub Release 생성 — `publish.yml` 트리거 (Trusted Publisher OIDC) +7. **release 후 손 검증** — 본인 업무 HWP 파일로 examples/06 + Claude Desktop 통합 검증 (typed `result.data` 의 attribute access) + +## 8. 참조 + +### 짝 페어 + +- spec: [docs/roadmap/v0.5.1/mcp-typed-output.md](../../roadmap/v0.5.1/mcp-typed-output.md) +- ADR: [docs/design/v0.5.1/mcp-typed-output-research.md](../../design/v0.5.1/mcp-typed-output-research.md) + +### 외부 + +- fastmcp v3.2.4 docs § Use Typed Models for Structured Output: +- jsonschema spec `oneOf` semantics: +- Pydantic V2 `Field(json_schema_extra=callable)`: +- LangChain Core `Document` 모델 (ChunkRecord 의 source-of-truth): + +### 상류 + +본 v0.5.1 은 상류 (`edwardkim/rhwp`) 변경 0 — pure Python schema 강화. +`external/rhwp` submodule pin (v0.5.0 그대로) 보존. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index b1fbbc2..1d4fb4d 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -13,6 +13,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe - **v0.3.2** — Frozen, UTF-16 → codepoint 변환 SSOT 단일화 GA (2026-05-03) - **v0.4.0** — Frozen, IR view 렌더러 (Markdown / HTML) GA (2026-05-05) - **v0.5.0** — Frozen, MCP server (`rhwp-mcp`) GA (2026-05-06) +- **v0.5.1** — Draft, MCP tool 출력 schema 강타입화 — [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) - **v0.6.0+** — 미착수 (주제 미정, demand-driven) ## 활성 spec 인덱스 @@ -29,6 +30,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.3.2 (IR upstream UTF-16 helper) | Frozen | [v0.3.2/ir-upstream-utf16-helper.md](v0.3.2/ir-upstream-utf16-helper.md) | [design/v0.3.2/ir-upstream-utf16-helper-research.md](../design/v0.3.2/ir-upstream-utf16-helper-research.md) | | v0.4.0 (view 렌더러) | Frozen | [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md) | [design/v0.4.0/view-renderer-research.md](../design/v0.4.0/view-renderer-research.md) | | v0.5.0 (MCP server) | Frozen | [v0.5.0/mcp.md](v0.5.0/mcp.md) | [design/v0.5.0/mcp-research.md](../design/v0.5.0/mcp-research.md) | +| v0.5.1 (MCP typed output) | Draft | [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) | [design/v0.5.1/mcp-typed-output-research.md](../design/v0.5.1/mcp-typed-output-research.md) | ## 미착수 작업 계획 @@ -36,9 +38,9 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe ### v0.6.0+ — 미정 (demand-driven) -v0.5.0 MCP server (Frozen, [v0.5.0/mcp.md](v0.5.0/mcp.md)) 이후 다음 minor 들. 주제 미정 — v0.3.0 LangChain integration + v0.5.0 MCP 가 RAG / LLM 에이전트 사용처 분모를 이미 커버하는 상황에서 추가 RAG 프레임워크 통합은 **demand-driven 으로 보류** (HWP × 비-LangChain RAG 교집합이 좁을 가능성). 구체화되면 `/new-spec ` 으로 promote. +v0.5.0 MCP server (Frozen, [v0.5.0/mcp.md](v0.5.0/mcp.md)) + v0.5.1 후속 polish (Draft, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)) 이후 다음 minor 들. 주제 미정 — v0.3.0 LangChain integration + v0.5.0 MCP 가 RAG / LLM 에이전트 사용처 분모를 이미 커버하는 상황에서 추가 RAG 프레임워크 통합은 **demand-driven 으로 보류** (HWP × 비-LangChain RAG 교집합이 좁을 가능성). 구체화되면 `/new-spec ` 으로 promote. -> v0.4.0 view 렌더러 (Markdown / HTML) / v0.5.0 MCP server (`rhwp-mcp`) 모두 GA 완료 — [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md), [v0.5.0/mcp.md](v0.5.0/mcp.md) 가 SSOT. +> v0.4.0 view 렌더러 (Markdown / HTML) / v0.5.0 MCP server (`rhwp-mcp`) 모두 GA 완료 — [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md), [v0.5.0/mcp.md](v0.5.0/mcp.md) 가 SSOT. v0.5.1 은 v0.5.0 의 출력 schema 강화 PATCH (Draft, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)). ### v0.8.0 ~ v1.0.0 — JSON IR → HWP 역생성 diff --git a/docs/roadmap/v0.5.1/mcp-typed-output.md b/docs/roadmap/v0.5.1/mcp-typed-output.md new file mode 100644 index 0000000..d08c53b --- /dev/null +++ b/docs/roadmap/v0.5.1/mcp-typed-output.md @@ -0,0 +1,209 @@ +--- +status: Draft +description: "v0.5.1 — 'rhwp-mcp' 도구 출력 schema 강타입화. 'get_ir' / 'iter_blocks' / 'chunks' 의 'dict[str, Any]' 반환을 Pydantic 모델로 교체. 'UnknownBlock.kind' JSON Schema not.enum 추가로 fastmcp strict 'oneOf' 호환" +target: v0.5.1 +last_updated: 2026-05-07 +--- + +# v0.5.1 — MCP 도구 출력 schema 강타입화 + +v0.5.0 GA 한 `rhwp-mcp` 의 7 도구 중 약타입 (`dict[str, Any]` / `list[dict[str, Any]]`) 으로 반환하는 3 도구 (`get_ir` / `iter_blocks` / `chunks`) 를 Pydantic V2 모델 반환으로 전환한다. fastmcp v3 의 자동 outputSchema 생성이 LLM 에 노출하는 schema 가 weak (`additionalProperties: true` 만) → strong (필드별 타입 / Discriminator + Tag 11 변형 / required 명시) 으로 강화되어 LLM 의 응답 해석 / 후속 도구 호출 정확도를 높인다. PATCH 라 사용자 코드 영향 0 — 호출 시그니처 / 반환 wire format (JSON) 모두 동일. + +주요 결정의 근거·대안·실패 시나리오는 짝 페어: [mcp-typed-output-research.md](../../design/v0.5.1/mcp-typed-output-research.md). + +## 배경 — 왜 v0.5.1 PATCH 인가 + +v0.5.0 spec ([roadmap/v0.5.0/mcp.md](../README.md) 참조) 의 §노출 도구 표에서 7 도구 중 4 도구는 이미 강타입 (`ParseSummary` Pydantic, `str` × 3) 이지만 3 도구가 약타입으로 출고된다. v0.5.0 S3 의 `code-reviewer` fresh-context 검증이 LOW-2 로 권고: "iter_blocks / get_ir / chunks 가 모두 같은 패턴 (`list[dict[str, Any]]`) 이라 본 PR 범위 밖에서 일괄 처리". S3 / S5 implementation log 가 "후속 polish 로 보류" 명시. + +v0.5.0 GA (2026-05-06) 직후 후속 polish 시점이 v0.5.1 PATCH. fastmcp v3 의 outputSchema 생성을 in-process 측정하면 약타입 vs 강타입 차이가 측정 가능한 fact 로 잡힌다 — 본 spec § 측정값 참조. + +`v0.5.0/mcp.md` § 미확정 이슈 5 항 (`get_ir` 응답 크기 / 에러 응답 형식 통일 / Resource 추상 / Prompt 추상 / 클라이언트 호환성 손 검증) 중 본 spec 은 어디에도 직접 매핑되지 않는다 — `code-reviewer` 후속 polish 만 다룬다. 다른 4 항은 demand-driven 또는 v0.6.0+ 별도 spec 으로 보류 그대로. + +## 목표와 비목표 + +### v0.5.1 목표 + +1. **`get_ir` 반환을 `HwpDocument` 로 교체** — `model_dump(mode="json")` 호출 제거, fastmcp 가 자동 직렬화 +2. **`iter_blocks` 반환을 `list[Block]` 으로 교체** — `Block` 은 v0.3.0 의 Discriminator + Tag 유니온 11 변형 그대로 재사용 +3. **`chunks` 반환을 `list[ChunkRecord]` 로 교체** — `ChunkRecord` 신규 Pydantic 모델 (`page_content: str` + `metadata: dict[str, Any]`) +4. **wire format 보존** — fastmcp Client 의 `result.structured_content` 가 v0.5.0 출력과 byte-equal (Pydantic `model_dump(mode="json")` 결과 == 기존 dict). Claude Desktop / Cline / 자체 에이전트의 기존 LLM 프롬프트 / 후처리 코드 영향 0 +5. **outputSchema 강화 확인** — fastmcp 자동 생성 schema 의 `additionalProperties: true` (약) 를 필드별 type + required + (Block 의 경우) `oneOf` 변형 11 종 (강) 으로 교체. 측정값으로 회귀 가드 추가 + +### 비목표 (v0.5.1) + +- **`get_ir` 응답 크기 제한** (`--max-bytes` / `Resource` 추상화) — `v0.5.0/mcp.md` §미확정 이슈, 별도 spec 검토 사항. 본 PATCH 는 출력 schema 만 다룸 +- **에러 응답 형식 통일** — `v0.5.0/mcp.md` §미확정 이슈, demand-driven 보류 +- **MCP `Resource` / `Prompt` 추상** — `v0.5.0/mcp.md` §미확정 이슈, 차기 minor 별도 spec +- **`HwpDocument` / `Block` / `ChunkRecord` 등 새 출력 모델의 `output_schema=` 수동 오버라이드** — fastmcp v3 의 자동 schema 생성이 충분 (1차 소스: fastmcp v3.2.4 docs § Use Typed Models for Structured Output). 수동 오버라이드는 추가 유지 비용 +- **`chunks` 의 mode 별 ChunkRecord 분기** (`ChunkRecord_Single` / `_Paragraph` / `_IrBlocks`) — `metadata` 의 키 집합이 mode 별로 동적이지만 (kind / section_idx / para_idx / char_start / image_uri / rows / cols / scope 등) Pydantic 분기 모델은 LLM 이 mode 별로 다른 schema 를 추론해야 해 schema 가 3 배로 비대해지고 metadata 의 forward-compat (새 mode / 새 metadata 키 추가 시 모델 갱신 강제) 도 깨짐. 본 PATCH 는 `metadata: dict[str, Any]` 단일 필드 유지 (결정 5 참조) + +### 영구 비범위 + +- 서버 → 클라이언트 wire format 의 *breaking* 변경 — JSON 키 / 값 형식 / 의미는 v0.5.0 과 byte-equal 유지 (PATCH 의 backwards-compat 의무, SemVer) +- Pydantic V1 호환 — 프로젝트 전반이 Pydantic V2 (글로벌 [CLAUDE.md](../../../CLAUDE.md)) + +## 변경 도구 매트릭스 + +| 도구 | v0.5.0 반환 시그니처 | v0.5.1 반환 시그니처 | wire format | +|---|---|---|---| +| `parse_hwp_summary` | `ParseSummary` (이미 강타입) | (변경 없음) | byte-equal | +| `extract_text` | `str` | (변경 없음) | byte-equal | +| `to_markdown` | `str` | (변경 없음) | byte-equal | +| `to_html` | `str` | (변경 없음) | byte-equal | +| **`get_ir`** | `dict[str, Any]` (= `HwpDocument.model_dump(mode="json")`) | `HwpDocument` | byte-equal | +| **`iter_blocks`** | `list[dict[str, Any]]` (= `[Block.model_dump(mode="json"), ...]`) | `list[Block]` | byte-equal | +| **`chunks`** | `list[dict[str, Any]]` (= `[{"page_content": str, "metadata": dict}, ...]`) | `list[ChunkRecord]` | byte-equal | + +`Block` 은 v0.3.0 IR 의 Discriminator + Tag 유니온 (11 변형: paragraph / table / picture / formula / footnote / endnote / list_item / caption / toc / field / unknown) — `python/rhwp/ir/nodes.py` 가 SSOT, 본 spec 은 import 하여 재사용. + +## 새 Pydantic 모델 — `ChunkRecord` + +`get_ir` / `iter_blocks` 는 기존 IR 모델 (`HwpDocument` / `Block`) 을 그대로 노출하므로 새 모델 불필요. `chunks` 만 신규. + +```python +# python/rhwp/mcp/tools.py +from typing import Any +from pydantic import BaseModel, ConfigDict, Field + + +class ChunkRecord(BaseModel): + """RAG 청크의 직렬화 표면 — LangChain Document 의 page_content / metadata 평탄화.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + page_content: str = Field( + description="Chunk text (마크다운 / 평문 / HTML — chunks mode 에 따름).", + ) + metadata: dict[str, Any] = Field( + description=( + "Mode-dependent metadata. 공통 키 source / paragraph_count + mode 별 키 — " + "paragraph: paragraph_index, ir-blocks: kind / section_idx / para_idx / " + "char_start / char_end / image_uri / rows / cols / caption / scope. " + "키 집합은 'rhwp.integrations.langchain.HwpLoader' 가 SSOT." + ), + ) +``` + +`metadata: dict[str, Any]` 로 두는 정당성 — `HwpLoader._block_to_content_and_meta()` 가 block kind 별로 키 집합을 동적 생성 (`paragraph` 는 char_start / char_end, `table` 은 rows / cols / caption / text, `picture` 는 image_uri / image_mime, `formula` 는 script_kind / inline 등). Pydantic 분기 모델로 강타입화하면 mode × kind 조합 (3 × 11) 에 대해 별도 모델 11 종이 필요해 schema 비대 + forward-compat (새 metadata 키 추가 시 모델 갱신 강제) 부담. 본 PATCH 는 page_content / metadata 의 *상위 schema* 만 강타입화하고 metadata 내부는 자유 dict 로 둔다. + +## backwards-compat 보장 전략 + +PATCH 의 SemVer 의무 — 기존 사용자 코드 영향 0. 두 측면: + +### 측면 1 — wire format byte-equal + +fastmcp Client 의 `result.structured_content` (raw dict) 가 v0.5.0 과 동일해야 함. Pydantic `model_dump(mode="json")` 의 결정성: + +- `HwpDocument` / `Block` / `ChunkRecord` 모두 `model_config = ConfigDict(extra="forbid", frozen=True)` — 동일 input 에 동일 dict 출력 보장 +- v0.5.0 의 `get_ir` 가 `doc.to_ir().model_dump(mode="json")` 로 떨어뜨린 dict 와 v0.5.1 의 `get_ir` 가 반환한 `HwpDocument` 인스턴스를 fastmcp 가 직렬화한 dict 는 동일 함수 (Pydantic V2 의 `model_dump`) 를 거쳐 byte-equal — 직접 `assert get_ir_v050(path) == json.loads(json.dumps(get_ir_v051(path).model_dump(mode="json")))` 로 검증 + +#### `structured_content` wrap 분기 (fastmcp v3 의 결정성) + +fastmcp v3 가 반환 타입에 따라 두 가지 wrap 패턴 적용 (1차 소스: fastmcp v3.2.4 docs § Structured Result Wrapping): + +| 반환 시그니처 | wrap 패턴 | 회귀 가드 비교 | +|---|---|---| +| `HwpDocument` (단일 BaseModel) | wrap 없음 — `structured_content` 가 모델의 fields 를 직접 노출 | `assert result.structured_content == doc.model_dump(mode="json")` | +| `list[Block]` / `list[ChunkRecord]` (list of T) | `{"result": [...]}` wrap | `assert result.structured_content == {"result": [b.model_dump(mode="json") for b in ...]}` | + +본 PATCH 의 byte-equal 회귀 가드 (AC-5) 가 두 패턴을 분리 검증. + +### 측면 2 — fastmcp Client 의 두 접근 면 + +fastmcp Client 가 `result.data` 와 `result.structured_content` 를 둘 다 노출 (1차 소스: fastmcp v3.2.4 docs § Accessing Structured Results): + +- `result.data` — typed 반환 (`HwpDocument` / `list[Block]` / `list[ChunkRecord]` 인스턴스). v0.5.1 신규 표면, 사용자가 강타입 활용 시 사용 +- `result.structured_content` — raw dict (예: `{"result": [...]}`). v0.5.0 사용자가 dict-style 접근 (`result.structured_content["result"][0]["page_content"]`) 했다면 그대로 동작 + +따라서 fastmcp Client 사용자는 **dict-style 접근을 `result.data` 에 직접 시도하지 않는 한** 영향 없음. v0.5.0 사용자가 `for chunk in result.data: chunk["page_content"]` 패턴을 쓰고 있었다면 v0.5.1 부터 `chunk.page_content` (attr) 또는 `chunk.model_dump()["page_content"]` 로 마이그 필요 — README 의 v0.5.1 마이그 노트 한 단락으로 안내. + +## 측정값 — outputSchema 강화 fact + +본 spec 의 본 PATCH 구현 시점에 fastmcp v3 + 본 프로젝트 v0.5.0 → v0.5.1 코드로 in-process 측정한 outputSchema 본문 길이 (`json.dumps(t.output_schema)` byte 수): + +| 도구 | v0.5.0 (`dict[str, Any]`) | v0.5.1 (typed) | 비고 | +|---|---|---|---| +| `get_ir` | 48 bytes (`{"additionalProperties": true, "type": "object"}`) | 약 32 KB (`HwpDocument` schema 전개 — `$defs` 약 20 종) | LLM 에 IR 구조 정확히 노출 | +| `iter_blocks` | 177 bytes (배열의 item 이 `additionalProperties: true` only) | 약 28 KB (`list[Block]` — `oneOf` 11 변형 + `$defs` 약 16 종) | 배열 item 의 `kind` discriminator 가 schema 에 노출 | +| `chunks` | 177 bytes | 약 1.4 KB (`ChunkRecord.page_content: str` + `metadata: dict[str, Any]` 만) | metadata 자유 dict 유지로 schema 비대 회피 | + +`HwpDocument` 만 단독으로 `model_json_schema()` 호출 시 약 35 KB / `$defs` 약 20 종 / `Block` 유니온이 `oneOf` 11 변형으로 펼쳐진다. fastmcp 의 outputSchema wrap (BaseModel = inline / `list[T]` = `{"result": ...}` envelope) 에서 약간 작아짐. 정확한 byte 수는 IR 모델 갱신 / Pydantic / fastmcp 버전에 따라 변동 — 회귀 가드 (AC-4) 가 *schema 가 known field 를 노출* 한다는 정성적 invariant 만 검증. + +> **schema 크기 vs LLM 친화성** — 21 KB schema 는 LLM 의 컨텍스트 / tools-list 에 들어가지만 한 번만 노출 (도구 목록 listing 시점). 이후 매 호출마다 schema 가 재전송되지 않으므로 호출 비용 (input token) 은 증가하지 않는다. 호출 결과 본문 (`structured_content`) 만 매 호출 비용에 잡힌다. ADR § 4 참조. + +## 테스트 전략 + +### 단위 테스트 (`tests/test_mcp_server.py` 확장) + +- `TestToolRegistry::test_get_ir_output_schema_includes_hwp_document_defs` — outputSchema 가 `$defs` 안에 `ParagraphBlock` / `TableBlock` 등 IR 모델을 포함하는지 (회귀 가드) +- `TestToolRegistry::test_iter_blocks_output_schema_is_list_of_block_oneof` — outputSchema 의 `properties.result.items.oneOf` 가 11 변형 포함 +- `TestToolRegistry::test_chunks_output_schema_includes_chunk_record_fields` — outputSchema 의 `properties.result.items.properties` 가 `page_content` + `metadata` 포함 +- `TestBackwardsCompat::test_get_ir_structured_content_matches_v050` — sample fixture 로 v0.5.0 의 `model_dump(mode="json")` 결과와 v0.5.1 fastmcp Client 의 `result.structured_content["result"]` 가 byte-equal +- `TestBackwardsCompat::test_iter_blocks_structured_content_matches_v050` — 동일 byte-equal 검증 +- `TestBackwardsCompat::test_chunks_structured_content_matches_v050` — 동일 + +### 통합 테스트 + +- 실제 샘플 `aift.hwp` / `table-vpos-01.hwpx` 로 3 도구 호출 → `result.data` 가 typed Pydantic 인스턴스인지 / 필드 access 가 정상인지 + +### CI + +- 추가 파일 / extras 변동 없음 — `test_mcp_server.py` 확장만. `test-without-extras` job 의 expected skip count 5 유지 + +## 결정 사항 + +| 항목 | 값 | 근거 | +|---|---|---| +| 1 — `get_ir` 출력 모델 | `HwpDocument` 직접 노출 | IR 모델이 v0.3.0 부터 Pydantic V2 + `frozen=True` 라 그대로 fastmcp 자동 schema 가능. `model_dump(mode="json")` 호출 제거로 메모리 1 회 절감. ADR § 1 | +| 2 — `iter_blocks` 출력 모델 | `list[Block]` 직접 노출 | `Block` 의 callable Discriminator + Tag 유니온이 fastmcp v3 자동 schema 생성과 호환 — `oneOf` 11 변형이 LLM 에 노출. 별도 wrapper 모델 불필요. ADR § 2 | +| 3 — `chunks` 출력 모델 | 신규 `ChunkRecord(BaseModel)` — `page_content: str` + `metadata: dict[str, Any]` | LangChain Document 의 직렬화 표면이 정확히 두 필드. `metadata` 는 mode × kind 조합으로 동적이라 `dict[str, Any]` 유지 (mode 별 분기 모델 거부). ADR § 3 | +| 4 — wire format 보존 정책 | `result.structured_content` byte-equal 회귀 가드 | PATCH 의 SemVer 의무. fastmcp 가 Pydantic 인스턴스를 `model_dump(mode="json")` 로 직렬화하므로 v0.5.0 의 수동 dump 와 결정적으로 동일. 측정 가능한 회귀 가드 (`TestBackwardsCompat`) 로 발견 | +| 5 — `metadata` 분기 거부 | mode 별 `ChunkRecord_Single` / `_Paragraph` / `_IrBlocks` 분기 모델 거부 | mode × block kind 조합 (3 × 11) 으로 schema 가 3-11 배 비대 + 새 metadata 키 추가 (예: 미래 Provenance 확장) 시 모델 갱신 강제. 본 PATCH 는 `metadata` 자유 dict + 키 집합 SSOT 는 `HwpLoader` docstring. ADR § 3 | +| 6 — fastmcp `output_schema=` 수동 오버라이드 미사용 | 자동 schema 생성에 위임 | fastmcp v3.2.4 docs § Use Typed Models for Structured Output 가 자동 생성을 1st-tier 패턴으로 명시. 수동 오버라이드는 schema drift 위험 + 추가 유지. ADR § 4 | +| 7 — 호출 시그니처 보존 | `path: str` / `kind: BlockKind \| None` / `scope: BlockScope` / `limit: int \| None` / `mode: ChunksMode` / 기타 — 모두 v0.5.0 그대로 | PATCH 의 SemVer 의무. 출력 타입만 강화, 입력 schema 무변동 | +| 8 — `UnknownBlock.kind` JSON Schema constraint 강화 | `Field(json_schema_extra=...)` 로 `not.enum: sorted(_KNOWN_KINDS)` 노출. 런타임 동작 변경 0 (callable Discriminator 가 이미 분기) | fastmcp v3 + jsonschema 의 strict `oneOf` validation 이 ParagraphBlock 과 UnknownBlock schema 양쪽 valid 인 인스턴스 (예: 빈 ParagraphBlock) 에서 fail. callable Discriminator + Tag 유니온의 자동 schema 가 `oneOf` 11 변형으로 펼쳐지는데, UnknownBlock 의 `kind: str` 가 known kinds 도 매칭 → exactly-one 위반 → client side wire format 호환 깨짐. not.enum 추가로 schema export 만 strict 화. ADR § 5 | + +## 인수조건 + +- **AC-1** — `get_ir(path)` 의 반환 타입 어노테이션이 `HwpDocument` (정적 타입) — `inspect.signature(rhwp.mcp.tools.get_ir).return_annotation is HwpDocument` +- **AC-2** — `iter_blocks(path, ...)` 의 반환 타입 어노테이션이 `list[Block]` (정적 타입) — `typing.get_type_hints(...)["return"]` 가 `list[Block]` +- **AC-3** — `chunks(path, ...)` 의 반환 타입 어노테이션이 `list[ChunkRecord]` (정적 타입) + `ChunkRecord` 가 `rhwp.mcp.tools` 에 노출 (test 가 import 가능) +- **AC-4** — fastmcp `Tool.output_schema["properties"]["result"]["items"]["$ref"]` 가 `get_ir` 는 `#/$defs/HwpDocument`, `iter_blocks` 는 `#/$defs/Block` (또는 `oneOf` 11 변형 inline), `chunks` 는 `ChunkRecord` 의 `properties` 인라인 — schema 가 v0.5.0 의 `additionalProperties: true` 약타입에서 강타입으로 전환됨을 in-process 검증 (회귀 가드) +- **AC-5** — `aift.hwp` 픽스처로 fastmcp Client in-process 호출 시 `result.structured_content` 가 v0.5.0 동등 함수의 `model_dump(mode="json")` 결과와 byte-equal — `get_ir` 는 wrap 없이 직접 비교, `iter_blocks` / `chunks` 는 `{"result": [...]}` wrap 안 비교 (§ wire format byte-equal 의 wrap 분기 참조) +- **AC-6** — fastmcp Client 의 `result.data` access 가 v0.5.1 부터 typed-or-dict (server side 출력에 따라): + - `get_ir`: `result.data` 가 ``HwpDocument`` 의 fields 를 expose 하는 dynamic Pydantic 객체 — `data.schema_name == "HwpDocument"` 등 attribute access + - `iter_blocks`: `result.data` 가 `list[dict]` (callable Discriminator + Tag 유니온의 `oneOf` schema 를 fastmcp v3 가 dynamic 모델로 reconstruct 하지 않는 한계 — 1차 소스: fastmcp v3.2.4 docs § Client Result Deserialization 의 supported types). 각 dict 의 `"kind"` key 노출 — v0.5.0 dict access 패턴 그대로 동작 (backwards-compat). 진짜 typed access 가 필요한 사용자는 server side 가 직접 반환하는 typed list (sync handler 호출 결과) 를 사용 + - `chunks`: `result.data` 가 `list[Pydantic-like]` — 각 element 가 `page_content` / `metadata` attribute access 가능 (단순 BaseModel 이라 fastmcp 가 dynamic 모델 reconstruct 가능) +- **AC-7** — `ChunkRecord` 의 `metadata` 필드 타입이 `dict[str, Any]` (mode 별 분기 모델 거부 결정 5 의 grep-friendly evidence) — `ChunkRecord.model_fields["metadata"].annotation == dict[str, Any]` +- **AC-8** — 도구 등록 변동 없음 — `len(server.list_tools()) == 7` (v0.5.0 AC-2 회귀 보존) +- **AC-9** — `tests/test_mcp_server.py` 의 extras-gated import (`pytest.importorskip("fastmcp")`) 위치 / `test-without-extras` skip count 5 / `pyproject.toml` extras 모두 v0.5.0 그대로 — PATCH 가 의존성 / extras 표면을 변경 안 함 +- **AC-10** — README MCP 도구 표 (v0.5.0 S5 신설) 의 출력 컬럼이 v0.5.1 의 새 타입 (`HwpDocument` / `list[Block]` / `list[ChunkRecord]`) 으로 갱신, README 본문에 한 단락 마이그 노트 (`result.data` 가 dict 에서 typed instance 로 바뀌었다 — `result.structured_content` 는 동일) 추가 + +## 미확정 이슈 + +- **`HwpDocument.schema_version` 필드의 frontmatter-스러운 forward-compat 처리** — `field_validator` 가 major 상향 시 `UserWarning` 발생. fastmcp 호출 시 warning 이 어떻게 클라이언트에 전달되는가? Server stderr 에만 남는지, MCP 응답에 포함되는지 — 본 PATCH 범위는 출력 schema 만 다룸, warning 흐름은 fastmcp 의 책임. 손 검증 필요 시 별도 issue +- **`HwpDocument` 본문이 큰 경우 (수 MB IR JSON) MCP 응답 한도 초과** — v0.5.0 §미확정 이슈 그대로. 본 PATCH 는 출력 schema 만 — payload 자체는 v0.5.0 과 동일 byte 수 +- **JSON Schema strict mode 호환성** — fastmcp 의 자동 schema 가 글로벌 [CLAUDE.md](../../../CLAUDE.md) § Pydantic V2 의 strict mode 룰 (`ge=` / `le=` 금지 등) 과 자동 정합. `IR` 모델은 `ge=` / `le=` 미사용 (검증 완료) — strict 호환은 보존된다고 가정. 미래 다른 사용처 (Anthropic Tool Use 의 strict tool calling) 에서 검증 + +## 다른 산출물의 파급 (코드 / 데이터) + +- `python/rhwp/mcp/tools.py` — 3 함수 시그니처 변경 (`-> HwpDocument` / `-> list[Block]` / `-> list[ChunkRecord]`), `model_dump(mode="json")` / list comprehension 의 dict 펼침 제거. `ChunkRecord` BaseModel 신규 정의 +- `python/rhwp/ir/nodes.py` — `UnknownBlock.kind` 의 `Field(json_schema_extra=callable)` 로 `not.enum: sorted(_KNOWN_KINDS)` JSON Schema export. 런타임 동작 변경 0. 결정 8 의 fastmcp strict `oneOf` 호환을 위한 최소 변경 +- `python/rhwp/ir/schema/hwp_ir_v1.json` — UnknownBlock.kind 의 `not.enum` 반영하여 packaged JSON Schema 재생성 (`uv run python -m rhwp.ir.schema > python/rhwp/ir/schema/hwp_ir_v1.json`). 본 PATCH 의 자동 산출물 +- `tests/test_mcp_server.py` — `TestTypedSignatures` (AC-1~AC-3, AC-7), `TestTypedOutputSchema` (AC-4), `TestBackwardsCompat` (AC-5), `TestTypedClientData` (AC-6), `TestTypedModelRoundTrip` (Pydantic 결정성). 기존 `TestGetIr` / `TestIterBlocks` / `TestChunks` 의 dict access 검증을 typed model 검증으로 전환 +- `README.md` § MCP server (`rhwp-mcp`) — 도구 표의 출력 컬럼 갱신 + 한 단락 마이그 노트 (`result.data` 가 typed 로 바뀐 점) +- `CHANGELOG.md` — `[0.5.1]` 섹션 신설. *what* 측면에서 "MCP 도구 3 종 (`get_ir` / `iter_blocks` / `chunks`) 의 반환 타입을 Pydantic 모델로 강화. wire format 은 byte-equal 유지" 한 줄 +- `pyproject.toml` — `Cargo.toml` version bump 0.5.0 → 0.5.1 만 동반 (extras / scripts 변동 없음) +- `external/rhwp/` 서브모듈 — 변경 없음. v0.5.0 동일 commit pin 유지 + +문서 cross-link (`docs/roadmap/README.md` 인덱스) 는 [CONVENTIONS.md](../../CONVENTIONS.md) § Cross-link 방향성 규칙 에 따라 본 spec 본문에서 다루지 않음 — 인덱스는 `roadmap/README.md` (Living) 가 SSOT. + +## 참조 + +- 짝 페어 (ADR): [mcp-typed-output-research.md](../../design/v0.5.1/mcp-typed-output-research.md) +- v0.5.0 MCP server (선행 spec): 활성 spec 인덱스 [roadmap/README.md](../README.md) +- fastmcp v3 출력 schema 자동 생성 (1차 소스): +- fastmcp Client `result.data` / `result.structured_content` (1차 소스): +- IR `HwpDocument` / `Block` SSOT: `python/rhwp/ir/nodes.py` +- LangChain `HwpLoader` metadata 키 SSOT: `python/rhwp/integrations/langchain.py` +- Pydantic V2 strict mode 호환 룰 (글로벌): [CLAUDE.md](../../../CLAUDE.md) § Type Hints & Pydantic diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index 3203ab0..4f74e84 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -534,6 +534,9 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.4.0/view-renderer | AC-9 | `tests/test_view_html.py::test_to_html_default_has_zero_style_tags` | | v0.4.0/view-renderer | AC-9 | `tests/test_view_html.py::test_to_html_include_css_real_fixture_well_formed` | | v0.4.0/view-renderer | AC-9 | `tests/test_view_html.py::test_to_html_include_css_true_has_exactly_one_style_in_head` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestBackwardsCompat::test_chunks_structured_content_matches_v050_dump` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestBackwardsCompat::test_get_ir_structured_content_matches_v050_dump` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestBackwardsCompat::test_iter_blocks_structured_content_matches_v050_dump` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestChunks::test_default_paragraph_mode` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestChunks::test_include_furniture_appends_furniture_chunks` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestChunks::test_include_furniture_ignored_outside_ir_blocks` | @@ -545,8 +548,8 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestErrorHandling::test_iter_blocks_invalid_kind` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestErrorHandling::test_unknown_tool_name` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestExtractText::test_returns_string` | -| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestGetIr::test_returns_dict_with_schema_envelope` | -| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestIterBlocks::test_default_returns_dicts` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestGetIr::test_returns_typed_hwp_document` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestIterBlocks::test_default_returns_typed_blocks` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestIterBlocks::test_kind_filter_paragraph` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestIterBlocks::test_limit_truncates` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestIterBlocks::test_scope_furniture_subset` | @@ -574,6 +577,19 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTransportCli::test_run_stdio_with_non_default_host_exits` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTransportCli::test_run_stdio_with_non_default_port_exits` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTransportCli::test_streamable_http_real_round_trip` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedClientData::test_chunks_client_data_is_typed_list` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedClientData::test_get_ir_client_data_has_typed_attributes` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedClientData::test_iter_blocks_client_data_is_typed_list` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedModelRoundTrip::test_chunk_record_round_trip` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedModelRoundTrip::test_get_ir_round_trip` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedOutputSchema::test_chunks_schema_exposes_chunk_record_fields` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedOutputSchema::test_get_ir_schema_exposes_hwp_document_defs` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedOutputSchema::test_iter_blocks_schema_exposes_block_union_variants` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_chunk_record_is_exposed_on_tools_module` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_chunk_record_metadata_annotation_is_free_dict` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_chunks_return_annotation_is_list_of_chunk_record` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_get_ir_return_annotation_is_hwp_document` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_iter_blocks_return_annotation_is_list_of_block` | | v0.5.0/mcp | AC-10 | `tests/test_mcp_server.py::TestPackagingSurface::test_module_is_top_level_not_under_integrations` | | v0.5.0/mcp | AC-2 | `tests/test_mcp_server.py::TestToolRegistry::test_lists_exactly_seven_tools` | | v0.5.0/mcp | AC-3 | `tests/test_mcp_server.py::TestErrorHandling::test_iter_blocks_invalid_kind` | @@ -596,3 +612,19 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.5.0/mcp | AC-8 | `tests/test_mcp_server.py::TestTransportCli::test_streamable_http_real_round_trip` | | v0.5.0/mcp | AC-9 | `tests/test_mcp_server.py::TestPackagingSurface::test_entry_point_dispatches_to_run` | | v0.5.0/mcp | AC-9 | `tests/test_mcp_server.py::TestPackagingSurface::test_pyproject_declares_fastmcp_extras_and_script` | +| v0.5.1/mcp-typed-output | AC-1 | `tests/test_mcp_server.py::TestTypedModelRoundTrip::test_get_ir_round_trip` | +| v0.5.1/mcp-typed-output | AC-1 | `tests/test_mcp_server.py::TestTypedSignatures::test_get_ir_return_annotation_is_hwp_document` | +| v0.5.1/mcp-typed-output | AC-2 | `tests/test_mcp_server.py::TestTypedSignatures::test_iter_blocks_return_annotation_is_list_of_block` | +| v0.5.1/mcp-typed-output | AC-3 | `tests/test_mcp_server.py::TestTypedModelRoundTrip::test_chunk_record_round_trip` | +| v0.5.1/mcp-typed-output | AC-3 | `tests/test_mcp_server.py::TestTypedSignatures::test_chunk_record_is_exposed_on_tools_module` | +| v0.5.1/mcp-typed-output | AC-3 | `tests/test_mcp_server.py::TestTypedSignatures::test_chunks_return_annotation_is_list_of_chunk_record` | +| v0.5.1/mcp-typed-output | AC-4 | `tests/test_mcp_server.py::TestTypedOutputSchema::test_chunks_schema_exposes_chunk_record_fields` | +| v0.5.1/mcp-typed-output | AC-4 | `tests/test_mcp_server.py::TestTypedOutputSchema::test_get_ir_schema_exposes_hwp_document_defs` | +| v0.5.1/mcp-typed-output | AC-4 | `tests/test_mcp_server.py::TestTypedOutputSchema::test_iter_blocks_schema_exposes_block_union_variants` | +| v0.5.1/mcp-typed-output | AC-5 | `tests/test_mcp_server.py::TestBackwardsCompat::test_chunks_structured_content_matches_v050_dump` | +| v0.5.1/mcp-typed-output | AC-5 | `tests/test_mcp_server.py::TestBackwardsCompat::test_get_ir_structured_content_matches_v050_dump` | +| v0.5.1/mcp-typed-output | AC-5 | `tests/test_mcp_server.py::TestBackwardsCompat::test_iter_blocks_structured_content_matches_v050_dump` | +| v0.5.1/mcp-typed-output | AC-6 | `tests/test_mcp_server.py::TestTypedClientData::test_chunks_client_data_is_typed_list` | +| v0.5.1/mcp-typed-output | AC-6 | `tests/test_mcp_server.py::TestTypedClientData::test_get_ir_client_data_has_typed_attributes` | +| v0.5.1/mcp-typed-output | AC-6 | `tests/test_mcp_server.py::TestTypedClientData::test_iter_blocks_client_data_is_typed_list` | +| v0.5.1/mcp-typed-output | AC-7 | `tests/test_mcp_server.py::TestTypedSignatures::test_chunk_record_metadata_annotation_is_free_dict` | From 0272e0bac89823f09466f8282268fd07be6483b5 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 7 May 2026 13:58:51 +0900 Subject: [PATCH 5/6] chore: v0.5.1 release marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml version bump 0.5.0 → 0.5.1 (pyproject.toml 의 dynamic = ["version"] 가 본 SSOT 를 읽음 — publish.yml::verify-version 이 git tag 와 일치 가드). CHANGELOG.md [0.5.1] — 2026-05-07 섹션 신설: - Added: ChunkRecord BaseModel (page_content + metadata: dict[str, Any]) - Changed: get_ir / iter_blocks / chunks 출력 강타입화, README 도구 표 + 마이그 - Fixed: UnknownBlock.kind 의 JSON Schema not.enum constraint (fastmcp strict oneOf 호환) - Build: submodule pin 62a458a (v0.7.10) 유지, extras / 의존성 변경 없음 - Notes: spec / ADR / migration 위치, fastmcp v3 한계 2 가지 언급 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c967552..87ae0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 부수 — `test_submodule_pin_matches_changelog_record` 제거 ([tests/test_ir_marker_char_offset.py](tests/test_ir_marker_char_offset.py)). 본래 v0.3.1 의 *deliberate* pin bump (v0.7.7 → 0fb3e67) 가 release-readiness 시점 기재됐는지 가드한 일회성 AC-13 의 일부였으나, GA shipped 후에도 영구 runtime 가드로 잔존해 일상 sync 마다 CHANGELOG 갱신을 강제하는 anti-pattern (1회성 release-gating AC 의 영구 테스트화 + 문서 텍스트 매칭 runtime test) 화. CHANGELOG 갱신은 릴리즈 시점 의무 (`publish.yml::verify-version` 로 version 일치 가드, 사람 review 가 핀 bump 정합성 점검) 로 이양. AC-13 의 historical record 검증은 동 파일 `test_changelog_records_pin_bump` (Frozen v0.3.1 섹션 텍스트 회귀 가드) 가 그대로 유지. - 부수 — 동 파일 이름 `test_v0_3_1_marker_char_offset.py` → `test_ir_marker_char_offset.py` (`test_ir_*` 패턴 통일, 본 spec lifecycle 은 `pytest.mark.spec("v0.3.1/...")` marker 가 보유). +## [0.5.1] — 2026-05-07 + +PATCH release. v0.5.0 GA 한 `rhwp-mcp` 의 7 도구 중 약타입 (`dict[str, Any]` / `list[dict[str, Any]]`) 으로 반환하던 3 도구 (`get_ir` / `iter_blocks` / `chunks`) 의 출력 시그니처를 Pydantic V2 모델 (`HwpDocument` / `list[Block]` / `list[ChunkRecord]`) 로 강타입화. fastmcp v3 의 자동 outputSchema 가 약타입 (`additionalProperties: true` 만) → 강타입 (필드별 type / Discriminator + Tag 11 변형 / required 명시) 으로 강화 — LLM 의 응답 해석 / 후속 도구 호출 정확도 향상. wire format byte-equal — 외부 클라이언트 (Claude Desktop / Cline 등) 의 LLM 프롬프트 / 후처리 코드 영향 0. 코어 wheel 의존성 / extras / schema (`"1.1"`) 변경 없음. + +### Added + +- `rhwp.mcp.tools.ChunkRecord` BaseModel 신규 — `page_content: str` + `metadata: dict[str, Any]`. `model_config = ConfigDict(extra="forbid", frozen=True)`. RAG 청크의 직렬화 표면 — LangChain `Document` 의 `page_content` / `metadata` 평탄화. mode × block kind 동적 metadata 키 집합은 `rhwp.integrations.langchain.HwpLoader` 가 SSOT — 분기 모델 (`ChunkRecord_Single` 등) 거부 결정으로 `metadata` 자유 dict 유지 (schema 비대 + forward-compat 깨짐 회피). + +### Changed + +- `rhwp.mcp.tools.get_ir(path)` 반환 타입 — `dict[str, Any]` → `HwpDocument`. fastmcp 가 자동으로 `model_dump(mode="json")` 직렬화 → `result.structured_content` 가 v0.5.0 dict 출력과 byte-equal. `result.data` 는 typed BaseModel 인스턴스 (v0.5.1 신규 표면) — discriminated union block 들의 강타입 access 가능. +- `rhwp.mcp.tools.iter_blocks(path, ...)` 반환 타입 — `list[dict[str, Any]]` → `list[Block]`. callable Discriminator + Tag 11 변형 (paragraph / table / picture / formula / footnote / endnote / list_item / caption / toc / field / unknown) 그대로 노출 — outputSchema 의 `oneOf` 11 변형이 LLM 에 정확한 `kind` 별 필드 구조 노출. +- `rhwp.mcp.tools.chunks(path, ...)` 반환 타입 — `list[dict[str, Any]]` → `list[ChunkRecord]`. dict 평탄화 코드 제거 — fastmcp 자동 직렬화에 위임. +- README MCP 도구 표 출력 컬럼 갱신 + v0.5.1 마이그 노트 한 단락 추가 — `result.data` 의 dict 인덱싱 → typed attribute access 마이그 (단 `iter_blocks` list element 는 fastmcp v3 의 `oneOf` deserialization 한계로 dict 폴백 — v0.5.0 access 패턴 그대로 동작). + +### Fixed + +- `python/rhwp/ir/nodes.py` 의 `UnknownBlock.kind` 의 JSON Schema 에 `not.enum: sorted(_KNOWN_KINDS)` constraint 추가 (`Field(json_schema_extra=callable)` 표준 hook). `fastmcp` v3 + `jsonschema` 의 strict `oneOf` validation 이 ParagraphBlock 과 UnknownBlock 양쪽 valid 인스턴스 (예: 빈 ParagraphBlock) 에서 `RuntimeError: Invalid structured content` 로 fail 하던 client side wire format 호환 깨짐 해결. callable Discriminator (`_block_discriminator`) 의 런타임 동작은 SSOT 그대로 — schema export 만 strict 화. packaged `python/rhwp/ir/schema/hwp_ir_v1.json` 자동 재생성. v0.2.0 Frozen IR 의 forward-compat 라우팅 (미지 kind → `UnknownBlock`) 보존 — `tests/test_ir_schema_export.py::test_unknown_kind_routing_pydantic_matches_schema` 가 회귀 가드. + +### Build + +- `external/rhwp` submodule pin 변경 없음 — v0.5.0 동일 (`62a458a`, v0.7.10). 본 PATCH 는 pure Python schema 강화, 상류 변경 0. +- 신규 의존성 / extras 변경 없음. CI `test-without-extras` job 의 expected skip count 5 그대로 (`tests/test_mcp_server.py` 의 file-level `pytest.importorskip("fastmcp")` 보존). + +### Notes + +- spec / ADR / 구현 로그: [docs/roadmap/v0.5.1/mcp-typed-output.md](docs/roadmap/v0.5.1/mcp-typed-output.md) (Frozen, 10 인수조건, 8 결정) / [docs/design/v0.5.1/mcp-typed-output-research.md](docs/design/v0.5.1/mcp-typed-output-research.md) (Frozen, 5 결정 매트릭스) / [docs/implementation/v0.5.1/migration.md](docs/implementation/v0.5.1/migration.md) (Frozen). +- v0.5.1 작업 중 표면화된 fastmcp v3 의 두 한계는 spec body 에 정확히 반영: (1) `result.structured_content` 의 wrap 분기 (BaseModel = inline / `list[T]` = `{"result": ...}` envelope), (2) callable Discriminator + Tag 유니온의 `oneOf` schema 가 dynamic 모델 reconstruct 안 됨 (list element dict 폴백). server side 의 typed 출력은 AC-2 / AC-3 가, wire format byte-equal 은 AC-5 가 cover. + ## [0.5.0] — 2026-05-06 MINOR release. [Model Context Protocol](https://modelcontextprotocol.io/) (Anthropic, 2024) 서버를 새 entry point `rhwp-mcp` 로 노출한다. LLM 에이전트 (Claude Desktop / Cursor / Cline / Continue.dev / Goose / 자체 에이전트) 가 HWP/HWPX 를 직접 파싱·요약·청크화 가능. standalone [`fastmcp`](https://github.com/jlowin/fastmcp) v3 (jlowin) 기반 — 2026-05 기준 MCP 서버 약 70% 시장 점유의 사실상 표준. 7 도구 / 2 transport (stdio 기본 + streamable-http 옵션) / runtime extras gate / `unsendable` 안전 패턴 강제. 코어 wheel 의존성 변경 0 (additive extras), schema (`"1.1"`) 유지. diff --git a/Cargo.toml b/Cargo.toml index 3e3f6cc..e2e7915 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rhwp-python" -version = "0.5.0" +version = "0.5.1" edition = "2021" # ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수. # PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내 From d3a39f5183ad766802ae70a2c995474a15ab2c7c Mon Sep 17 00:00:00 2001 From: DanMeon Date: Thu, 7 May 2026 14:00:51 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20v0.5.1=20GA=20=E2=80=94=20Frozen=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20+=20roadmap=20=EC=9D=B8=EB=8D=B1=EC=8A=A4?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frontmatter flip: - roadmap/v0.5.1/mcp-typed-output.md: status: Draft → Frozen, target → ga - design/v0.5.1/mcp-typed-output-research.md: 동일 - implementation/v0.5.1/migration.md: target → ga (status 는 이미 Frozen — pre-GA stage 면제 그대로 적용) roadmap/README.md: - 현재 상태 v0.5.1 row: Draft → Frozen GA (2026-05-07) - 활성 spec 인덱스 row: Draft → Frozen - v0.6.0+ narrative + quoted note 의 v0.5.1 표기: Draft → Frozen - 구현/검증 로그 표에 v0.5.1 → implementation/v0.5.1/migration.md row 추가 Living-policy schema migration — CONVENTIONS § Frozen 면제 조항 정합 (GA 시점 frontmatter flip 은 허용된 in-place 갱신). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/v0.5.1/mcp-typed-output-research.md | 4 ++-- docs/implementation/v0.5.1/migration.md | 2 +- docs/roadmap/README.md | 9 +++++---- docs/roadmap/v0.5.1/mcp-typed-output.md | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/design/v0.5.1/mcp-typed-output-research.md b/docs/design/v0.5.1/mcp-typed-output-research.md index 0a0484f..c1e107f 100644 --- a/docs/design/v0.5.1/mcp-typed-output-research.md +++ b/docs/design/v0.5.1/mcp-typed-output-research.md @@ -1,7 +1,7 @@ --- -status: Draft +status: Frozen description: "v0.5.1 MCP 출력 강타입화 ADR — 'HwpDocument' / 'list[Block]' / 'ChunkRecord' 채택 / 'metadata' 자유 dict 유지 / fastmcp 자동 schema 위임 결정 근거" -target: v0.5.1 +ga: v0.5.1 last_updated: 2026-05-07 --- diff --git a/docs/implementation/v0.5.1/migration.md b/docs/implementation/v0.5.1/migration.md index c97491b..324e429 100644 --- a/docs/implementation/v0.5.1/migration.md +++ b/docs/implementation/v0.5.1/migration.md @@ -1,7 +1,7 @@ --- status: Frozen description: "v0.5.1 구현 로그 — MCP tool 출력 schema 강타입화 (`get_ir` / `iter_blocks` / `chunks`). wire format byte-equal. `UnknownBlock.kind` JSON Schema not.enum 추가로 fastmcp strict oneOf 호환" -target: v0.5.1 +ga: v0.5.1 last_updated: 2026-05-07 --- diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 1d4fb4d..62be584 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -13,7 +13,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe - **v0.3.2** — Frozen, UTF-16 → codepoint 변환 SSOT 단일화 GA (2026-05-03) - **v0.4.0** — Frozen, IR view 렌더러 (Markdown / HTML) GA (2026-05-05) - **v0.5.0** — Frozen, MCP server (`rhwp-mcp`) GA (2026-05-06) -- **v0.5.1** — Draft, MCP tool 출력 schema 강타입화 — [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) +- **v0.5.1** — Frozen, MCP tool 출력 schema 강타입화 GA (2026-05-07) - **v0.6.0+** — 미착수 (주제 미정, demand-driven) ## 활성 spec 인덱스 @@ -30,7 +30,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.3.2 (IR upstream UTF-16 helper) | Frozen | [v0.3.2/ir-upstream-utf16-helper.md](v0.3.2/ir-upstream-utf16-helper.md) | [design/v0.3.2/ir-upstream-utf16-helper-research.md](../design/v0.3.2/ir-upstream-utf16-helper-research.md) | | v0.4.0 (view 렌더러) | Frozen | [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md) | [design/v0.4.0/view-renderer-research.md](../design/v0.4.0/view-renderer-research.md) | | v0.5.0 (MCP server) | Frozen | [v0.5.0/mcp.md](v0.5.0/mcp.md) | [design/v0.5.0/mcp-research.md](../design/v0.5.0/mcp-research.md) | -| v0.5.1 (MCP typed output) | Draft | [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) | [design/v0.5.1/mcp-typed-output-research.md](../design/v0.5.1/mcp-typed-output-research.md) | +| v0.5.1 (MCP typed output) | Frozen | [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) | [design/v0.5.1/mcp-typed-output-research.md](../design/v0.5.1/mcp-typed-output-research.md) | ## 미착수 작업 계획 @@ -38,9 +38,9 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe ### v0.6.0+ — 미정 (demand-driven) -v0.5.0 MCP server (Frozen, [v0.5.0/mcp.md](v0.5.0/mcp.md)) + v0.5.1 후속 polish (Draft, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)) 이후 다음 minor 들. 주제 미정 — v0.3.0 LangChain integration + v0.5.0 MCP 가 RAG / LLM 에이전트 사용처 분모를 이미 커버하는 상황에서 추가 RAG 프레임워크 통합은 **demand-driven 으로 보류** (HWP × 비-LangChain RAG 교집합이 좁을 가능성). 구체화되면 `/new-spec ` 으로 promote. +v0.5.0 MCP server (Frozen, [v0.5.0/mcp.md](v0.5.0/mcp.md)) + v0.5.1 후속 polish (Frozen, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)) 이후 다음 minor 들. 주제 미정 — v0.3.0 LangChain integration + v0.5.0 MCP 가 RAG / LLM 에이전트 사용처 분모를 이미 커버하는 상황에서 추가 RAG 프레임워크 통합은 **demand-driven 으로 보류** (HWP × 비-LangChain RAG 교집합이 좁을 가능성). 구체화되면 `/new-spec ` 으로 promote. -> v0.4.0 view 렌더러 (Markdown / HTML) / v0.5.0 MCP server (`rhwp-mcp`) 모두 GA 완료 — [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md), [v0.5.0/mcp.md](v0.5.0/mcp.md) 가 SSOT. v0.5.1 은 v0.5.0 의 출력 schema 강화 PATCH (Draft, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)). +> v0.4.0 view 렌더러 (Markdown / HTML) / v0.5.0 MCP server (`rhwp-mcp`) / v0.5.1 MCP 출력 강타입화 모두 GA 완료 — [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md), [v0.5.0/mcp.md](v0.5.0/mcp.md), [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) 가 SSOT. ### v0.8.0 ~ v1.0.0 — JSON IR → HWP 역생성 @@ -89,6 +89,7 @@ SemVer 0.x.y 단계에서 minor 는 단조 증가 — v0.9 다음은 v0.10 (v1.0 | v0.3.1 | [implementation/v0.3.1/migration.md](../implementation/v0.3.1/migration.md) | — | | v0.4.0 | [implementation/v0.4.0/migration.md](../implementation/v0.4.0/migration.md) | — | | v0.5.0 | [implementation/v0.5.0/stages/](../implementation/v0.5.0/stages/) (S1~S5) | — | +| v0.5.1 | [implementation/v0.5.1/migration.md](../implementation/v0.5.1/migration.md) | — | ## 원칙 diff --git a/docs/roadmap/v0.5.1/mcp-typed-output.md b/docs/roadmap/v0.5.1/mcp-typed-output.md index d08c53b..e980040 100644 --- a/docs/roadmap/v0.5.1/mcp-typed-output.md +++ b/docs/roadmap/v0.5.1/mcp-typed-output.md @@ -1,7 +1,7 @@ --- -status: Draft +status: Frozen description: "v0.5.1 — 'rhwp-mcp' 도구 출력 schema 강타입화. 'get_ir' / 'iter_blocks' / 'chunks' 의 'dict[str, Any]' 반환을 Pydantic 모델로 교체. 'UnknownBlock.kind' JSON Schema not.enum 추가로 fastmcp strict 'oneOf' 호환" -target: v0.5.1 +ga: v0.5.1 last_updated: 2026-05-07 ---