@kimdayoun/hwpx-mcp 0.3.0 → 0.3.4

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.
@@ -1883,7 +1883,10 @@ class HwpxParser {
1883
1883
  // Get paragraph shape reference from the <hp:p> tag
1884
1884
  const paraShapeRefMatch = pTagAttrs.match(/paraPrIDRef="(\d+)"/);
1885
1885
  if (paraShapeRefMatch) {
1886
- const paraShape = this.styles.paraShapes.get(parseInt(paraShapeRefMatch[1]));
1886
+ // Keep the raw reference: callers assembling XML by hand need the numeric
1887
+ // ID, and resolving it to style values throws the ID away.
1888
+ paragraph.paraPrId = parseInt(paraShapeRefMatch[1]);
1889
+ const paraShape = this.styles.paraShapes.get(paragraph.paraPrId);
1887
1890
  if (paraShape) {
1888
1891
  paragraph.paraStyle = {
1889
1892
  align: paraShape.align,
@@ -1943,8 +1946,9 @@ class HwpxParser {
1943
1946
  const runs = [];
1944
1947
  let charStyle;
1945
1948
  const charShapeRefMatch = xml.match(/charPrIDRef="(\d+)"/);
1949
+ const charPrIDRef = charShapeRefMatch ? parseInt(charShapeRefMatch[1]) : undefined;
1946
1950
  if (charShapeRefMatch) {
1947
- const charShape = this.styles.charShapes.get(parseInt(charShapeRefMatch[1]));
1951
+ const charShape = this.styles.charShapes.get(charPrIDRef);
1948
1952
  if (charShape) {
1949
1953
  charStyle = {
1950
1954
  fontName: charShape.fontName,
@@ -2113,6 +2117,15 @@ class HwpxParser {
2113
2117
  charStyle: { ...charStyle, superscript: true, fontSize: charStyle?.fontSize ? charStyle.fontSize * 0.7 : 7 },
2114
2118
  });
2115
2119
  }
2120
+ // Attach the raw header.xml reference to every run produced here. Callers
2121
+ // that assemble XML directly need this ID; resolving it into style values
2122
+ // alone forces them back to regex-scraping section0.xml.
2123
+ if (charPrIDRef !== undefined) {
2124
+ for (const run of runs) {
2125
+ if (run.charPrIDRef === undefined)
2126
+ run.charPrIDRef = charPrIDRef;
2127
+ }
2128
+ }
2116
2129
  return runs;
2117
2130
  }
2118
2131
  static processTextContent(tContent, charStyle, runs, hyperlink, field) {
@@ -3106,6 +3119,9 @@ class HwpxParser {
3106
3119
  type: 'hr',
3107
3120
  data: {
3108
3121
  id: generateId(),
3122
+ // The paragraph stays in the XML; keep its id so id-based
3123
+ // anchors can count it (HwpxDocument.resolveElementAnchor).
3124
+ sourceParagraphId: el.data.id,
3109
3125
  width: 'full',
3110
3126
  height: 1,
3111
3127
  color: '#000000',
@@ -0,0 +1,5 @@
1
+ import type JSZip from 'jszip';
2
+ /** First well-formedness error in `xml`, or null when it parses. */
3
+ export declare function xmlWellFormednessError(xml: string): string | null;
4
+ /** Every `.xml` / `.hpf` part of the package that does not parse, as "path: error". */
5
+ export declare function findMalformedXmlParts(zip: JSZip): Promise<string[]>;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.xmlWellFormednessError = xmlWellFormednessError;
4
+ exports.findMalformedXmlParts = findMalformedXmlParts;
5
+ /**
6
+ * Well-formedness check for the XML parts of a saved HWPX package.
7
+ *
8
+ * save_document(verify_integrity) used to look for three textual symptoms
9
+ * (no `<?xml`, a dangling `<` at the end, `<` inside a tag). A section with a
10
+ * mismatched close tag passed all three and was reported as
11
+ * `integrity_verified: true`, while Hancom and every XML parser rejected it
12
+ * (reported 2026-09-24: `<hp:p>` 5730 open / 5728 close after one edit).
13
+ * A real parser is the only check that means what the flag claims.
14
+ *
15
+ * Measured on 275 Hancom-saved originals (2,046 XML parts, 168 MB): 0 false
16
+ * rejections, ~1.5 s total. The only rejected sample was a password-protected
17
+ * file, whose parts are ciphertext rather than XML.
18
+ */
19
+ const saxes_1 = require("saxes");
20
+ /** First well-formedness error in `xml`, or null when it parses. */
21
+ function xmlWellFormednessError(xml) {
22
+ // xmlns: true also rejects an undeclared prefix (<hs:sec> with only
23
+ // xmlns:hp declared). Hancom declares every prefix it uses (0 rejections
24
+ // across the 275-file corpus with this setting), while set_section_xml
25
+ // accepted a section missing xmlns:hs.
26
+ const parser = new saxes_1.SaxesParser({ xmlns: true });
27
+ let first = null;
28
+ parser.on('error', err => {
29
+ if (first === null)
30
+ first = err.message;
31
+ });
32
+ try {
33
+ parser.write(xml).close();
34
+ }
35
+ catch (err) {
36
+ first ?? (first = err instanceof Error ? err.message : String(err));
37
+ }
38
+ return first;
39
+ }
40
+ /** Every `.xml` / `.hpf` part of the package that does not parse, as "path: error". */
41
+ async function findMalformedXmlParts(zip) {
42
+ const out = [];
43
+ const names = Object.keys(zip.files)
44
+ .filter(name => !zip.files[name].dir && /\.(xml|hpf)$/i.test(name))
45
+ .sort();
46
+ for (const name of names) {
47
+ const err = xmlWellFormednessError(await zip.file(name).async('string'));
48
+ if (err)
49
+ out.push(`${name}: ${err}`);
50
+ }
51
+ return out;
52
+ }
package/dist/index.js CHANGED
@@ -41,8 +41,8 @@ const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const HwpxDocument_1 = require("./HwpxDocument");
43
43
  const HangingIndentCalculator_1 = require("./HangingIndentCalculator");
44
- // Version marker for debugging
45
- const MCP_VERSION = 'v2-fixed-xml-replacement';
44
+ const XmlWellFormed_1 = require("./XmlWellFormed");
45
+ const MCP_VERSION = require('../package.json').version;
46
46
  console.error(`[HWPX MCP] Server starting - ${MCP_VERSION} - ${new Date().toISOString()}`);
47
47
  // Document storage
48
48
  const openDocuments = new Map();
@@ -105,11 +105,16 @@ Example: get_tool_guide({ workflow: "template" })`,
105
105
  properties: {
106
106
  workflow: {
107
107
  type: 'string',
108
- description: 'Workflow type: template, table, image, search, read, create, or all',
108
+ description: 'Workflow type: template, table, image, search, read, create, or all. Also accepted as topic.',
109
+ enum: ['template', 'table', 'image', 'search', 'read', 'create', 'all']
110
+ },
111
+ topic: {
112
+ type: 'string',
113
+ description: 'Alias for workflow.',
109
114
  enum: ['template', 'table', 'image', 'search', 'read', 'create', 'all']
110
115
  },
111
116
  },
112
- required: ['workflow'],
117
+ anyOf: [{ required: ['workflow'] }, { required: ['topic'] }],
113
118
  },
114
119
  },
115
120
  // === Document Management ===
@@ -142,7 +147,7 @@ Example: get_tool_guide({ workflow: "template" })`,
142
147
  type: 'object',
143
148
  properties: {
144
149
  doc_id: { type: 'string', description: 'Document ID' },
145
- output_path: { type: 'string', description: 'Output path (optional, saves to original if omitted)' },
150
+ output_path: { type: 'string', description: 'Absolute or relative output path. Required for documents from create_document that were not given a file_path. Also accepted as file_path.' },
146
151
  create_backup: { type: 'boolean', description: 'Create .bak backup before saving (default: true)' },
147
152
  verify_integrity: { type: 'boolean', description: 'Verify saved file integrity (default: true)' },
148
153
  },
@@ -630,14 +635,20 @@ When NOT to use:
630
635
  description: `⭐ RECOMMENDED for finding tables. Returns ALL tables with their headers and metadata.
631
636
 
632
637
  Returns for each table:
633
- - table_index: Global index (use this for other table operations)
638
+ - section_index + table_index_in_section: pass BOTH to tools that take section_index
639
+ (update_table_cell, get_table_cell, get_table, insert_table_row, insert_table_column,
640
+ merge_cells, insert_nested_table, …)
641
+ - table_index: position across the whole document. ONLY for tools that take no
642
+ section_index (get_cell_context, batch_fill_table, insert_image_in_cell,
643
+ render_mermaid_in_cell, insert_paragraph after_table)
634
644
  - header: Text from the paragraph BEFORE the table (usually the table title)
635
645
  - size: rows × cols
636
646
  - is_empty: Whether table has content
637
647
  - first_row_preview: Preview of first row data
638
648
 
639
- Use this FIRST when working with tables, then use the table_index for:
640
- - get_table, update_table_cell, insert_image_in_cell, etc.
649
+ In a document with one section both indices are equal. With a cover section plus
650
+ a body section they differ: passing table_index to update_table_cell writes to a
651
+ DIFFERENT table (or fails) — use table_index_in_section there.
641
652
 
642
653
  Alternative tools:
643
654
  - find_table_by_header: Search by header text
@@ -1894,12 +1905,13 @@ Positioning within cell:
1894
1905
  // === New Document Creation ===
1895
1906
  {
1896
1907
  name: 'create_document',
1897
- description: 'Create a new empty HWPX document',
1908
+ description: 'Create a new empty HWPX document. Pass file_path to fix where save_document will write it; otherwise you must pass output_path to save_document.',
1898
1909
  inputSchema: {
1899
1910
  type: 'object',
1900
1911
  properties: {
1901
1912
  title: { type: 'string', description: 'Document title (optional)' },
1902
1913
  creator: { type: 'string', description: 'Document author (optional)' },
1914
+ file_path: { type: 'string', description: 'Destination path for later saves (optional). The file is written on save_document, not here.' },
1903
1915
  },
1904
1916
  },
1905
1917
  },
@@ -2129,7 +2141,7 @@ Call this after modifying the document to ensure fresh data on next read operati
2129
2141
  // ============================================================
2130
2142
  const server = new index_js_1.Server({
2131
2143
  name: 'hwpx-mcp-server',
2132
- version: '0.3.0',
2144
+ version: MCP_VERSION,
2133
2145
  }, {
2134
2146
  capabilities: {
2135
2147
  tools: {},
@@ -2137,15 +2149,40 @@ const server = new index_js_1.Server({
2137
2149
  });
2138
2150
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools }));
2139
2151
  // ============================================================
2152
+ // Required-argument validation
2153
+ // ============================================================
2154
+ const requiredArgsByTool = new Map(tools.map(tool => [
2155
+ tool.name,
2156
+ (tool.inputSchema?.required) ?? [],
2157
+ ]));
2158
+ /**
2159
+ * Report the exact missing arguments instead of letting the handler fail with a
2160
+ * generic message. `section_index` is declared required on the insert/update
2161
+ * tools, but omitting it used to surface as "Failed to insert paragraph", which
2162
+ * reads like document corruption and sends callers off inspecting the file.
2163
+ */
2164
+ function findMissingArgs(toolName, args) {
2165
+ const required = requiredArgsByTool.get(toolName);
2166
+ if (!required || required.length === 0)
2167
+ return [];
2168
+ return required.filter(key => args?.[key] === undefined || args?.[key] === null);
2169
+ }
2170
+ // ============================================================
2140
2171
  // Tool Handlers
2141
2172
  // ============================================================
2142
2173
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
2143
2174
  const { name, arguments: args } = request.params;
2175
+ const missing = findMissingArgs(name, args);
2176
+ if (missing.length > 0) {
2177
+ return error(`Missing required argument${missing.length > 1 ? 's' : ''} for ${name}: ${missing.join(', ')}`);
2178
+ }
2144
2179
  try {
2145
2180
  switch (name) {
2146
2181
  // === 🎯 Tool Guide ===
2147
2182
  case 'get_tool_guide': {
2148
- const workflow = args?.workflow;
2183
+ // `topic` is the name callers reach for first; accept both rather than
2184
+ // silently returning the same full reference for every request.
2185
+ const workflow = args?.workflow ?? args?.topic;
2149
2186
  const guides = {
2150
2187
  template: `📋 TEMPLATE/FORM WORKFLOW (양식 작업)
2151
2188
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -2290,8 +2327,12 @@ For best results, start with a template file instead.`,
2290
2327
  💡 WORKFLOW GUIDES:
2291
2328
  Call get_tool_guide with: template, table, image, search, read, create`
2292
2329
  };
2293
- const guide = guides[workflow] || guides['all'];
2294
- return success({ workflow, guide });
2330
+ const known = Object.keys(guides);
2331
+ const guide = guides[workflow];
2332
+ if (!guide) {
2333
+ return error(`Unknown workflow "${workflow}". Available: ${known.join(', ')}`);
2334
+ }
2335
+ return success({ workflow, available_workflows: known, guide });
2295
2336
  }
2296
2337
  // === Document Management ===
2297
2338
  case 'open_document': {
@@ -2327,25 +2368,40 @@ Call get_tool_guide with: template, table, image, search, read, create`
2327
2368
  return error('HWP files are read-only');
2328
2369
  // Use document lock to ensure all pending updates complete before save
2329
2370
  return await withDocumentLock(docId, async () => {
2330
- const savePath = args?.output_path || doc.path;
2371
+ // `file_path` is the parameter name used by open_document/create_document,
2372
+ // so callers reach for it here too. Accept it rather than silently
2373
+ // falling back to the document's own path.
2374
+ const requestedPath = args?.output_path || args?.file_path;
2375
+ if (!requestedPath && !doc.hasPath) {
2376
+ return error('output_path is required for a document created with create_document; ' +
2377
+ 'it has no location on disk yet');
2378
+ }
2379
+ const savePath = path.resolve(requestedPath || doc.path);
2331
2380
  const createBackup = args?.create_backup !== false; // default: true
2332
2381
  const verifyIntegrity = args?.verify_integrity !== false; // default: true
2333
2382
  let backupPath = null;
2334
- const tempPath = savePath + '.tmp';
2335
- // Create backup if file exists and backup is enabled
2336
- if (createBackup && fs.existsSync(savePath)) {
2337
- backupPath = savePath + '.bak';
2338
- try {
2339
- fs.copyFileSync(savePath, backupPath);
2340
- }
2341
- catch (backupErr) {
2342
- return error(`Failed to create backup: ${backupErr}`);
2343
- }
2383
+ const saveDirectory = path.dirname(savePath);
2384
+ if (!fs.existsSync(saveDirectory)) {
2385
+ return error(`Directory does not exist: ${saveDirectory}`);
2344
2386
  }
2387
+ // A private directory prevents pre-created .tmp symlinks from redirecting writes.
2388
+ const tempDirectory = fs.mkdtempSync(path.join(saveDirectory, '.hwpx-save-'));
2389
+ const tempPath = path.join(tempDirectory, 'document.hwpx');
2345
2390
  try {
2391
+ if (createBackup && fs.existsSync(savePath)) {
2392
+ backupPath = savePath + '.bak';
2393
+ const existingBackup = fs.lstatSync(backupPath, { throwIfNoEntry: false });
2394
+ if (existingBackup && !existingBackup.isFile()) {
2395
+ return error('Backup destination must be a regular file');
2396
+ }
2397
+ const stagedBackup = path.join(tempDirectory, 'backup.hwpx');
2398
+ fs.copyFileSync(savePath, stagedBackup, fs.constants.COPYFILE_EXCL);
2399
+ // Rename replaces the directory entry instead of following a destination symlink.
2400
+ fs.renameSync(stagedBackup, backupPath);
2401
+ }
2346
2402
  const data = await doc.save();
2347
2403
  // Phase 1: Write to temp file first (atomic write pattern)
2348
- fs.writeFileSync(tempPath, data);
2404
+ fs.writeFileSync(tempPath, data, { flag: 'wx', mode: 0o600 });
2349
2405
  // Verify integrity on temp file before moving
2350
2406
  if (verifyIntegrity) {
2351
2407
  try {
@@ -2368,24 +2424,13 @@ Call get_tool_guide with: template, table, image, search, read, create`
2368
2424
  if (missingFiles.length > 0) {
2369
2425
  throw new Error(`Missing required files: ${missingFiles.join(', ')}`);
2370
2426
  }
2371
- // Verify all section XML files are valid
2372
- const sectionFiles = Object.keys(zip.files).filter(f => f.match(/^Contents\/section\d+\.xml$/));
2373
- for (const sectionFile of sectionFiles) {
2374
- const file = zip.file(sectionFile);
2375
- if (file) {
2376
- const xmlContent = await file.async('string');
2377
- if (!xmlContent || !xmlContent.includes('<?xml')) {
2378
- throw new Error(`Invalid XML in ${sectionFile}`);
2379
- }
2380
- // Check for truncated XML (incomplete tag at end)
2381
- if (xmlContent.match(/<[^>]*$/)) {
2382
- throw new Error(`Truncated XML in ${sectionFile}`);
2383
- }
2384
- // Check for broken opening tags (< followed by < without >)
2385
- if (xmlContent.match(/<[^>]*</)) {
2386
- throw new Error(`Broken tag structure in ${sectionFile}`);
2387
- }
2388
- }
2427
+ // Every XML part must actually parse. The old textual checks
2428
+ // (<?xml present, no dangling '<') passed a section with a
2429
+ // mismatched close tag, and the save reported
2430
+ // integrity_verified: true for a file Hancom cannot open.
2431
+ const malformed = await (0, XmlWellFormed_1.findMalformedXmlParts)(zip);
2432
+ if (malformed.length > 0) {
2433
+ throw new Error(`Malformed XML: ${malformed.slice(0, 3).join('; ')}`);
2389
2434
  }
2390
2435
  }
2391
2436
  catch (verifyErr) {
@@ -2400,31 +2445,22 @@ Call get_tool_guide with: template, table, image, search, read, create`
2400
2445
  return error(`Save verification failed: ${verifyErr}`);
2401
2446
  }
2402
2447
  }
2403
- // Phase 2: Atomic move - rename temp to final (atomic on same filesystem)
2404
- if (fs.existsSync(savePath)) {
2405
- fs.unlinkSync(savePath);
2406
- }
2448
+ // Do not unlink first: a failed rename must leave the original document intact.
2407
2449
  fs.renameSync(tempPath, savePath);
2450
+ doc.setPath(savePath);
2408
2451
  return success({
2409
2452
  message: `Saved to ${savePath}`,
2453
+ path: savePath,
2410
2454
  backup_created: backupPath ? true : false,
2455
+ backup_path: backupPath,
2411
2456
  integrity_verified: verifyIntegrity
2412
2457
  });
2413
2458
  }
2414
2459
  catch (saveErr) {
2415
- // Clean up temp file if exists
2416
- if (fs.existsSync(tempPath)) {
2417
- try {
2418
- fs.unlinkSync(tempPath);
2419
- }
2420
- catch { }
2421
- }
2422
- // Restore from backup if save fails
2423
- if (backupPath && fs.existsSync(backupPath)) {
2424
- fs.copyFileSync(backupPath, savePath);
2425
- return error(`Save failed, restored from backup: ${saveErr}`);
2426
- }
2427
- return error(`Save failed: ${saveErr}`);
2460
+ return error(`Save failed; original document preserved: ${saveErr}`);
2461
+ }
2462
+ finally {
2463
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
2428
2464
  }
2429
2465
  });
2430
2466
  }
@@ -4003,11 +4039,26 @@ Call get_tool_guide with: template, table, image, search, read, create`
4003
4039
  case 'create_document': {
4004
4040
  const docId = generateId();
4005
4041
  const doc = HwpxDocument_1.HwpxDocument.createNew(docId, args?.title, args?.creator);
4042
+ // Remember the intended destination so a later save_document without an
4043
+ // explicit path writes where the caller asked, not into the server cwd.
4044
+ const requestedPath = args?.file_path || args?.output_path;
4045
+ let plannedPath = null;
4046
+ if (requestedPath) {
4047
+ plannedPath = path.resolve(requestedPath);
4048
+ const parentDirectory = path.dirname(plannedPath);
4049
+ if (!fs.existsSync(parentDirectory)) {
4050
+ return error(`Directory does not exist: ${parentDirectory}`);
4051
+ }
4052
+ doc.setPath(plannedPath);
4053
+ }
4006
4054
  openDocuments.set(docId, doc);
4007
4055
  return success({
4008
4056
  doc_id: docId,
4009
4057
  format: 'hwpx',
4010
- message: 'New document created',
4058
+ path: plannedPath,
4059
+ message: plannedPath
4060
+ ? `New document created; save_document will write to ${plannedPath}`
4061
+ : 'New document created; pass output_path to save_document to choose where it is written',
4011
4062
  });
4012
4063
  }
4013
4064
  // === XML Analysis and Repair ===
@@ -4297,7 +4348,11 @@ function success(data) {
4297
4348
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
4298
4349
  }
4299
4350
  function error(message) {
4300
- return { content: [{ type: 'text', text: JSON.stringify({ error: message }) }] };
4351
+ // isError tells the MCP client the call failed. Without it, a missing
4352
+ // argument or a refused write came back as a normal result whose body merely
4353
+ // contained {"error": …}, and agents treated it as success (reported
4354
+ // 2026-09-24). The JSON body is kept for clients that read it.
4355
+ return { content: [{ type: 'text', text: JSON.stringify({ error: message }) }], isError: true };
4301
4356
  }
4302
4357
  function escapeHtml(text) {
4303
4358
  return text
package/dist/types.d.ts CHANGED
@@ -997,6 +997,11 @@ export interface HwpxTextBox {
997
997
  }
998
998
  export interface HwpxHorizontalRule {
999
999
  id: string;
1000
+ /**
1001
+ * Id of the <hp:p> this rule was parsed from. The paragraph is still in the
1002
+ * section XML, so id-based insert anchors must count it.
1003
+ */
1004
+ sourceParagraphId?: string;
1000
1005
  width: number | 'full';
1001
1006
  height: number;
1002
1007
  color?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kimdayoun/hwpx-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.4",
4
4
  "description": "한글 문서(HWPX)를 읽고 쓰는 MCP 서버 — 125개 도구. 문단·표·스타일·이미지·머리말/꼬리말까지 XML 수준으로 편집한다. MCP Server for Korean HWPX documents.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "CHANGELOG.md",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
@@ -49,13 +50,20 @@
49
50
  "prepublishOnly": "npm run build",
50
51
  "start": "node dist/index.js",
51
52
  "test": "vitest run",
53
+ "test:unit": "vitest run src tests/unit",
54
+ "test:module": "vitest run tests/module",
55
+ "test:regression": "vitest run tests/regression",
56
+ "test:e2e": "npm run build && vitest run tests/e2e",
57
+ "test:versions": "node scripts/version-matrix.mjs",
58
+ "test:security": "npm run build && node --test test-save-security.mjs",
52
59
  "test:watch": "vitest"
53
60
  },
54
61
  "dependencies": {
55
62
  "@modelcontextprotocol/sdk": "^1.0.0",
56
63
  "hwp.js": "^0.0.3",
57
64
  "jszip": "^3.10.1",
58
- "pako": "^2.1.0"
65
+ "pako": "^2.1.0",
66
+ "saxes": "^6.0.0"
59
67
  },
60
68
  "devDependencies": {
61
69
  "@types/node": "^20.0.0",