@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.
- package/CHANGELOG.md +201 -0
- package/README.md +30 -49
- package/dist/HangingIndentCalculator.js +136 -166
- package/dist/HwpxDocument.d.ts +275 -12
- package/dist/HwpxDocument.js +1589 -627
- package/dist/HwpxParser.js +18 -2
- package/dist/XmlWellFormed.d.ts +5 -0
- package/dist/XmlWellFormed.js +52 -0
- package/dist/index.js +117 -62
- package/dist/types.d.ts +5 -0
- package/package.json +10 -2
package/dist/HwpxParser.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
45
|
-
const MCP_VERSION = '
|
|
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: '
|
|
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
|
-
-
|
|
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
|
-
|
|
640
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
2294
|
-
|
|
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
|
-
|
|
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
|
|
2335
|
-
|
|
2336
|
-
|
|
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
|
-
//
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|