hwpx-bridge 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HWPX Bridge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # HWPX Bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ AI 에이전트가 한글 문서(HWP·HWPX)를 다룰 수 있게 해 주는 **MCP 서버 하나**입니다.
4
+
5
+ - **[kordoc](https://github.com/chrisryugj/kordoc)의 모든 기능**: HWP·HWPX·PDF·DOCX·XLSX·이미지 읽기, 스캔 문서 OCR, Markdown → 새 HWPX 작성, 양식 채우기, 서식 유지 수정, HWP → HWPX, 렌더링, 공문서 검수 (22개 도구)
6
+ - **[Filee](https://github.com/KnifeLemon/Filee)의 문서 변환 기능만**: DOCX → HWPX를 글꼴·색·정렬·머리말·쪽번호·표 서식까지 유지해 변환, 그 밖의 문서 형식 간 변환 (`filee_convert`)
7
+
8
+ 문서는 내 컴퓨터에서 처리되며 변환 서비스에 업로드되지 않습니다.
9
+
10
+ ## 설치 — 한 줄
11
+
12
+ [Node.js](https://nodejs.org) 20 이상이 있으면(kordoc과 같은 조건) 아무 터미널에서:
13
+
14
+ ```
15
+ npx -y hwpx-bridge setup
16
+ ```
17
+
18
+ 등록할 앱을 고르면 끝입니다: Claude 앱, Claude Code, Codex, Cursor, Gemini CLI, Windsurf. 앱을 한 번 다시 시작하면 도구가 나타납니다.
19
+
20
+ 번호를 고르지 않고 바로 등록하려면:
21
+
22
+ ```
23
+ npx -y hwpx-bridge setup --client claude-desktop,claude-code,codex
24
+ ```
25
+
26
+ 직접 넣는 경우 (`npx -y hwpx-bridge config`가 출력하는 것과 같음):
27
+
28
+ ```json
29
+ { "mcpServers": { "hwpx-bridge": { "command": "npx", "args": ["-y", "hwpx-bridge@0.3.1", "mcp"] } } }
30
+ ```
31
+
32
+ Windows의 Claude 앱은 `"command": "cmd", "args": ["/c", "npx", "-y", "hwpx-bridge@0.3.1", "mcp"]` 형식이 필요하며, `setup`이 자동으로 맞춥니다. 처음 실행할 때 엔진(약 100 MB)을 내려받으므로, `setup`을 먼저 실행해 두면 앱 첫 실행이 빠릅니다.
33
+
34
+ Claude Code·Codex 플러그인 마켓플레이스(`ThisIsMarkive/foryourdocument`)로 설치해도 같은 서버(`npx -y hwpx-bridge@0.3.1 mcp`)와 사용 안내 스킬이 등록됩니다.
35
+
36
+ 확인: `npx -y hwpx-bridge doctor`
37
+
38
+ ## 도구
39
+
40
+ | 하고 싶은 일 | 도구 |
41
+ | --- | --- |
42
+ | 기존 DOCX → HWPX (서식 유지) | `filee_convert` |
43
+ | HWPX → PDF/DOCX, PDF → DOCX, PPTX·XLSX → PDF, 이미지 → PDF 등 | `filee_convert` (`to`) |
44
+ | 문서 읽기, 스캔 OCR | `parse_document` |
45
+ | 새 HWPX 작성 | `generate_document` |
46
+ | 양식 채우기, 기존 HWPX 수정 | `fill_form`, `patch_document` |
47
+ | HWP → HWPX | `convert_to_hwpx` |
48
+ | 미리보기 | `render_document` |
49
+ | 상태 확인 | `hwpx_bridge_status` |
50
+
51
+ 그 밖의 kordoc 도구: `detect_format`, `parse_metadata`, `parse_pages`, `parse_table`, `parse_chunks`, `parse_form`, `compare_documents`, `place_seal`, `add_memo`, `redact_document`, `crop_regions`, `extract_tables`, `extract_profile`, `build_agency_pack`, `agency_pack_info`, `lint_document`.
52
+
53
+ `filee_convert`가 받는 입력: 문서(docx, doc, odt, rtf …), hwp/hwpx, pdf, pptx/xlsx 등 오피스, md/txt/html 등 텍스트, 이미지와 벡터 그림.
54
+ 출력: `hwpx, hwp, docx, odt, rtf, pdf, pptx, xlsx, csv, md, txt, html, png, jpg, webp, tiff, svg`.
55
+ 동영상·오디오·압축·전자책·CAD·3D·폰트 등 문서와 무관한 Filee 기능은 노출하지 않습니다. 같은 이름의 결과 파일이 있으면 덮어쓰지 않고 건너뜁니다.
56
+
57
+ ## 지원 환경
58
+
59
+ | | kordoc 도구 | `filee_convert` |
60
+ | --- | --- | --- |
61
+ | Windows x64 | O | O |
62
+ | Linux x64 | O | O |
63
+ | macOS 등 | O | X (안내 메시지) |
64
+
65
+ OCR 모델은 kordoc이 처음 OCR할 때 자동으로 받습니다(약 18 MB부터). OCR 결과는 오인식이 있을 수 있습니다.
66
+
67
+ ## 구조
68
+
69
+ ```
70
+ npm/hwpx-bridge MCP 서버 (Node). kordoc npm 패키지를 그대로 실행하고 도구를 함께 노출
71
+ npm/filee-win32-x64 Filee 2.4.0 공식 릴리스의 CLI만 추린 것 (Windows)
72
+ npm/filee-linux-x64 〃 (Linux)
73
+ npm/scripts/prepare-filee.mjs 공식 릴리스를 SHA-256으로 확인하고 CLI에 필요한 파일만 복사
74
+ plugins/hwpx-bridge Claude Code·Codex 플러그인 (MCP 등록 + 사용 안내 스킬)
75
+ ```
76
+
77
+ Filee는 소스를 빌드하지 않고 **공식 릴리스의 파일을 그대로** 씁니다. 그중 `filee-cli`가 로드하는 파일(`filee-cli.deps.json` 기준)과 HWP 렌더링 엔진 rhwp만 남기고, 데스크톱 앱(GUI, Ctrl 단축키, 탐색기 메뉴), 7-Zip, 디버그 심볼, 번역 파일은 뺍니다. 변환 결과는 원본 Filee와 같습니다.
78
+
79
+ ## 배포 (관리자용)
80
+
81
+ 1. npm 계정을 만들고(2단계 인증 필수) Granular Access Token을 만듭니다: 권한 Read and write, 대상 All packages, **Bypass 2FA** 체크, 만료 최대 90일. GitHub 저장소 Secret `NPM_TOKEN`으로 등록합니다. 첫 배포 뒤에는 각 패키지 설정에서 Trusted Publishing(GitHub Actions)을 켜면 토큰 갱신이 필요 없습니다.
82
+ 2. 세 `package.json`과 플러그인 매니페스트의 버전을 올리고, `vX.Y.Z` 태그를 푸시하거나 GitHub Releases에서 같은 태그로 릴리스를 만듭니다.
83
+ 3. GitHub Actions가 Filee 준비 → 패키지 생성 → Windows·Linux에서 실제 설치·변환 테스트 → npm 배포를 순서대로 합니다.
84
+
85
+ 로컬에서 확인하려면: `node npm/scripts/prepare-filee.mjs all` 후 각 폴더에서 `npm pack`.
86
+
87
+ 이 프로젝트는 kordoc·Filee의 공식 제품이 아닌 독립적인 연결 도구입니다. 라이선스: MIT. [제3자 고지](npm/hwpx-bridge/THIRD_PARTY_NOTICES.md)
@@ -0,0 +1,13 @@
1
+ # Third-party notices
2
+
3
+ hwpx-bridge combines two independent open-source projects. Neither project endorses this package.
4
+
5
+ | Component | How it is used | License |
6
+ | --- | --- | --- |
7
+ | [kordoc](https://github.com/chrisryugj/kordoc) by chrisryugj | npm dependency `kordoc`, run unmodified as an MCP server | MIT |
8
+ | [Filee](https://github.com/KnifeLemon/Filee) by Myeonggu Kim (KnifeLemon) and contributors | Command-line converter from the official release archive, unmodified, in `hwpx-bridge-filee-<platform>` | MIT (`filee/LICENSE-Filee.txt` in each platform package) |
9
+ | [rhwp](https://github.com/edwardkim/rhwp) | Bundled by Filee for HWP/HWPX rendering (`filee/engines/rhwp`) | MIT |
10
+ | [Model Context Protocol SDK](https://github.com/modelcontextprotocol/typescript-sdk) | npm dependency | MIT |
11
+
12
+ The Filee platform packages also contain the .NET runtime and libraries listed in Filee's own
13
+ `THIRD-PARTY-NOTICES.md` (shipped in the Linux package; the same components are used on Windows).
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const pkg = require('../package.json');
6
+ const [command = 'help', ...rest] = process.argv.slice(2);
7
+
8
+ const HELP = `hwpx-bridge ${pkg.version} — kordoc + Filee document conversion as one MCP server
9
+
10
+ npx -y hwpx-bridge setup AI 앱(Claude, Claude Code, Codex, Cursor ...)에 등록
11
+ npx -y hwpx-bridge setup --client claude-desktop,codex
12
+ npx -y hwpx-bridge config 직접 붙여 넣을 MCP 설정 JSON 출력
13
+ npx -y hwpx-bridge doctor kordoc·Filee 실행 확인
14
+ npx -y hwpx-bridge mcp MCP 서버 실행 (앱이 이 명령을 실행합니다)
15
+ `;
16
+
17
+ try {
18
+ switch (command) {
19
+ case 'mcp': await (await import('../lib/server.mjs')).startServer(); break;
20
+ case 'setup': await (await import('../lib/setup.mjs')).runSetup(rest); break;
21
+ case 'config': (await import('../lib/setup.mjs')).printConfig(); break;
22
+ case 'doctor': await (await import('../lib/doctor.mjs')).runDoctor(); break;
23
+ case '-v': case '--version': case 'version': console.log(pkg.version); break;
24
+ default: console.log(HELP);
25
+ }
26
+ } catch (error) {
27
+ console.error(error.message);
28
+ process.exitCode = 1;
29
+ }
package/lib/doctor.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // `npx -y hwpx-bridge doctor` — start the MCP server once, as an AI app would, and report what works.
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ import { locateFilee } from './filee.mjs';
7
+ import { runProcess } from './process.mjs';
8
+
9
+ export async function runDoctor() {
10
+ const filee = locateFilee();
11
+ if (filee.error) console.log(`x Filee: ${filee.error}`);
12
+ else {
13
+ const version = await runProcess(filee.cli, ['version'], { timeout: 60_000 }).catch(error => ({ code: 1, stdout: '', stderr: error.message }));
14
+ console.log(version.code === 0 ? `+ Filee: ${version.stdout.trim()} (${filee.cli})` : `x Filee: ${version.stderr.trim() || 'failed to start'}`);
15
+ }
16
+ const bin = path.join(path.dirname(fileURLToPath(import.meta.url)), '../bin/hwpx-bridge.mjs');
17
+ const client = new Client({ name: 'hwpx-bridge-doctor', version: '0' });
18
+ try {
19
+ await client.connect(new StdioClientTransport({ command: process.execPath, args: [bin, 'mcp'], stderr: 'inherit' }));
20
+ const { tools } = await client.listTools();
21
+ console.log(`+ MCP: ${tools.length} tools — ${tools.map(t => t.name).join(', ')}`);
22
+ const status = await client.callTool({ name: 'hwpx_bridge_status', arguments: {} });
23
+ console.log(status.content[0].text);
24
+ } catch (error) {
25
+ console.log(`x MCP: ${error.message}`);
26
+ process.exitCode = 1;
27
+ } finally { await client.close().catch(() => {}); }
28
+ }
package/lib/filee.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // Filee document conversion. The converter is the unmodified Filee command-line program from the
2
+ // official release, shipped in the platform package hwpx-bridge-filee-<platform>-<arch>.
3
+ import fs from 'node:fs/promises';
4
+ import fss from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { createRequire } from 'node:module';
8
+ import { checkPath } from './paths.mjs';
9
+ import { runProcess } from './process.mjs';
10
+ import { INPUT_EXTENSIONS, OUTPUT_FORMATS } from './formats.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const PLATFORM_PACKAGE = `hwpx-bridge-filee-${process.platform}-${process.arch}`;
14
+
15
+ /** Locate the bundled Filee CLI, or return null with the reason. */
16
+ export function locateFilee() {
17
+ if (process.env.HWPX_BRIDGE_FILEE) {
18
+ const cli = path.resolve(process.env.HWPX_BRIDGE_FILEE);
19
+ return fss.existsSync(cli) ? { cli, source: 'HWPX_BRIDGE_FILEE' } : { error: `HWPX_BRIDGE_FILEE does not exist: ${cli}` };
20
+ }
21
+ let root;
22
+ try { root = path.dirname(require.resolve(`${PLATFORM_PACKAGE}/package.json`)); }
23
+ catch {
24
+ return { error: `Filee is not available for ${process.platform}-${process.arch}. Supported: Windows x64, Linux x64. kordoc tools still work.` };
25
+ }
26
+ const cli = path.join(root, 'filee', process.platform === 'win32' ? 'filee-cli.exe' : 'filee-cli');
27
+ if (!fss.existsSync(cli)) return { error: `${PLATFORM_PACKAGE} is installed but ${cli} is missing.` };
28
+ let source = {};
29
+ try { source = JSON.parse(fss.readFileSync(path.join(root, 'filee', 'SOURCE.json'), 'utf8')); } catch { /* optional */ }
30
+ return { cli, source: PLATFORM_PACKAGE, version: source.version };
31
+ }
32
+
33
+ export const fileeTool = {
34
+ name: 'filee_convert',
35
+ title: 'Filee document conversion',
36
+ description: [
37
+ 'Convert a document file to another document format with Filee (local, no upload).',
38
+ 'Best choice for DOCX -> HWPX: keeps fonts, sizes, colors, bold/italic/underline, alignment, indentation,',
39
+ 'line spacing, lists, headers/footers, page numbers, page margins, page breaks, table shading and images.',
40
+ 'Also converts HWP/HWPX/DOCX/PPTX/XLSX/PDF/Markdown/HTML/images between each other (e.g. HWPX -> PDF or DOCX,',
41
+ 'PDF -> DOCX, DOCX -> PDF, image -> PDF).',
42
+ 'To read a document use parse_document; to write a new HWPX from text use generate_document; for scans/OCR',
43
+ 'use parse_document OCR options. Existing output files are never overwritten (they are skipped).'
44
+ ].join(' '),
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ input_path: { type: 'string', description: 'Absolute path of an existing local input file.' },
49
+ output_dir: { type: 'string', description: 'Absolute output directory (created if missing). The output keeps the input file name with the new extension.' },
50
+ to: { type: 'string', enum: OUTPUT_FORMATS, default: 'hwpx', description: 'Target format. Default hwpx.' }
51
+ },
52
+ required: ['input_path', 'output_dir'],
53
+ additionalProperties: false
54
+ },
55
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
56
+ };
57
+
58
+ export async function callFilee(args, { signal } = {}) {
59
+ const located = locateFilee();
60
+ if (located.error) throw new Error(located.error);
61
+ const documentRoot = process.env.HWPX_BRIDGE_DOCUMENT_ROOT || process.env.KORDOC_ROOT;
62
+ const input = await checkPath(args?.input_path, { documentRoot });
63
+ const output = await checkPath(args?.output_dir, { directory: true, create: true, documentRoot });
64
+ const extension = path.extname(input).slice(1).toLowerCase();
65
+ if (!INPUT_EXTENSIONS.has(extension)) throw new Error(`Unsupported input type .${extension}. Only document, office, HWP, PDF, text and image files are accepted.`);
66
+ const target = args?.to ?? 'hwpx';
67
+ if (!OUTPUT_FORMATS.includes(target)) throw new Error(`Unsupported target format ${target}. Use one of: ${OUTPUT_FORMATS.join(', ')}.`);
68
+
69
+ // A private Filee data folder per job: no user settings are read or written, and the optional
70
+ // downloadable engines (LibreOffice, Pandoc, Ghostscript, FFmpeg, Tesseract ...) are never used.
71
+ const job = await fs.mkdtemp(path.join(os.tmpdir(), 'hwpx-bridge-filee-'));
72
+ try {
73
+ await fs.writeFile(path.join(job, 'settings.json'), JSON.stringify({
74
+ libreOfficeForHwp: false, libreOfficeForDocuments: false, libreOfficeForSheets: false
75
+ }));
76
+ const result = await runProcess(located.cli, ['convert', input, '--to', target, '-o', output, '--skip', '--json'], {
77
+ env: { ...process.env, FILEE_DATA_DIR: job, DOTNET_CLI_TELEMETRY_OPTOUT: '1' }, signal
78
+ });
79
+ // --json prints progress events as JSON lines, then the summary object.
80
+ const text = result.stdout.replace(/^/, '');
81
+ const start = text.search(/^\{\s*$/m);
82
+ let summary;
83
+ try { summary = JSON.parse(start >= 0 ? text.slice(start) : text); }
84
+ catch { throw new Error(`Filee returned no result (exit ${result.code}). ${result.stderr.slice(-4000) || text.slice(-4000)}`); }
85
+ const failed = result.code !== 0 || summary.failed > 0 || summary.converted + summary.skipped === 0;
86
+ return {
87
+ isError: failed,
88
+ content: [{ type: 'text', text: JSON.stringify({ engine: `Filee ${located.version ?? ''}`.trim(), ...summary }, null, 2) }]
89
+ };
90
+ } finally {
91
+ await fs.rm(job, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
92
+ }
93
+ }
@@ -0,0 +1,4 @@
1
+ // Generated from `filee formats --json` (Filee 2.4.0): document, HWP, PDF, office, text, image and vector
2
+ // categories only. Media, archive, e-book, CAD, 3D, font, subtitle, geo and e-mail formats are not exposed.
3
+ export const INPUT_EXTENSIONS = new Set(["3fr", "602", "abw", "adoc", "ahk", "ai", "apng", "arw", "asciidoc", "avif", "bat", "bmp", "bw", "c", "cc", "cdr", "cgm", "cmd", "command", "cpp", "cr2", "cr3", "crw", "cs", "css", "csv", "cur", "cwk", "cxx", "dart", "dbk", "dbml", "dcm", "dcr", "dds", "dib", "dicom", "dng", "doc", "docbook", "docm", "docx", "dot", "dotm", "dotx", "dps", "dst", "emf", "eps", "epsf", "epsi", "erf", "et", "exr", "fff", "fits", "fodg", "fodp", "fods", "fodt", "fts", "gif", "h", "hdp", "hdr", "heic", "heif", "hgl", "hh", "hif", "hpg", "hpgl", "hpp", "hql", "htm", "html", "hwp", "hwpx", "hxx", "icb", "icns", "ico", "iiq", "ini", "ipynb", "j2k", "java", "jfif", "jif", "jp2", "jpe", "jpeg", "jpf", "jpg", "jpx", "jxl", "jxr", "kdc", "key", "kra", "kra~", "latex", "livp", "log", "lua", "lwp", "man", "markdown", "mcw", "md", "mediawiki", "mef", "mht", "mhtml", "miff", "mng", "mos", "mrw", "nef", "nrw", "numbers", "odg", "odp", "ods", "odt", "ofd", "opml", "orf", "org", "otp", "ots", "ott", "pac", "pages", "pam", "pbm", "pcd", "pcx", "pdf", "pef", "pfm", "pgm", "plt", "png", "pnm", "pot", "potm", "potx", "ppm", "pps", "ppsx", "ppt", "pptm", "pptx", "ps", "ps1", "psb", "psd", "psw", "pub", "py", "qoi", "raf", "ras", "raw", "rgb", "rgba", "rsc", "rst", "rtf", "rw2", "rwl", "sda", "sdc", "sdd", "sdw", "sgi", "sql", "sr2", "srf", "srw", "sun", "svg", "svgz", "tab", "tex", "textile", "tga", "tif", "tiff", "tsv", "txt", "typ", "vda", "vsd", "vsdx", "vst", "wbmp", "wdp", "webp", "wiki", "wmf", "wpd", "wps", "x3f", "xbm", "xcf", "xhtml", "xls", "xlsm", "xlsx", "xlt", "xltx", "xpm", "zabw"]);
4
+ export const OUTPUT_FORMATS = ["hwpx", "hwp", "docx", "odt", "rtf", "pdf", "pptx", "xlsx", "csv", "md", "txt", "html", "png", "jpg", "webp", "tiff", "svg"];
package/lib/paths.mjs ADDED
@@ -0,0 +1,34 @@
1
+ // From HWPX Bridge 0.2.x server/paths.mjs (unchanged).
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export function inside(root, target) {
6
+ const relative = path.relative(root, target);
7
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
8
+ }
9
+ export async function checkPath(value, { directory = false, create = false, documentRoot } = {}) {
10
+ if (typeof value !== 'string' || !path.isAbsolute(value) || /[\0*?]/.test(value)) {
11
+ throw new Error('Use an absolute local file/directory path without wildcards.');
12
+ }
13
+ // Avoid UNC/network paths so the local-only tools cannot send file data to a share.
14
+ if (/^\\\\|^\/\//.test(value) || /^\\\?\//.test(value)) throw new Error('Network and device paths are not supported.');
15
+ let resolved = path.resolve(value);
16
+ let ancestor = resolved;
17
+ const missing = [];
18
+ while (true) {
19
+ try { ancestor = await fs.realpath(ancestor); break; }
20
+ catch (error) {
21
+ if (error.code !== 'ENOENT' || !create) throw error;
22
+ missing.unshift(path.basename(ancestor));
23
+ const parent = path.dirname(ancestor);
24
+ if (parent === ancestor) throw error;
25
+ ancestor = parent;
26
+ }
27
+ }
28
+ resolved = path.join(ancestor, ...missing);
29
+ if (documentRoot && !inside(await fs.realpath(documentRoot), resolved)) throw new Error('Path is outside HWPX_BRIDGE_DOCUMENT_ROOT.');
30
+ if (create) await fs.mkdir(resolved, { recursive: true });
31
+ const stat = await fs.stat(resolved);
32
+ if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error(directory ? 'Expected a directory.' : 'Expected a regular file.');
33
+ return resolved;
34
+ }
@@ -0,0 +1,38 @@
1
+ // From HWPX Bridge 0.2.x server/process.mjs (unchanged).
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+
5
+ export async function runProcess(command, args, { env, signal, timeout = 900_000 } = {}) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(command, args, { env, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
8
+ child.stdout.setEncoding('utf8');
9
+ child.stderr.setEncoding('utf8');
10
+ let stdout = ''; let stderr = ''; let stopped;
11
+ const stop = reason => {
12
+ if (stopped) return;
13
+ stopped = reason;
14
+ if (process.platform === 'win32' && child.pid) {
15
+ // Filee may own Tesseract children; stop this owned process tree together.
16
+ const killer = spawn(path.join(process.env.SystemRoot, 'System32/taskkill.exe'), ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, shell: false, stdio: 'ignore' });
17
+ killer.on('error', () => child.kill());
18
+ killer.on('close', code => { if (code !== 0) child.kill(); });
19
+ } else child.kill();
20
+ };
21
+ const abort = () => stop('Operation cancelled.');
22
+ const timer = setTimeout(() => stop(`Operation exceeded ${timeout / 1000} seconds.`), timeout);
23
+ signal?.addEventListener('abort', abort, { once: true });
24
+ if (signal?.aborted) abort();
25
+ child.stdout.on('data', bytes => {
26
+ stdout += bytes.toString('utf8');
27
+ if (stdout.length > 8_000_000) stop('Engine output exceeded the 8 MB limit.');
28
+ });
29
+ child.stderr.on('data', bytes => { stderr = (stderr + bytes.toString('utf8')).slice(-32_000); });
30
+ const finish = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
31
+ child.on('error', error => { finish(); reject(error); });
32
+ child.on('close', code => {
33
+ finish();
34
+ if (stopped) reject(new Error(stopped));
35
+ else resolve({ code, stdout, stderr });
36
+ });
37
+ });
38
+ }
package/lib/server.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // One MCP server = every kordoc tool + Filee document conversion.
2
+ //
3
+ // kordoc runs as its own MCP server (the unmodified kordoc npm package) in a child process; this server
4
+ // lists its tools next to filee_convert and forwards calls. kordoc's OCR models are downloaded by kordoc
5
+ // on first use, as kordoc normally does.
6
+ import path from 'node:path';
7
+ import { createRequire } from 'node:module';
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
12
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
13
+ import { fileeTool, callFilee, locateFilee } from './filee.mjs';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const pkg = require('../package.json');
17
+ const KORDOC_CALL_TIMEOUT = 30 * 60_000; // OCR model downloads and long renders
18
+
19
+ function kordocInfo() {
20
+ const main = require.resolve('kordoc'); // dist/index.cjs; the package exports only "."
21
+ const dist = path.dirname(main);
22
+ return { mcp: path.join(dist, 'mcp.js'), version: pkg.dependencies.kordoc };
23
+ }
24
+
25
+ const statusTool = {
26
+ name: 'hwpx_bridge_status',
27
+ title: 'HWPX Bridge status',
28
+ description: 'Show hwpx-bridge, kordoc and Filee versions and whether each engine is available on this computer.',
29
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
30
+ annotations: { readOnlyHint: true, openWorldHint: false }
31
+ };
32
+
33
+ const INSTRUCTIONS = `hwpx-bridge: kordoc (read/write/edit Korean documents) + Filee (document format conversion).
34
+ - Existing DOCX -> HWPX (or any document format change: HWPX->PDF/DOCX, PDF->DOCX, PPTX/XLSX->PDF ...): filee_convert. Do not re-create a DOCX through parse_document + generate_document; that loses fonts, colors and layout.
35
+ - Read HWP/HWPX/PDF/DOCX/XLSX/images (incl. OCR): parse_document. New HWPX from text: generate_document. Fill or edit an existing HWPX keeping its layout: fill_form / patch_document. HWP -> HWPX keeping formatting: convert_to_hwpx.
36
+ - Use absolute local paths and a new output path; never overwrite the source file without permission.`;
37
+
38
+ export async function startServer() {
39
+ let kordoc; // Client once connected
40
+ let kordocError; // string when kordoc failed to start
41
+ let kordocTools = [];
42
+ let kordocInstructions = '';
43
+ const info = kordocInfo();
44
+
45
+ const kordocReady = (async () => {
46
+ try {
47
+ const client = new Client({ name: 'hwpx-bridge', version: pkg.version });
48
+ await client.connect(new StdioClientTransport({
49
+ command: process.execPath, args: [info.mcp], env: process.env, stderr: 'inherit'
50
+ }));
51
+ kordocTools = (await client.listTools()).tools.filter(tool => tool.name !== fileeTool.name && tool.name !== statusTool.name);
52
+ kordocInstructions = client.getInstructions() ?? '';
53
+ kordoc = client;
54
+ } catch (error) {
55
+ kordocError = `kordoc MCP failed to start: ${error.message}`;
56
+ process.stderr.write(`[hwpx-bridge] ${kordocError}\n`);
57
+ }
58
+ })();
59
+
60
+ // kordoc starts in about a second; waiting lets its own usage guide travel with ours.
61
+ await kordocReady;
62
+ const server = new Server(
63
+ { name: 'hwpx-bridge', version: pkg.version },
64
+ { capabilities: { tools: {} }, instructions: kordocInstructions ? `${INSTRUCTIONS}\n\n${kordocInstructions}` : INSTRUCTIONS }
65
+ );
66
+
67
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
68
+ await kordocReady;
69
+ return { tools: [fileeTool, ...kordocTools, statusTool] };
70
+ });
71
+
72
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
73
+ const { name, arguments: args } = request.params;
74
+ try {
75
+ if (name === fileeTool.name) return await callFilee(args, { signal: extra.signal });
76
+ if (name === statusTool.name) {
77
+ await kordocReady;
78
+ const filee = locateFilee();
79
+ return { content: [{ type: 'text', text: JSON.stringify({
80
+ hwpxBridge: pkg.version, node: process.version, platform: `${process.platform}-${process.arch}`,
81
+ kordoc: kordoc ? { version: info.version, tools: kordocTools.length } : { error: kordocError },
82
+ filee: filee.error ? { error: filee.error } : { version: filee.version, executable: filee.cli }
83
+ }, null, 2) }] };
84
+ }
85
+ await kordocReady;
86
+ if (!kordoc) throw new Error(kordocError ?? 'kordoc is not available.');
87
+ if (!kordocTools.some(tool => tool.name === name)) throw new Error(`Unknown tool: ${name}`);
88
+ return await kordoc.callTool({ name, arguments: args ?? {} }, undefined, {
89
+ signal: extra.signal, timeout: KORDOC_CALL_TIMEOUT, resetTimeoutOnProgress: true
90
+ });
91
+ } catch (error) {
92
+ return { isError: true, content: [{ type: 'text', text: error.message }] };
93
+ }
94
+ });
95
+
96
+ let closing = false;
97
+ const close = async () => {
98
+ if (closing) return;
99
+ closing = true;
100
+ await kordoc?.close().catch(() => {});
101
+ await server.close().catch(() => {});
102
+ process.exit(0);
103
+ };
104
+ process.on('SIGINT', close);
105
+ process.on('SIGTERM', close);
106
+ process.stdin.on('end', close);
107
+ await server.connect(new StdioServerTransport());
108
+ }
package/lib/setup.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // `npx -y hwpx-bridge setup` — register the MCP server in AI clients.
2
+ // Same approach as kordoc's src/setup.ts (MIT, chrisryugj): JSON config files are merged in place,
3
+ // Claude Code and Codex are registered through their own CLIs. Nothing else on the computer is changed.
4
+ import fs from 'node:fs/promises';
5
+ import fss from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import { createInterface } from 'node:readline/promises';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const run = promisify(execFile);
14
+ const require = createRequire(import.meta.url);
15
+ const pkg = require('../package.json');
16
+ const NAME = 'hwpx-bridge';
17
+ const isWin = process.platform === 'win32';
18
+
19
+ /** Pinned so a client never silently runs a newer (or hijacked) npm release. */
20
+ export function packageSpec(version = pkg.version) {
21
+ return /^\d+\.\d+\.\d+$/.test(version) ? `${NAME}@${version}` : NAME;
22
+ }
23
+
24
+ /** Claude Desktop on Windows cannot start npx.cmd directly, hence `cmd /c`. */
25
+ export function serverEntry() {
26
+ return isWin
27
+ ? { command: 'cmd', args: ['/c', 'npx', '-y', packageSpec(), 'mcp'] }
28
+ : { command: 'npx', args: ['-y', packageSpec(), 'mcp'] };
29
+ }
30
+
31
+ function claudeDesktopConfig() {
32
+ const home = os.homedir();
33
+ if (isWin) {
34
+ const appData = process.env.APPDATA ?? path.join(home, 'AppData/Roaming');
35
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(home, 'AppData/Local');
36
+ // The Microsoft Store build of Claude reads a package-local copy of %APPDATA%.
37
+ const store = path.join(localAppData, 'Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude');
38
+ return path.join(fss.existsSync(store) ? store : path.join(appData, 'Claude'), 'claude_desktop_config.json');
39
+ }
40
+ if (process.platform === 'darwin') return path.join(home, 'Library/Application Support/Claude/claude_desktop_config.json');
41
+ return path.join(home, '.config/Claude/claude_desktop_config.json');
42
+ }
43
+
44
+ export const CLIENTS = [
45
+ { id: 'claude-desktop', label: 'Claude 앱 (Claude Desktop)', kind: 'json', file: claudeDesktopConfig },
46
+ { id: 'claude-code', label: 'Claude Code (사용자 전체)', kind: 'cli', cli: 'claude' },
47
+ { id: 'codex', label: 'Codex (ChatGPT)', kind: 'cli', cli: 'codex' },
48
+ { id: 'cursor', label: 'Cursor', kind: 'json', file: () => path.join(os.homedir(), '.cursor/mcp.json') },
49
+ { id: 'gemini', label: 'Gemini CLI', kind: 'json', file: () => path.join(os.homedir(), '.gemini/settings.json') },
50
+ { id: 'windsurf', label: 'Windsurf', kind: 'json', file: () => path.join(os.homedir(), '.codeium/windsurf/mcp_config.json') }
51
+ ];
52
+
53
+ async function registerJson(file) {
54
+ let config = {};
55
+ if (fss.existsSync(file)) {
56
+ const raw = (await fs.readFile(file, 'utf8')).replace(/^/, '');
57
+ config = raw.trim() ? JSON.parse(raw) : {};
58
+ }
59
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) throw new Error(`설정 파일 형식이 올바르지 않아 그대로 두었습니다: ${file}`);
60
+ const entry = serverEntry();
61
+ if (JSON.stringify(config.mcpServers?.[NAME]) === JSON.stringify(entry)) return `이미 등록됨 (${file})`;
62
+ if (fss.existsSync(file)) await fs.copyFile(file, `${file}.bak-${Date.now()}`);
63
+ config.mcpServers = { ...(config.mcpServers ?? {}), [NAME]: entry };
64
+ await fs.mkdir(path.dirname(file), { recursive: true });
65
+ await fs.writeFile(file, JSON.stringify(config, null, 2) + '\n', 'utf8');
66
+ return `등록 완료 (${file})`;
67
+ }
68
+
69
+ async function registerCli(cli) {
70
+ const options = { windowsHide: true, timeout: 60_000, shell: isWin }; // claude/codex are .cmd shims on Windows
71
+ const entry = serverEntry();
72
+ const scope = cli === 'claude' ? ['--scope', 'user'] : [];
73
+ // Replace an earlier hwpx-bridge registration (e.g. an older pinned version).
74
+ await run(cli, ['mcp', 'remove', ...scope, NAME], options).catch(() => {});
75
+ await run(cli, ['mcp', 'add', ...scope, NAME, '--', entry.command, ...entry.args], options);
76
+ return `등록 완료 (${cli} mcp add)`;
77
+ }
78
+
79
+ async function choose() {
80
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
81
+ try {
82
+ console.log('\nhwpx-bridge를 등록할 AI 앱을 고르세요 (여러 개는 쉼표로, 예: 1,3)\n');
83
+ CLIENTS.forEach((client, i) => console.log(` ${i + 1}. ${client.label}`));
84
+ const answer = await rl.question('\n번호: ');
85
+ const picked = answer.split(/[,\s]+/).filter(Boolean).map(n => CLIENTS[Number(n) - 1]);
86
+ if (!picked.length || picked.some(c => !c)) throw new Error('올바른 번호를 입력하세요.');
87
+ return picked;
88
+ } finally { rl.close(); }
89
+ }
90
+
91
+ export async function runSetup(argv) {
92
+ const flag = argv.indexOf('--client');
93
+ let clients;
94
+ if (flag >= 0) {
95
+ const ids = (argv[flag + 1] ?? '').split(',').filter(Boolean);
96
+ clients = ids.map(id => CLIENTS.find(c => c.id === id));
97
+ if (!ids.length || clients.some(c => !c)) throw new Error(`--client 값: ${CLIENTS.map(c => c.id).join(', ')}`);
98
+ } else if (process.stdin.isTTY) clients = await choose();
99
+ else throw new Error(`대화형 터미널이 아닙니다. --client ${CLIENTS.map(c => c.id).join(',')} 중에서 지정하세요.`);
100
+
101
+ let failed = false;
102
+ for (const client of clients) {
103
+ try {
104
+ const message = client.kind === 'json' ? await registerJson(client.file()) : await registerCli(client.cli);
105
+ console.log(` + ${client.label}: ${message}`);
106
+ } catch (error) {
107
+ failed = true;
108
+ const hint = error.code === 'ENOENT' ? `${client.cli} 명령을 찾을 수 없습니다.` : error.message;
109
+ console.log(` x ${client.label}: ${hint}`);
110
+ }
111
+ }
112
+ console.log(`\n앱을 다시 시작하면 'hwpx-bridge' 도구가 나타납니다 (kordoc 도구 + filee_convert).`);
113
+ console.log(`직접 설정하려면: npx -y ${packageSpec()} config`);
114
+ if (failed) process.exitCode = 1;
115
+ }
116
+
117
+ export function printConfig() {
118
+ console.log(JSON.stringify({ mcpServers: { [NAME]: serverEntry() } }, null, 2));
119
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "hwpx-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "One MCP server for Korean documents: every kordoc tool (read/write/edit HWP·HWPX·PDF, OCR) plus Filee document conversion (DOCX→HWPX with formatting). One-line setup for Claude, Codex, Cursor and more.",
5
+ "type": "module",
6
+ "bin": { "hwpx-bridge": "bin/hwpx-bridge.mjs" },
7
+ "files": ["bin/", "lib/", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"],
8
+ "engines": { "node": ">=20" },
9
+ "dependencies": {
10
+ "@modelcontextprotocol/sdk": "1.32.2",
11
+ "kordoc": "4.24.62"
12
+ },
13
+ "optionalDependencies": {
14
+ "hwpx-bridge-filee-win32-x64": "0.3.1",
15
+ "hwpx-bridge-filee-linux-x64": "0.3.1"
16
+ },
17
+ "keywords": ["hwp", "hwpx", "docx", "mcp", "kordoc", "filee", "korean", "ocr", "claude", "codex"],
18
+ "license": "MIT",
19
+ "repository": { "type": "git", "url": "git+https://github.com/ThisIsMarkive/foryourdocument.git", "directory": "npm/hwpx-bridge" },
20
+ "homepage": "https://github.com/ThisIsMarkive/foryourdocument#readme"
21
+ }