diff --git a/mydocs/manual/mcp_integration_guide.md b/mydocs/manual/mcp_integration_guide.md index 319728ca44..4dd667c561 100644 --- a/mydocs/manual/mcp_integration_guide.md +++ b/mydocs/manual/mcp_integration_guide.md @@ -44,7 +44,7 @@ VS Code·Zed·Goose·Gemini CLI 등 18개 호스트별 설정은 | `initialize` | `protocolVersion`(클라이언트 제안 에코), `capabilities.tools`, `serverInfo{name:"rhwp",version}` | | `notifications/initialized` | (알림 — 무응답) | | `ping` | `{}` | -| `tools/list` | 선언 도구 전부 + 세션 도구 3종. 각 항목은 MCP 필수 3종(`name`/`description`/`inputSchema`) | +| `tools/list` | 선언 도구 전부 + 세션 도구. 각 항목은 MCP 필수 3종(`name`/`description`/`inputSchema`) | | `tools/call` | `content[0].text` 에 CLI 와 동일한 JSON 봉투. JSON 이면 `structuredContent` 로도 병행 제공 | 지원하지 않는 메서드는 JSON-RPC `-32601`, 파싱 불가 입력은 `-32700`, @@ -135,8 +135,9 @@ rhwp capabilities --mcp | jq -c '.tools[] | {name, cli: .cli.args, required: .in | 문서 규모·형식 파악 | `hwp_info` | | 본문 읽기 (1회) | `hwp_export_text` / (반복) `hwp_open`→`hwp_doc_text` | | "어느 쪽에 있나" | `hwp_search` (페이지·셀 주소 동봉) | -| 조문·개요 구조 | `hwp_export_structure` | -| 표 격자(병합 보존) | `hwp_export_tables` | +| 조문·개요 구조 | (1회) `hwp_export_structure` / (반복) `hwp_open`→`hwp_doc_structure` | +| 날짜·금액·수량 추출 | (1회) `hwp_extract_data` / (반복) `hwp_open`→`hwp_doc_extract_data` | +| 표 격자(병합 보존) | (1회) `hwp_export_tables` / (반복) `hwp_open`→`hwp_doc_tables` | | 누름틀 조사 → 채우기 | `hwp_fields` → `hwp_fill_fields` | | 표 좌표로 값 쓰기 | `hwp_set_cell` | | 문구 일괄 치환 | `hwp_replace_text` | diff --git a/src/agent_profiles.rs b/src/agent_profiles.rs index a2cff592ed..f5bc950bef 100644 --- a/src/agent_profiles.rs +++ b/src/agent_profiles.rs @@ -18,6 +18,10 @@ pub const ALL_SESSION_TOOLS: &[&str] = &[ "hwp_doc_tables", "hwp_doc_render_page", "hwp_doc_search", + // [#4856] 세션 조회 파리티 — 무상태 표면(export-structure·extract-data)에는 있으나 + // 세션에는 없던 개요/조문·데이터 추출 축. 대형 문서를 한 번 열어 재파싱 없이 훑는다. + "hwp_doc_structure", + "hwp_doc_extract_data", "hwp_doc_replace_text", "hwp_doc_set_cell", "hwp_doc_fill_fields", @@ -42,6 +46,10 @@ pub const SESSION_READ_TOOLS: &[&str] = &[ "hwp_doc_fields", "hwp_doc_tables", "hwp_doc_search", + // [#4856] 조회 파리티 축 — 둘 다 문서를 바꾸지 않는 순수 조회다(structure=개요/조문, + // extract_data=날짜·금액·수량). 조회 전용 직무가 세션으로 여는 집합에 함께 든다. + "hwp_doc_structure", + "hwp_doc_extract_data", "hwp_doc_render_page", "hwp_close", // [#4357 W1] 워크스페이스 4종은 전부 조회 축 — 저널·인벤토리·트리는 읽기, diff --git a/src/mcp_serve.rs b/src/mcp_serve.rs index 36e9d9a555..babd2a89e0 100644 --- a/src/mcp_serve.rs +++ b/src/mcp_serve.rs @@ -649,6 +649,8 @@ fn stats_tool_name<'a>( "hwp_doc_tables" => Some("hwp_doc_tables"), "hwp_doc_render_page" => Some("hwp_doc_render_page"), "hwp_doc_search" => Some("hwp_doc_search"), + "hwp_doc_structure" => Some("hwp_doc_structure"), + "hwp_doc_extract_data" => Some("hwp_doc_extract_data"), "hwp_doc_replace_text" => Some("hwp_doc_replace_text"), "hwp_doc_set_cell" => Some("hwp_doc_set_cell"), "hwp_doc_fill_fields" => Some("hwp_doc_fill_fields"), @@ -1135,17 +1137,17 @@ fn served_tools( })); session.push(serde_json::json!({ "name": "hwp_doc_info", - "description": "[#3609] 핸들의 메타(형식·페이지/문단 수·폰트)를 재파싱 없이 조회한다. 편집 후 페이지 수 변화를 추적할 때 쓴다. 봉투는 hwp_info 와 동형.", + "description": "[#3609] 핸들의 메타(형식·구역/페이지/문단 수·폰트·제목)를 재파싱 없이 조회한다. 언제 — 편집(fill/replace/set_cell) 후 pageCount 변화를 추적하거나 규모를 재확인할 때. 봉투는 무상태 hwp_info 와 동형: {format, pageCount, paraCount, fonts, title, warnings}. 오류 — 핸들이 없거나 만료면 isError:true 와 nextCall(hwp_open).", "inputSchema": { "type": "object", "properties": { "docId": { "type": "string" } }, "required": ["docId"] } })); session.push(serde_json::json!({ "name": "hwp_doc_fields", - "description": "[#3609] 핸들의 누름틀을 재파싱 없이 조사한다. hwp_doc_fill_fields 직후 반영값 확인에 쓴다. 봉투는 hwp_fields 와 동형.", + "description": "[#3609] 핸들의 누름틀·필드를 이름·안내문·현재값·위치와 함께 재파싱 없이 조사한다. 언제 — hwp_doc_fill_fields 로 채우기 전 어떤 필드가 있는지 확인하거나 채운 직후 반영값을 검증할 때. 봉투는 무상태 hwp_fields 와 동형: {fieldCount, fields[].name/instruction/value/location, textSecurity}. 반복 이름은 fill 때 '이름[N]'(0 기준) 으로 지목한다. 오류 — 핸들이 없으면 isError:true 와 nextCall(hwp_open).", "inputSchema": { "type": "object", "properties": { "docId": { "type": "string" } }, "required": ["docId"] } })); session.push(serde_json::json!({ "name": "hwp_doc_tables", - "description": "[#3609] 핸들의 표 격자를 재파싱 없이 추출한다. 봉투는 hwp_export_tables 와 동형.", + "description": "[#3609] 핸들의 표를 병합 정보와 중첩 구조를 보존한 격자 JSON 으로 재파싱 없이 추출한다. 언제 — hwp_doc_set_cell 로 셀을 고치기 전 표 번호·행/열 좌표·병합 범위를 확인하거나, 표를 데이터로 읽을 때. 봉투는 무상태 hwp_export_tables 와 동형: {tableCount, tables[]}(각 셀에 rowSpan/colSpan·중첩표 보존). 오류 — 핸들이 없거나 만료면 isError:true 와 nextCall(hwp_open).", "inputSchema": { "type": "object", "properties": { "docId": { "type": "string" } }, "required": ["docId"] } })); session.push(serde_json::json!({ @@ -1167,6 +1169,32 @@ fn served_tools( "required": ["docId", "query"] } })); + // [#4856] 세션 조회 파리티 — 무상태 표면에는 있으나 세션에는 없던 구조·데이터 추출 축. + session.push(serde_json::json!({ + "name": "hwp_doc_structure", + "description": "[#4856] hwp_open 으로 연 핸들에서 개요·조문(제N조) 계층을 재파싱 없이 트리로 뽑는다. 언제 — 대형 법령·규정을 세션으로 한 번 열어 조문 단위로 청킹·인용할 때(전문 덤프 대신 목차만). 봉투는 무상태 hwp_export_structure 와 동형: {schemaVersion, source(=docId), mode, nodeCount, structure}. mode 는 auto|outline|clause(기본 auto=문서에 맞춰 자동 판별). hwp_doc_tree(안정 노드 ID p0/t0 축)와 달리 이쪽은 제목·조문의 의미 계층이다. 오류 — 핸들이 없거나 만료면 isError:true 와 nextCall(hwp_open) 로 재발급을 안내한다.", + "inputSchema": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "hwp_open 이 돌려준 핸들" }, + "mode": { "type": "string", "enum": ["auto", "outline", "clause"], "description": "분류 방식. 기본 auto" } + }, + "required": ["docId"] + } + })); + session.push(serde_json::json!({ + "name": "hwp_doc_extract_data", + "description": "[#4856] hwp_open 으로 연 핸들에서 날짜·금액·수량을 구역·문단·페이지·문자 오프셋 주소와 함께 재파싱 없이 뽑는다. 언제 — 대형 문서를 세션으로 열어 텍스트·표·검색과 더불어 데이터 값까지 한 핸들에서 반복 조회할 때. 봉투는 무상태 hwp_extract_data 와 동형: 값마다 raw(문서 표기)와 normalized(ISO-8601 날짜·정수 금액·수량, 정규화 불가 시 null)가 함께 온다. kind 로 종류를, limit 로 반환 상한을 좁힌다 — 총량은 전수 스캔으로 세어 totalItemCount 로 오고, 절단되면 truncated:true 다. 오류 — 핸들이 없으면 isError:true 와 nextCall(hwp_open).", + "inputSchema": { + "type": "object", + "properties": { + "docId": { "type": "string", "description": "hwp_open 이 돌려준 핸들" }, + "kind": { "type": "string", "enum": ["date", "amount", "number", "all"], "description": "뽑을 종류. 기본 all" }, + "limit": { "type": "integer", "minimum": 1, "description": "[#3787 S7] 반환 건수 상한(컨텍스트 절약). 전수 스캔 후 표시만 절단하며 총량은 totalItemCount 로 온다. 생략하면 무제한" } + }, + "required": ["docId"] + } + })); session.push(serde_json::json!({ "name": "hwp_doc_replace_text", "description": "[#3601] 핸들의 IR 에 문자열 일괄 치환을 누적한다(디스크 미기록 — hwp_doc_save 가 기록 지점). replacedCount 0 은 오류가 아니라 계수 보고다. hwp_doc_fill_fields 와 조합해 '채우고 다듬고 한 번에 저장'하는 흐름을 만든다. [#3719] 봉투의 changedPages:[n,…]|null 은 재조판 후 0 기준 쪽 번호 — 그 쪽만 hwp_doc_render_page 로 렌더하면 눈검증이 끝난다(null 이면 확정 불가이니 전체를 보라).", @@ -1284,6 +1312,9 @@ fn handle_tool_call( "hwp_doc_tables" => Ok(session_tables(&args, sessions)), "hwp_doc_render_page" => Ok(session_render_page(&args, sessions)), "hwp_doc_search" => Ok(session_search(&args, sessions)), + // [#4856] 세션 조회 파리티 — 무상태 export-structure·extract-data 의 세션 판. + "hwp_doc_structure" => Ok(session_doc_structure(&args, sessions)), + "hwp_doc_extract_data" => Ok(session_doc_extract_data(&args, sessions)), // [#4357 W1] 변이 4종은 저널로 감싼다 — 매 변이의 전/후 본문 digest 가 // 자동으로 남아 hwp_ws_journal 로 자기검증한다. "hwp_doc_replace_text" => Ok(journal_wrap( @@ -1708,6 +1739,94 @@ fn session_search(args: &serde_json::Value, sessions: &mut Sessions) -> serde_js tool_ok_text(crate::search_json_value(doc_id, query, case_sensitive, &shown, total).to_string()) } +/// [#4856] 열린 핸들에서 개요·조문 구조를 재파싱 없이 추출한다 — 무상태 +/// `export-structure --json` 과 **같은 코어(build_structure)·봉투(structure_json_value)** +/// 를 재사용해 동형을 보장한다(`source` 자리에는 경로 대신 핸들 docId 가 들어간다). +/// +/// mode 오타는 조용히 auto 로 되돌아가면 안 된다 — 요청한 분류축이 바뀐 채 성공으로 +/// 보고되기 때문이다(무상태 `--mode` 가 EXIT_USAGE 로 끊는 것과 같은 갈래). 그래서 +/// mode 검증을 핸들 조회보다 **먼저** 해, 핸들이 없는 오타 호출도 어느 축이 왜 +/// 틀렸는지부터 답한다. +fn session_doc_structure(args: &serde_json::Value, sessions: &mut Sessions) -> serde_json::Value { + use rhwp::document_core::queries::structure::{build_structure, StructureMode}; + let mode = match args.get("mode") { + None | Some(serde_json::Value::Null) => StructureMode::Auto, + Some(serde_json::Value::String(s)) => match StructureMode::parse(s) { + Some(m) => m, + None => { + return tool_error(format!( + "mode 는 auto|outline|clause 여야 합니다 (받은 값: {s})" + )) + } + }, + Some(other) => { + return tool_error(format!("mode 는 문자열이어야 합니다 (받은 값: {other})")) + } + }; + let (sd, id) = match with_doc(args, sessions) { + Ok(v) => v, + Err(e) => return e, + }; + let st = build_structure(sd.doc.document(), mode); + tool_ok_text(crate::structure_json_value(&id, &st).to_string()) +} + +/// [#4856] 열린 핸들에서 날짜·금액·수량을 재파싱 없이 뽑는다 — 무상태 +/// `extract-data --json` 과 **같은 코어(extract_data)·봉투(extract_data_json_value)** +/// 를 재사용한다. 총량은 전수 스캔으로 세고 **표시만** 절단해(무상태 `--limit` 과 같은 +/// 규칙) `totalItemCount` 가 두 표면에서 같은 뜻이다. +/// +/// kind 오타를 조용히 all 로 되돌리면 뽑는 종류가 바뀐 채 성공으로 보고된다 — search +/// 의 caseSensitive 와 같은 이유로 거부가 유일하게 안전하다. +fn session_doc_extract_data( + args: &serde_json::Value, + sessions: &mut Sessions, +) -> serde_json::Value { + use rhwp::document_core::queries::extract_data::DataKind; + let (kind_arg, selected): (String, Vec) = match args.get("kind") { + None | Some(serde_json::Value::Null) => ("all".to_string(), DataKind::ALL.to_vec()), + Some(serde_json::Value::String(s)) if s == "all" => { + ("all".to_string(), DataKind::ALL.to_vec()) + } + Some(serde_json::Value::String(s)) => match DataKind::parse(s) { + Some(k) => (s.clone(), vec![k]), + None => { + return tool_error(format!( + "kind 는 date|amount|number|all 여야 합니다 (받은 값: {s})" + )) + } + }, + Some(other) => { + return tool_error(format!("kind 는 문자열이어야 합니다 (받은 값: {other})")) + } + }; + // [#3787 S7] 반환 상한. 0·음수·소수·문자열은 거부, 생략은 무제한(종전 무상태와 동형). + let limit = match opt_limit(args, "limit") { + Ok(v) => v, + Err(e) => return tool_error(e), + }; + let (sd, id) = match with_doc(args, sessions) { + Ok(v) => v, + Err(e) => return e, + }; + let all_items = sd.doc.extract_data(&selected); + let total_item_count = all_items.len(); + let mut counts = serde_json::Map::new(); + for kind in &selected { + let n = all_items.iter().filter(|it| it.kind == *kind).count(); + counts.insert(kind.as_str().to_string(), serde_json::json!(n)); + } + let counts = serde_json::Value::Object(counts); + let items: Vec<_> = match limit { + Some(n) => all_items.into_iter().take(n).collect(), + None => all_items, + }; + tool_ok_text( + crate::extract_data_json_value(&id, &kind_arg, &items, total_item_count, &counts) + .to_string(), + ) +} + /// [#3719 §6-1] 세션 편집 봉투의 `changedPages` — 무상태 판(#3712)과 **같은** 코어 /// 질의(`DocumentCore::pages_covering_paragraphs`)를 재사용한다. 새 계산은 없다. /// diff --git a/tests/mcp_session_structure_extract_contract.rs b/tests/mcp_session_structure_extract_contract.rs new file mode 100644 index 0000000000..ad07fde2b1 --- /dev/null +++ b/tests/mcp_session_structure_extract_contract.rs @@ -0,0 +1,281 @@ +//! [#4856] `mcp-serve` 세션 조회 파리티 — 열린 핸들에서 재파싱 없이 개요·조문 +//! 구조(hwp_doc_structure)와 날짜·금액·수량(hwp_doc_extract_data)을 뽑는다. +//! +//! 무상태 표면(export-structure·extract-data)에는 있으나 세션에는 없던 두 축을 채운다. +//! 계약: 두 도구가 tools/list 에 **나오고**(읽기 전용 annotations) tools/call 로 +//! **불린다**. 봉투는 무상태 CLI 와 동형(같은 코어·같은 봉투 helper 재사용)이라, 세션 +//! 판과 무상태 판이 같은 문서에서 같은 값을 낸다. +#![cfg(not(target_arch = "wasm32"))] + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +/// 개요/조문·숫자(‥장·100%)가 있는 HWP3 표본 — 구조·데이터 추출의 안정 표적. +const SAMPLE: &str = "samples/hwp3-sample.hwp"; + +fn sample() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(SAMPLE) +} + +fn run_cli(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rhwp")) + .args(args) + .output() + .expect("rhwp 실행 실패") +} + +struct Server { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Server { + fn started() -> Server { + let mut child = Command::new(env!("CARGO_BIN_EXE_rhwp")) + .arg("mcp-serve") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("rhwp mcp-serve 실행 실패"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")); + let mut s = Server { + child, + stdin, + stdout, + next_id: 1, + }; + let r = s.request( + "initialize", + serde_json::json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "session-structure-extract-test", "version": "0"} + }), + ); + assert!(r["result"]["serverInfo"]["name"].is_string(), "{r}"); + s + } + + fn request(&mut self, method: &str, params: serde_json::Value) -> serde_json::Value { + let id = self.next_id; + self.next_id += 1; + let msg = + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + writeln!(self.stdin, "{msg}").expect("요청 쓰기 실패"); + self.stdin.flush().expect("flush"); + let mut line = String::new(); + loop { + line.clear(); + let n = self.stdout.read_line(&mut line).expect("응답 읽기 실패"); + assert!(n > 0, "서버가 응답 없이 종료했습니다 (method={method})"); + if line.trim().is_empty() { + continue; + } + let v: serde_json::Value = serde_json::from_str(line.trim()) + .unwrap_or_else(|e| panic!("stdout 이 JSON-RPC 가 아닙니다 ({e}): {line}")); + if v.get("id").and_then(|i| i.as_i64()) == Some(id) { + return v; + } + } + } + + fn call(&mut self, name: &str, args: serde_json::Value) -> (bool, serde_json::Value) { + let r = self.request( + "tools/call", + serde_json::json!({"name": name, "arguments": args}), + ); + let result = &r["result"]; + let is_error = result["isError"].as_bool().unwrap_or(false); + let text = result["content"][0]["text"] + .as_str() + .unwrap_or("") + .to_string(); + let v = serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)); + (is_error, v) + } + + fn open(&mut self, path: &Path) -> String { + let (err, v) = self.call( + "hwp_open", + serde_json::json!({"path": path.to_str().unwrap()}), + ); + assert!(!err, "hwp_open 실패: {v}"); + v["docId"].as_str().expect("docId").to_string() + } + + fn listed_tool(&mut self, name: &str) -> serde_json::Value { + let r = self.request("tools/list", serde_json::json!({})); + r["result"]["tools"] + .as_array() + .expect("tools 배열") + .iter() + .find(|t| t["name"] == name) + .unwrap_or_else(|| panic!("{name} 이 tools/list 에 없습니다")) + .clone() + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// ① 두 새 도구가 tools/list 에 나오고, 읽기 전용·비파괴 annotations 를 단다. +#[test] +fn new_session_tools_are_listed_read_only() { + let mut s = Server::started(); + for name in ["hwp_doc_structure", "hwp_doc_extract_data"] { + let t = s.listed_tool(name); + assert!(t["description"].is_string(), "{name}: 설명 누락: {t}"); + assert!( + t["inputSchema"]["properties"]["docId"].is_object(), + "{name}: docId 입력 스키마 누락: {t}" + ); + let a = &t["annotations"]; + assert_eq!(a["readOnlyHint"], true, "{name}: {a}"); + assert_eq!(a["destructiveHint"], false, "{name}: {a}"); + assert_eq!(a["idempotentHint"], true, "{name}: {a}"); + assert_eq!(a["openWorldHint"], false, "{name}: {a}"); + } +} + +/// ② hwp_doc_structure 는 무상태 export-structure 와 동형 봉투를 낸다. +#[test] +fn session_structure_matches_stateless() { + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let stateless = run_cli(&["export-structure", src.to_str().unwrap(), "--json"]); + let sv: serde_json::Value = + serde_json::from_slice(&stateless.stdout).expect("export-structure --json"); + + let mut s = Server::started(); + let doc_id = s.open(&src); + let (err, v) = s.call("hwp_doc_structure", serde_json::json!({"docId": doc_id})); + assert!(!err, "hwp_doc_structure 실패: {v}"); + // 동형: mode·nodeCount 가 무상태 판과 같고, structure 는 객체다. + assert_eq!(v["mode"], sv["mode"], "mode 동형: {v}"); + assert_eq!(v["nodeCount"], sv["nodeCount"], "nodeCount 동형: {v}"); + assert!(v["structure"].is_object(), "structure 객체: {v}"); + // source 자리에는 경로 대신 핸들 docId 가 들어간다. + assert_eq!(v["source"], serde_json::json!(doc_id), "source=docId: {v}"); +} + +/// ②-b mode 를 명시하면 그대로 반영되고, 오타는 조용히 auto 로 되돌아가지 않는다. +#[test] +fn session_structure_mode_is_honored_and_typos_rejected() { + let src = sample(); + if !src.exists() { + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + + let (err, v) = s.call( + "hwp_doc_structure", + serde_json::json!({"docId": doc_id, "mode": "outline"}), + ); + assert!(!err, "{v}"); + assert_eq!(v["mode"], "outline", "명시한 mode 가 반영돼야 합니다: {v}"); + + let (err, v) = s.call( + "hwp_doc_structure", + serde_json::json!({"docId": doc_id, "mode": "nonsense"}), + ); + assert!(err, "mode 오타는 isError 여야 합니다: {v}"); +} + +/// ③ hwp_doc_extract_data 는 무상태 extract-data 와 동형 봉투를 낸다. +#[test] +fn session_extract_data_matches_stateless() { + let src = sample(); + if !src.exists() { + eprintln!("샘플 없음 — 건너뜀"); + return; + } + let stateless = run_cli(&["extract-data", src.to_str().unwrap(), "--json"]); + let sv: serde_json::Value = + serde_json::from_slice(&stateless.stdout).expect("extract-data --json"); + let expected_total = sv["totalItemCount"].as_u64().expect("totalItemCount"); + + let mut s = Server::started(); + let doc_id = s.open(&src); + let (err, v) = s.call("hwp_doc_extract_data", serde_json::json!({"docId": doc_id})); + assert!(!err, "hwp_doc_extract_data 실패: {v}"); + assert_eq!(v["kind"], "all", "기본 kind=all: {v}"); + assert_eq!( + v["totalItemCount"].as_u64(), + Some(expected_total), + "totalItemCount 동형: {v}" + ); + assert_eq!( + v["itemCount"], sv["itemCount"], + "itemCount 동형(절단 없음): {v}" + ); + assert_eq!(v["counts"], sv["counts"], "counts 동형: {v}"); + assert!(v["items"].is_array(), "items 배열: {v}"); + assert_eq!(v["source"], serde_json::json!(doc_id), "source=docId: {v}"); +} + +/// ③-b limit 절단은 표시만 자르고 총량은 totalItemCount 로 보고한다(전제: 표본에 2건+). +#[test] +fn session_extract_data_limit_truncates_display_only() { + let src = sample(); + if !src.exists() { + return; + } + let mut s = Server::started(); + let doc_id = s.open(&src); + let (err, full) = s.call("hwp_doc_extract_data", serde_json::json!({"docId": doc_id})); + assert!(!err, "{full}"); + let total = full["totalItemCount"].as_u64().unwrap_or(0); + if total < 2 { + eprintln!("표본 데이터가 2건 미만 — limit 절단 검증 건너뜀"); + return; + } + let (err, v) = s.call( + "hwp_doc_extract_data", + serde_json::json!({"docId": doc_id, "limit": 1}), + ); + assert!(!err, "{v}"); + assert_eq!( + v["itemCount"].as_u64(), + Some(1), + "표시는 1건으로 잘린다: {v}" + ); + assert_eq!( + v["totalItemCount"].as_u64(), + Some(total), + "총량은 그대로: {v}" + ); + assert_eq!(v["truncated"], true, "절단 표지: {v}"); + // kind 오타는 조용히 all 로 되돌아가지 않는다. + let (err, bad) = s.call( + "hwp_doc_extract_data", + serde_json::json!({"docId": doc_id, "kind": "nonsense"}), + ); + assert!(err, "kind 오타는 isError 여야 합니다: {bad}"); +} + +/// ④ 닫힌/모르는 핸들은 isError + nextCall(hwp_open) 로 재발급을 안내한다(두 도구 공통). +#[test] +fn new_session_tools_recover_from_missing_handle() { + let mut s = Server::started(); + for name in ["hwp_doc_structure", "hwp_doc_extract_data"] { + let (err, v) = s.call(name, serde_json::json!({"docId": "doc-없음"})); + assert!(err, "{name}: 모르는 핸들은 isError 여야 합니다: {v}"); + assert_eq!( + v["nextCall"]["name"], "hwp_open", + "{name}: 재발급 안내(nextCall=hwp_open): {v}" + ); + } +} diff --git a/tests/mcp_tool_annotations_contract.rs b/tests/mcp_tool_annotations_contract.rs index 3fcd8335a3..fcb94478d8 100644 --- a/tests/mcp_tool_annotations_contract.rs +++ b/tests/mcp_tool_annotations_contract.rs @@ -337,11 +337,18 @@ fn served_tools_reflect_manifest_and_session_tools_are_consistent() { assert_eq!(a["destructiveHint"], false, "{name}: {a}"); assert_eq!(a["idempotentHint"], true, "{name}: {a}"); } + // [#4856] 세션 조회 파리티 — 개요/조문(structure)·데이터 추출(extract_data). + // 환경 무변경이고 같은 인자 재호출이 같은 관찰로 수렴한다(순수 조회). + "hwp_doc_structure" | "hwp_doc_extract_data" => { + assert_eq!(a["readOnlyHint"], true, "{name}: {a}"); + assert_eq!(a["destructiveHint"], false, "{name}: {a}"); + assert_eq!(a["idempotentHint"], true, "{name}: {a}"); + } other => panic!("계약에 없는 세션 도구 {other} — 이 match 에 판정을 추가하라: {a}"), } } assert_eq!( - session_seen, 16, + session_seen, 18, "세션 도구 수가 달라졌다 — agent_profiles::ALL_SESSION_TOOLS 와 이 계약을 함께 갱신하라" ); }