feat: add generate_docx sandbox MCP tool for public Word downloads

Expose generate_docx in mindspace-sandbox-mcp so agents can write public/*.docx
before linking HTML download pages, with tests mirroring the Mark summary flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-04 00:18:43 +08:00
parent 702871a98c
commit 52c7082c70
5 changed files with 232 additions and 2 deletions
+1 -1
View File
@@ -233,7 +233,7 @@ export function sandboxDeveloperTools(capabilities) {
export function sandboxMcpTools(capabilities) {
const tools = [];
if (capabilities.static_publish) {
tools.push('read_file', 'write_file', 'edit_file', 'create_dir', 'generate_long_image');
tools.push('read_file', 'write_file', 'edit_file', 'create_dir', 'generate_docx', 'generate_long_image');
if (capabilities.shell || capabilities.code_browse) tools.push('list_dir');
}
if (capabilities.private_data_space) {
+2
View File
@@ -194,6 +194,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
'write_file',
'edit_file',
'create_dir',
'generate_docx',
'generate_long_image',
'private_data_info',
'private_data_schema',
@@ -257,6 +258,7 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2]
assert.ok(sandboxExt.available_tools.includes('write_file'));
assert.ok(sandboxExt.available_tools.includes('read_file'));
assert.ok(sandboxExt.available_tools.includes('generate_docx'));
assert.ok(sandboxExt.available_tools.includes('generate_long_image'));
// built-in developer extension should only remain for read_image (image_read: true by default)
+103
View File
@@ -11,6 +11,7 @@
import path from 'node:path';
import fs from 'node:fs';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
@@ -24,6 +25,7 @@ if (!SANDBOX_ROOT) {
}
const SANDBOX = path.resolve(SANDBOX_ROOT);
const SANDBOX_MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace');
const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite');
const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3';
@@ -48,6 +50,70 @@ function resolveSandboxed(p) {
return resolved;
}
function resolveDocxGenerateScript() {
const candidates = [
path.join(SANDBOX, '.agents', 'skills', 'docx-generate', 'generate_docx.py'),
path.join(SANDBOX_MODULE_DIR, 'skills', 'docx-generate', 'generate_docx.py'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
}
throw new Error(
'generate_docx: 未找到 generate_docx.py,请先 load_skill → docx-generate 同步技能到工作区',
);
}
function normalizeDocxSections(sections) {
if (!Array.isArray(sections) || sections.length === 0) {
throw new Error('generate_docx: sections 必须是非空数组');
}
return sections.map((section, index) => {
if (!section || typeof section !== 'object') {
throw new Error(`generate_docx: sections[${index}] 必须是对象`);
}
const normalized = {
heading: section.heading != null ? String(section.heading) : '',
paragraphs: Array.isArray(section.paragraphs)
? section.paragraphs.map((paragraph) => String(paragraph))
: [],
};
if (section.table && typeof section.table === 'object') {
normalized.table = section.table;
}
return normalized;
});
}
function runGenerateDocxScript({ outputPath, title, sections }) {
const script = resolveDocxGenerateScript();
const python = process.env.PYTHON_BIN?.trim() || 'python3';
const payload = JSON.stringify({
title: String(title ?? ''),
sections: normalizeDocxSections(sections),
});
const stdout = execFileSync(
python,
[script, '--json', '-', '--output', outputPath],
{
cwd: SANDBOX,
input: payload,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
},
);
const abs = resolveSandboxed(outputPath);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
throw new Error(`generate_docx: 脚本执行后未找到 ${outputPath}`);
}
const size = fs.statSync(abs).size;
if (size < 64) {
throw new Error(`generate_docx: ${outputPath} 体积异常(${size} 字节)`);
}
return { bytes: size, stdout: String(stdout ?? '').trim() };
}
const ALL_TOOLS = [
{
name: 'read_file',
@@ -122,6 +188,24 @@ const ALL_TOOLS = [
required: ['html_path'],
},
},
{
name: 'generate_docx',
description:
'生成 Word .docx 文件并落盘到工作区(如 public/报告.docx)。公网下载页必须先调用本工具确认文件存在,再写 HTML 相对链接。',
inputSchema: {
type: 'object',
properties: {
output_path: { type: 'string', description: '输出 .docx 路径,如 public/协和智慧门诊研究摘要.docx' },
title: { type: 'string', description: '文档主标题' },
sections: {
type: 'array',
description: '章节数组;每项可含 heading、paragraphs、可选 table(headers/rows)',
items: { type: 'object' },
},
},
required: ['output_path', 'title', 'sections'],
},
},
{
name: 'private_data_info',
description:
@@ -491,6 +575,25 @@ async function callTool(name, args) {
},
];
}
case 'generate_docx': {
const outputPath = String(args.output_path ?? args.path ?? '').trim();
if (!outputPath.toLowerCase().endsWith('.docx')) {
throw new Error('generate_docx: output_path 必须是 .docx 文件');
}
resolveSandboxed(outputPath);
const title = String(args.title ?? '').trim();
if (!title) {
throw new Error('generate_docx: title 不能为空');
}
const sections = args.sections ?? args.payload?.sections;
const result = runGenerateDocxScript({ outputPath, title, sections });
return [
{
type: 'text',
text: `已生成 ${outputPath}${result.bytes} 字节)`,
},
];
}
case 'private_data_info': {
ensurePrivateDataDb();
const size = privateDataSize();
+115
View File
@@ -4,6 +4,49 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { syncPublicDocxDownloads } from './mindspace-public-finish-sync.mjs';
function copyDocxGenerateSkill(root) {
const srcDir = path.join(process.cwd(), 'skills', 'docx-generate');
const destDir = path.join(root, '.agents', 'skills', 'docx-generate');
fs.mkdirSync(destDir, { recursive: true });
for (const name of ['generate_docx.py', 'SKILL.md']) {
fs.copyFileSync(path.join(srcDir, name), path.join(destDir, name));
}
}
function summarySections(caseName) {
return [
{
heading: '一、项目概述',
paragraphs: [
`${caseName}项目面向医疗服务流程中的核心痛点,通过数字化与 AI 能力重构诊前、诊中、诊后体验。`,
'本摘要基于创新三角评估框架,从创新、成果、市场、团队四个维度提炼关键结论。',
],
},
{
heading: '二、解决方案与技术创新',
paragraphs: [
'项目以数据驱动和智能辅助决策为主线,形成可复制的技术体系与流程再造方案。',
'在技术创新、商业模式与政策吻合度方面均具备行业示范价值。',
],
},
{
heading: '三、成果与价值',
paragraphs: [
'核心指标显示候诊效率、临床质量或安全水平获得显著提升,患者与医护双侧受益。',
'项目已具备向医联体与区域平台输出的标准化能力。',
],
},
{
heading: '四、壁垒、风险与未来展望',
paragraphs: [
'竞争壁垒来自临床数据积累、流程嵌入深度与专科Know-how。',
'未来可在专科深化、基层赋能与支付模式创新方向持续扩展。',
],
},
];
}
function startSandbox(root, envOverrides = {}) {
const child = spawn(process.execPath, ['mindspace-sandbox-mcp.mjs', root], {
@@ -102,3 +145,75 @@ test('sandbox MCP exposes schedule tools only when schedule env is configured',
['schedule_create_item', 'schedule_create_reminder', 'schedule_list_items'],
);
});
test('generate_docx writes public Word files for Mark-style summary request', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-docx-'));
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
copyDocxGenerateSkill(root);
const server = startSandbox(root, {
ALLOWED_TOOLS: 'generate_docx,write_file,list_dir',
});
t.after(() => server.child.kill());
await server.request('initialize');
const listed = await server.request('tools/list');
assert.ok(listed.result.tools.some((tool) => tool.name === 'generate_docx'));
const cases = [
{
docx: 'public/协和智慧门诊研究摘要.docx',
html: 'public/协和智慧门诊研究摘要.html',
title: '北京协和医院「智慧门诊 + AI 预问诊」研究摘要',
},
{
docx: 'public/广州妇儿CDSS研究摘要.docx',
html: 'public/广州妇儿CDSS研究摘要.html',
title: '广州妇儿中心「数据驱动 + AI 临床决策」研究摘要',
},
];
for (const item of cases) {
const generated = await server.request('tools/call', {
name: 'generate_docx',
arguments: {
output_path: item.docx,
title: item.title,
sections: summarySections(item.title),
},
});
assert.equal(generated.result.isError, false, generated.result.content?.[0]?.text);
assert.match(generated.result.content[0].text, /已生成 public\/.+\.docx/);
assert.ok(fs.statSync(path.join(root, item.docx)).size > 500);
}
for (const item of cases) {
const docxName = path.basename(item.docx);
await server.request('tools/call', {
name: 'write_file',
arguments: {
path: item.html,
content: `<!doctype html><html><body><a href="${docxName}" download>下载 Word</a></body></html>`,
},
});
}
const listedPublic = await server.request('tools/call', {
name: 'list_dir',
arguments: { path: 'public' },
});
assert.equal(listedPublic.result.isError, false);
assert.match(listedPublic.result.content[0].text, /协和智慧门诊研究摘要\.docx/);
assert.match(listedPublic.result.content[0].text, /广州妇儿CDSS研究摘要\.docx/);
const sync = syncPublicDocxDownloads({ publishDir: root });
assert.deepEqual(sync.missing, []);
assert.deepEqual(sync.synced, []);
for (const item of cases) {
const abs = path.join(root, item.docx);
assert.ok(fs.existsSync(abs));
const zipHeader = fs.readFileSync(abs).subarray(0, 2).toString('utf8');
assert.equal(zipHeader, 'PK', `${item.docx} should be a valid zip/docx`);
}
});
+11 -1
View File
@@ -19,7 +19,17 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .
- 用户要 Word / docx / .doc 文档(输出 `.docx`
- 需要保存到 `oa/``private/``public/` 等分区
## 推荐命令
## 推荐方式(优先)
**优先调用 sandbox-fs 的 `generate_docx` 工具**(与 `generate_long_image` 同级),直接写入 `public/文件名.docx``oa/文件名.docx`
- `output_path`:如 `public/协和智慧门诊研究摘要.docx`
- `title`:文档标题
- `sections`:章节数组(`heading``paragraphs`、可选 `table`
生成后必须 `list_dir public/` 确认目标文件已落盘,再写 HTML 下载链接。
## 备选命令(仅当 MCP 不可用时)
技能目录内有 `generate_docx.py`(仅依赖 Python 3 标准库):