@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.
- package/CHANGELOG.md +148 -0
- package/README.md +17 -46
- package/dist/HangingIndentCalculator.js +136 -166
- package/dist/HwpxDocument.d.ts +152 -12
- package/dist/HwpxDocument.js +780 -444
- package/dist/HwpxParser.js +18 -2
- package/dist/index.js +95 -40
- package/dist/types.d.ts +5 -0
- package/package.json +3 -1
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',
|
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
|
-
|
|
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: '
|
|
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:
|
|
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
|
-
|
|
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
|
|
2294
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
-
|
|
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}`);
|
|
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
|
-
|
|
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.
|
|
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": {
|