@kimdayoun/hwpx-mcp 0.3.0 → 0.3.3

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',
package/dist/index.js CHANGED
@@ -41,8 +41,7 @@ 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 MCP_VERSION = require('../package.json').version;
46
45
  console.error(`[HWPX MCP] Server starting - ${MCP_VERSION} - ${new Date().toISOString()}`);
47
46
  // Document storage
48
47
  const openDocuments = new Map();
@@ -105,11 +104,16 @@ Example: get_tool_guide({ workflow: "template" })`,
105
104
  properties: {
106
105
  workflow: {
107
106
  type: 'string',
108
- description: 'Workflow type: template, table, image, search, read, create, or all',
107
+ description: 'Workflow type: template, table, image, search, read, create, or all. Also accepted as topic.',
108
+ enum: ['template', 'table', 'image', 'search', 'read', 'create', 'all']
109
+ },
110
+ topic: {
111
+ type: 'string',
112
+ description: 'Alias for workflow.',
109
113
  enum: ['template', 'table', 'image', 'search', 'read', 'create', 'all']
110
114
  },
111
115
  },
112
- required: ['workflow'],
116
+ anyOf: [{ required: ['workflow'] }, { required: ['topic'] }],
113
117
  },
114
118
  },
115
119
  // === Document Management ===
@@ -142,7 +146,7 @@ Example: get_tool_guide({ workflow: "template" })`,
142
146
  type: 'object',
143
147
  properties: {
144
148
  doc_id: { type: 'string', description: 'Document ID' },
145
- output_path: { type: 'string', description: 'Output path (optional, saves to original if omitted)' },
149
+ 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
150
  create_backup: { type: 'boolean', description: 'Create .bak backup before saving (default: true)' },
147
151
  verify_integrity: { type: 'boolean', description: 'Verify saved file integrity (default: true)' },
148
152
  },
@@ -1894,12 +1898,13 @@ Positioning within cell:
1894
1898
  // === New Document Creation ===
1895
1899
  {
1896
1900
  name: 'create_document',
1897
- description: 'Create a new empty HWPX document',
1901
+ 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
1902
  inputSchema: {
1899
1903
  type: 'object',
1900
1904
  properties: {
1901
1905
  title: { type: 'string', description: 'Document title (optional)' },
1902
1906
  creator: { type: 'string', description: 'Document author (optional)' },
1907
+ file_path: { type: 'string', description: 'Destination path for later saves (optional). The file is written on save_document, not here.' },
1903
1908
  },
1904
1909
  },
1905
1910
  },
@@ -2129,7 +2134,7 @@ Call this after modifying the document to ensure fresh data on next read operati
2129
2134
  // ============================================================
2130
2135
  const server = new index_js_1.Server({
2131
2136
  name: 'hwpx-mcp-server',
2132
- version: '0.3.0',
2137
+ version: MCP_VERSION,
2133
2138
  }, {
2134
2139
  capabilities: {
2135
2140
  tools: {},
@@ -2137,15 +2142,40 @@ const server = new index_js_1.Server({
2137
2142
  });
2138
2143
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools }));
2139
2144
  // ============================================================
2145
+ // Required-argument validation
2146
+ // ============================================================
2147
+ const requiredArgsByTool = new Map(tools.map(tool => [
2148
+ tool.name,
2149
+ (tool.inputSchema?.required) ?? [],
2150
+ ]));
2151
+ /**
2152
+ * Report the exact missing arguments instead of letting the handler fail with a
2153
+ * generic message. `section_index` is declared required on the insert/update
2154
+ * tools, but omitting it used to surface as "Failed to insert paragraph", which
2155
+ * reads like document corruption and sends callers off inspecting the file.
2156
+ */
2157
+ function findMissingArgs(toolName, args) {
2158
+ const required = requiredArgsByTool.get(toolName);
2159
+ if (!required || required.length === 0)
2160
+ return [];
2161
+ return required.filter(key => args?.[key] === undefined || args?.[key] === null);
2162
+ }
2163
+ // ============================================================
2140
2164
  // Tool Handlers
2141
2165
  // ============================================================
2142
2166
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
2143
2167
  const { name, arguments: args } = request.params;
2168
+ const missing = findMissingArgs(name, args);
2169
+ if (missing.length > 0) {
2170
+ return error(`Missing required argument${missing.length > 1 ? 's' : ''} for ${name}: ${missing.join(', ')}`);
2171
+ }
2144
2172
  try {
2145
2173
  switch (name) {
2146
2174
  // === 🎯 Tool Guide ===
2147
2175
  case 'get_tool_guide': {
2148
- const workflow = args?.workflow;
2176
+ // `topic` is the name callers reach for first; accept both rather than
2177
+ // silently returning the same full reference for every request.
2178
+ const workflow = args?.workflow ?? args?.topic;
2149
2179
  const guides = {
2150
2180
  template: `📋 TEMPLATE/FORM WORKFLOW (양식 작업)
2151
2181
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -2290,8 +2320,12 @@ For best results, start with a template file instead.`,
2290
2320
  💡 WORKFLOW GUIDES:
2291
2321
  Call get_tool_guide with: template, table, image, search, read, create`
2292
2322
  };
2293
- const guide = guides[workflow] || guides['all'];
2294
- return success({ workflow, guide });
2323
+ const known = Object.keys(guides);
2324
+ const guide = guides[workflow];
2325
+ if (!guide) {
2326
+ return error(`Unknown workflow "${workflow}". Available: ${known.join(', ')}`);
2327
+ }
2328
+ return success({ workflow, available_workflows: known, guide });
2295
2329
  }
2296
2330
  // === Document Management ===
2297
2331
  case 'open_document': {
@@ -2327,25 +2361,40 @@ Call get_tool_guide with: template, table, image, search, read, create`
2327
2361
  return error('HWP files are read-only');
2328
2362
  // Use document lock to ensure all pending updates complete before save
2329
2363
  return await withDocumentLock(docId, async () => {
2330
- const savePath = args?.output_path || doc.path;
2364
+ // `file_path` is the parameter name used by open_document/create_document,
2365
+ // so callers reach for it here too. Accept it rather than silently
2366
+ // falling back to the document's own path.
2367
+ const requestedPath = args?.output_path || args?.file_path;
2368
+ if (!requestedPath && !doc.hasPath) {
2369
+ return error('output_path is required for a document created with create_document; ' +
2370
+ 'it has no location on disk yet');
2371
+ }
2372
+ const savePath = path.resolve(requestedPath || doc.path);
2331
2373
  const createBackup = args?.create_backup !== false; // default: true
2332
2374
  const verifyIntegrity = args?.verify_integrity !== false; // default: true
2333
2375
  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
- }
2376
+ const saveDirectory = path.dirname(savePath);
2377
+ if (!fs.existsSync(saveDirectory)) {
2378
+ return error(`Directory does not exist: ${saveDirectory}`);
2344
2379
  }
2380
+ // A private directory prevents pre-created .tmp symlinks from redirecting writes.
2381
+ const tempDirectory = fs.mkdtempSync(path.join(saveDirectory, '.hwpx-save-'));
2382
+ const tempPath = path.join(tempDirectory, 'document.hwpx');
2345
2383
  try {
2384
+ if (createBackup && fs.existsSync(savePath)) {
2385
+ backupPath = savePath + '.bak';
2386
+ const existingBackup = fs.lstatSync(backupPath, { throwIfNoEntry: false });
2387
+ if (existingBackup && !existingBackup.isFile()) {
2388
+ return error('Backup destination must be a regular file');
2389
+ }
2390
+ const stagedBackup = path.join(tempDirectory, 'backup.hwpx');
2391
+ fs.copyFileSync(savePath, stagedBackup, fs.constants.COPYFILE_EXCL);
2392
+ // Rename replaces the directory entry instead of following a destination symlink.
2393
+ fs.renameSync(stagedBackup, backupPath);
2394
+ }
2346
2395
  const data = await doc.save();
2347
2396
  // Phase 1: Write to temp file first (atomic write pattern)
2348
- fs.writeFileSync(tempPath, data);
2397
+ fs.writeFileSync(tempPath, data, { flag: 'wx', mode: 0o600 });
2349
2398
  // Verify integrity on temp file before moving
2350
2399
  if (verifyIntegrity) {
2351
2400
  try {
@@ -2400,31 +2449,22 @@ Call get_tool_guide with: template, table, image, search, read, create`
2400
2449
  return error(`Save verification failed: ${verifyErr}`);
2401
2450
  }
2402
2451
  }
2403
- // Phase 2: Atomic move - rename temp to final (atomic on same filesystem)
2404
- if (fs.existsSync(savePath)) {
2405
- fs.unlinkSync(savePath);
2406
- }
2452
+ // Do not unlink first: a failed rename must leave the original document intact.
2407
2453
  fs.renameSync(tempPath, savePath);
2454
+ doc.setPath(savePath);
2408
2455
  return success({
2409
2456
  message: `Saved to ${savePath}`,
2457
+ path: savePath,
2410
2458
  backup_created: backupPath ? true : false,
2459
+ backup_path: backupPath,
2411
2460
  integrity_verified: verifyIntegrity
2412
2461
  });
2413
2462
  }
2414
2463
  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}`);
2464
+ return error(`Save failed; original document preserved: ${saveErr}`);
2465
+ }
2466
+ finally {
2467
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
2428
2468
  }
2429
2469
  });
2430
2470
  }
@@ -4003,11 +4043,26 @@ Call get_tool_guide with: template, table, image, search, read, create`
4003
4043
  case 'create_document': {
4004
4044
  const docId = generateId();
4005
4045
  const doc = HwpxDocument_1.HwpxDocument.createNew(docId, args?.title, args?.creator);
4046
+ // Remember the intended destination so a later save_document without an
4047
+ // explicit path writes where the caller asked, not into the server cwd.
4048
+ const requestedPath = args?.file_path || args?.output_path;
4049
+ let plannedPath = null;
4050
+ if (requestedPath) {
4051
+ plannedPath = path.resolve(requestedPath);
4052
+ const parentDirectory = path.dirname(plannedPath);
4053
+ if (!fs.existsSync(parentDirectory)) {
4054
+ return error(`Directory does not exist: ${parentDirectory}`);
4055
+ }
4056
+ doc.setPath(plannedPath);
4057
+ }
4006
4058
  openDocuments.set(docId, doc);
4007
4059
  return success({
4008
4060
  doc_id: docId,
4009
4061
  format: 'hwpx',
4010
- message: 'New document created',
4062
+ path: plannedPath,
4063
+ message: plannedPath
4064
+ ? `New document created; save_document will write to ${plannedPath}`
4065
+ : 'New document created; pass output_path to save_document to choose where it is written',
4011
4066
  });
4012
4067
  }
4013
4068
  // === XML Analysis and Repair ===
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.3",
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,6 +50,7 @@
49
50
  "prepublishOnly": "npm run build",
50
51
  "start": "node dist/index.js",
51
52
  "test": "vitest run",
53
+ "test:security": "npm run build && node --test test-save-security.mjs",
52
54
  "test:watch": "vitest"
53
55
  },
54
56
  "dependencies": {