@klosedoc/mcp 0.1.0 → 0.1.2
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/README.md +14 -16
- package/authorization.mjs +59 -56
- package/package.json +1 -1
- package/server.mjs +332 -91
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- 可访问的 KloseDoc 地址,以及在“账户菜单 → Agent 接入”创建的用户级令牌。
|
|
13
13
|
- 各个项目中已存在的演示产物目录(例如 dist),项目可分散放置。
|
|
14
14
|
|
|
15
|
-
在已开放接入的 KloseDoc 页面选择客户端并复制配置。客户端通过 `npx -y @klosedoc/mcp@0.1.
|
|
15
|
+
在已开放接入的 KloseDoc 页面选择客户端并复制配置。客户端通过 `npx -y @klosedoc/mcp@0.1.2` 启动适配器;不要使用未固定版本的 `latest`。0.1.2 必须发布并验证后才会开放此版本配置;0.1.0 / 0.1.1 的旧授权流程不适用于本版本。
|
|
16
16
|
|
|
17
17
|
配置所需环境变量:
|
|
18
18
|
|
|
@@ -23,26 +23,24 @@
|
|
|
23
23
|
|
|
24
24
|
本地 HTTP 地址只能连接本机 KloseDoc,不能用于给其他人分享。
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## 自动上传(0.1.2 起)
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
统一使用自动上传,不再选择模式、不调用 MCP 表单确认,也不需要逐个目录运行授权命令。用户安装并配置此版本后,Agent 可以读取其指定的项目产物及根目录的 `klosedoc.json`,上传到配置的 KloseDoc 站点。
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
1. 在接入页创建令牌,选择客户端并保存生成的配置;只需要服务地址和令牌。
|
|
31
|
+
2. 重启 MCP 连接,让 Agent 指定绝对项目路径 `configDirectory`、相对产物路径 `path` 和入口 `entry`。
|
|
32
|
+
3. 适配器校验并打包所选产物,上传为草稿。
|
|
33
|
+
4. 发布仍是独立工具调用,需要用户明确要求以及具备发布权限的令牌。
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
`path` 默认是 `dist`。自定义目录必须显式传入,`klosedoc.json` 不能扩大或替换本次选择的读取范围。项目可分散存放,本地路径不发送至 KloseDoc 服务器。禁止整个项目目录、主目录、文件系统根目录、隐藏路径和符号链接。
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
|
|
39
|
-
```
|
|
37
|
+
自动上传不是目录白名单。仅向你信任的 Agent 配置令牌,使用不含私密信息的独立产物目录。过滤不能识别 HTML、JSON 或 ZIP 中的所有敏感内容。本包不修改客户端自身的工具审批策略;客户端仍可能提示确认或阻止工具调用。
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
### 旧配置升级
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
`authorize`、`unauthorize`、`authorizations` 命令已移除,旧的目录授权文件不再读取或写入。不要继续依赖它限制上传范围。客户端仅配置 `KLOSEDOCPUBLISH_URL` 和 `KLOSEDOCPUBLISH_TOKEN`;若检测到旧的 `KLOSEDOCPUBLISH_ROOT` 或 `KLOSEDOCPUBLISH_UPLOAD_MODE`,适配器会停止并提示用户检查和更换配置,不会静默忽略旧的限制。更新固定包版本前,请先了解自动上传的行为变化。
|
|
44
42
|
|
|
45
|
-
|
|
43
|
+
取消尚未完成的工具调用可以中止本次传输;服务端已提交的版本不会因此删除。撤销 KloseDoc 令牌可阻止后续服务访问,移除客户端 MCP 配置可停止接入;已发布内容需单独下线。
|
|
46
44
|
|
|
47
45
|
## 关联项目
|
|
48
46
|
|
|
@@ -58,7 +56,7 @@ node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
|
|
|
58
56
|
|
|
59
57
|
已有项目也可以使用配置文件:
|
|
60
58
|
|
|
61
|
-
在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html
|
|
59
|
+
在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html`),不包含令牌。上传时传入项目的绝对路径 `configDirectory`;非默认产物目录同时传入 `path`,以便限定本次读取范围。
|
|
62
60
|
|
|
63
61
|
让 Agent 调用 `klosedoc_presentation_upload` 上传输出目录,生成草稿。确认预览后再调用 `klosedoc_publication_publish`;默认令牌没有发布权限。也可用 `klosedoc_presentation_create` 创建项目、`klosedoc_publication_status` 读取状态、`klosedoc_publication_rollback` 恢复草稿、`klosedoc_publication_revoke` 下线。
|
|
64
62
|
|
|
@@ -66,7 +64,7 @@ node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
|
|
|
66
64
|
|
|
67
65
|
## 安全边界
|
|
68
66
|
|
|
69
|
-
|
|
67
|
+
适配器仅打包本次指定的输出路径,不执行构建脚本,不支持符号链接或硬链接,禁止整个项目、主目录和根目录。目录打包跳过隐藏文件、`node_modules` 和 `klosedoc.json`,非允许类型文件会拒绝上传。单文件最大 10 MB,包解压后最大 50 MB、1000 个文件;账户配额、令牌权限和有效性由服务端检查。操作期间不要让不可信进程并发替换目录;路径检查不能替代操作系统级文件隔离。
|
|
70
68
|
|
|
71
69
|
本包仅含客户端适配器,不包含 KloseDoc 的服务端、数据库、密钥或其他产品源码。
|
|
72
70
|
|
package/authorization.mjs
CHANGED
|
@@ -1,70 +1,73 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import os from
|
|
4
|
-
import { createInterface } from 'node:readline/promises';
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
5
4
|
|
|
6
|
-
const registry = process.env.KLOSEDOC_AUTH_FILE || path.join(os.homedir(), '.config', 'klosedoc', 'authorizations.json');
|
|
7
5
|
export function within(parent, target) {
|
|
8
6
|
const relative = path.relative(parent, target);
|
|
9
|
-
return
|
|
7
|
+
return (
|
|
8
|
+
relative === "" ||
|
|
9
|
+
(!relative.startsWith(".." + path.sep) &&
|
|
10
|
+
relative !== ".." &&
|
|
11
|
+
!path.isAbsolute(relative))
|
|
12
|
+
);
|
|
10
13
|
}
|
|
11
14
|
export async function checkedPath(value) {
|
|
12
|
-
if (!value || !path.isAbsolute(value))
|
|
15
|
+
if (!value || !path.isAbsolute(value))
|
|
16
|
+
throw new Error("An absolute project path is required");
|
|
13
17
|
const resolved = path.resolve(value);
|
|
14
18
|
let current = path.parse(resolved).root;
|
|
15
|
-
for (const part of resolved
|
|
19
|
+
for (const part of resolved
|
|
20
|
+
.slice(current.length)
|
|
21
|
+
.split(path.sep)
|
|
22
|
+
.filter(Boolean)) {
|
|
16
23
|
current = path.join(current, part);
|
|
17
|
-
if ((await fs.lstat(current)).isSymbolicLink())
|
|
24
|
+
if ((await fs.lstat(current)).isSymbolicLink())
|
|
25
|
+
throw new Error("Symlinks are not supported");
|
|
18
26
|
}
|
|
19
27
|
return resolved;
|
|
20
28
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
export async function authorization(projectDirectory) {
|
|
29
|
+
// Automatic uploads still validate the exact output scope before reading content.
|
|
30
|
+
export async function validateUploadTarget({
|
|
31
|
+
projectDirectory,
|
|
32
|
+
output,
|
|
33
|
+
signal,
|
|
34
|
+
}) {
|
|
35
|
+
signal?.throwIfAborted();
|
|
30
36
|
const project = await checkedPath(projectDirectory);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
if (
|
|
38
|
+
project === path.parse(project).root ||
|
|
39
|
+
project === os.homedir() ||
|
|
40
|
+
!(await fs.stat(project)).isDirectory()
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"Choose an individual project, not a home directory or filesystem root",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const selected = output || "dist";
|
|
47
|
+
if (path.isAbsolute(selected))
|
|
48
|
+
throw new Error("Output path must be relative to the project");
|
|
49
|
+
const target = path.resolve(project, selected);
|
|
50
|
+
if (target === project || !within(project, target))
|
|
51
|
+
throw new Error(
|
|
52
|
+
"Choose a dedicated output directory or HTML/ZIP file inside the project",
|
|
53
|
+
);
|
|
54
|
+
const segments = path.relative(project, target).split(path.sep);
|
|
55
|
+
if (
|
|
56
|
+
segments.some((part) => part.startsWith(".") || part === "node_modules") ||
|
|
57
|
+
path.basename(target) === "klosedoc.json"
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Hidden paths, node_modules and project configuration cannot be uploaded",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
await checkedPath(target);
|
|
64
|
+
const stat = await fs.stat(target);
|
|
65
|
+
if (
|
|
66
|
+
!stat.isDirectory() &&
|
|
67
|
+
!(stat.isFile() && /\.(html?|zip)$/i.test(target))
|
|
68
|
+
) {
|
|
69
|
+
throw new Error("Choose a dedicated output directory or HTML/ZIP file");
|
|
55
70
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
59
|
-
let answer;
|
|
60
|
-
try { answer = await prompt.question('Type yes to confirm: '); } finally { prompt.close(); }
|
|
61
|
-
if (answer.trim() !== 'yes') throw new Error('Cancelled; authorization unchanged');
|
|
62
|
-
data.projects = data.projects.filter(item => item.project !== project);
|
|
63
|
-
if (output) data.projects.push({ project, output });
|
|
64
|
-
await fs.mkdir(path.dirname(registry), { recursive: true, mode: 0o700 });
|
|
65
|
-
await checkedPath(path.dirname(registry));
|
|
66
|
-
const temporary = registry + '.' + process.pid + '.tmp';
|
|
67
|
-
await fs.writeFile(temporary, JSON.stringify(data, null, 2), { mode: 0o600, flag: 'wx' });
|
|
68
|
-
await fs.rename(temporary, registry);
|
|
69
|
-
console.log('Local authorization updated.');
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
return { project, output: target, selected };
|
|
70
73
|
}
|
package/package.json
CHANGED
package/server.mjs
CHANGED
|
@@ -1,134 +1,375 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import fs from
|
|
3
|
-
import { constants } from
|
|
4
|
-
import path from
|
|
5
|
-
import crypto from
|
|
6
|
-
import { zipSync } from
|
|
7
|
-
import { McpServer } from
|
|
8
|
-
import { StdioServerTransport } from
|
|
9
|
-
import { z } from
|
|
10
|
-
import {
|
|
11
|
-
import metadata from
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { zipSync } from "fflate";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { validateUploadTarget, checkedPath, within } from "./authorization.mjs";
|
|
11
|
+
import metadata from "./package.json" with { type: "json" };
|
|
12
12
|
|
|
13
|
-
if (process.argv.includes(
|
|
14
|
-
|
|
15
|
-
console.log('KloseDoc MCP (stdio) — currently supports presentations only.\nRequires Node.js 24+.\nSet KLOSEDOCPUBLISH_URL, KLOSEDOCPUBLISH_TOKEN in your MCP client.\nLocal access: authorize [project-directory] [output-directory=dist], authorizations, unauthorize [project-directory].\nSee the package README for setup and safety boundaries.');
|
|
13
|
+
if (process.argv.includes("--version")) {
|
|
14
|
+
console.log(metadata.version);
|
|
16
15
|
process.exit(0);
|
|
17
16
|
}
|
|
18
|
-
if (
|
|
19
|
-
|
|
17
|
+
if (process.argv.includes("--help")) {
|
|
18
|
+
console.log(
|
|
19
|
+
"KloseDoc MCP (stdio) — presentations only. Requires Node.js 24+.\nSet KLOSEDOCPUBLISH_URL and KLOSEDOCPUBLISH_TOKEN in your MCP client.\nUploads automatically read the specified presentation output and create a draft. No form confirmation or directory authorization command is required.\nPublishing remains a separate tool and token permission. Client tool-approval policies are unchanged.\nSee the package README for setup and safety boundaries.",
|
|
20
|
+
);
|
|
20
21
|
process.exit(0);
|
|
21
22
|
}
|
|
22
|
-
if (process.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
if (process.argv.length > 2)
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Unknown command. Directory authorization commands are no longer supported; see --help.",
|
|
26
|
+
);
|
|
27
|
+
// Do not silently reinterpret an older, explicitly restricted configuration.
|
|
28
|
+
if (
|
|
29
|
+
process.env.KLOSEDOCPUBLISH_ROOT !== undefined ||
|
|
30
|
+
process.env.KLOSEDOCPUBLISH_UPLOAD_MODE !== undefined
|
|
31
|
+
)
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Legacy root or upload-mode settings are no longer supported. Review the automatic-upload behavior and replace the KloseDoc client configuration yourself.",
|
|
34
|
+
);
|
|
35
|
+
const server = new McpServer({
|
|
36
|
+
name: "klosedoc-mcp",
|
|
37
|
+
version: metadata.version,
|
|
38
|
+
});
|
|
39
|
+
const base = new URL(
|
|
40
|
+
process.env.KLOSEDOCPUBLISH_URL || "http://127.0.0.1:3000/api/agent/slides",
|
|
41
|
+
);
|
|
42
|
+
if (
|
|
43
|
+
(base.protocol !== "https:" &&
|
|
44
|
+
!(
|
|
45
|
+
base.protocol === "http:" &&
|
|
46
|
+
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname)
|
|
47
|
+
)) ||
|
|
48
|
+
base.username ||
|
|
49
|
+
base.password ||
|
|
50
|
+
base.search ||
|
|
51
|
+
base.hash ||
|
|
52
|
+
base.pathname.replace(/\/$/, "") !== "/api/agent/slides"
|
|
53
|
+
)
|
|
54
|
+
throw new Error("Use an HTTPS KloseDoc URL, or HTTP on localhost");
|
|
27
55
|
const token = process.env.KLOSEDOCPUBLISH_TOKEN;
|
|
28
|
-
if (!token) throw new Error(
|
|
56
|
+
if (!token) throw new Error("KLOSEDOCPUBLISH_TOKEN is required");
|
|
29
57
|
const maxBytes = 50 * 1024 * 1024;
|
|
30
|
-
const extensions = new Set([
|
|
58
|
+
const extensions = new Set([
|
|
59
|
+
".html",
|
|
60
|
+
".htm",
|
|
61
|
+
".css",
|
|
62
|
+
".js",
|
|
63
|
+
".mjs",
|
|
64
|
+
".json",
|
|
65
|
+
".png",
|
|
66
|
+
".jpg",
|
|
67
|
+
".jpeg",
|
|
68
|
+
".gif",
|
|
69
|
+
".webp",
|
|
70
|
+
".avif",
|
|
71
|
+
".svg",
|
|
72
|
+
".ico",
|
|
73
|
+
".woff",
|
|
74
|
+
".woff2",
|
|
75
|
+
".ttf",
|
|
76
|
+
".otf",
|
|
77
|
+
".mp4",
|
|
78
|
+
".webm",
|
|
79
|
+
".mp3",
|
|
80
|
+
".ogg",
|
|
81
|
+
".wav",
|
|
82
|
+
".txt",
|
|
83
|
+
".pdf",
|
|
84
|
+
]);
|
|
31
85
|
async function readFile(file, limit) {
|
|
32
86
|
await checkedPath(file);
|
|
33
|
-
const handle = await fs.open(file,constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
87
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
34
88
|
try {
|
|
35
89
|
const stat = await handle.stat();
|
|
36
|
-
if (stat.nlink !== 1) throw new Error(
|
|
37
|
-
if (!stat.isFile() || stat.size > limit)
|
|
90
|
+
if (stat.nlink !== 1) throw new Error("Hard links are not supported");
|
|
91
|
+
if (!stat.isFile() || stat.size > limit)
|
|
92
|
+
throw new Error("File exceeds the size limit");
|
|
38
93
|
const bytes = await handle.readFile();
|
|
39
94
|
await checkedPath(file);
|
|
40
|
-
if (bytes.length > limit) throw new Error(
|
|
95
|
+
if (bytes.length > limit) throw new Error("File exceeds the size limit");
|
|
41
96
|
return bytes;
|
|
42
|
-
} finally {
|
|
97
|
+
} finally {
|
|
98
|
+
await handle.close();
|
|
99
|
+
}
|
|
43
100
|
}
|
|
44
101
|
async function configFor(directory) {
|
|
45
|
-
const filename = path.join(directory,
|
|
102
|
+
const filename = path.join(directory, "klosedoc.json");
|
|
46
103
|
try {
|
|
47
|
-
const value = JSON.parse(
|
|
48
|
-
|
|
104
|
+
const value = JSON.parse(
|
|
105
|
+
(await readFile(filename, 64 * 1024)).toString("utf8"),
|
|
106
|
+
);
|
|
107
|
+
if (value.type && value.type !== "web_slides")
|
|
108
|
+
throw new Error("type must be web_slides");
|
|
49
109
|
return value;
|
|
50
|
-
} catch (error) {
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.code === "ENOENT") return {};
|
|
112
|
+
throw new Error("Invalid klosedoc.json: " + error.message);
|
|
113
|
+
}
|
|
51
114
|
}
|
|
52
|
-
async function bundle(value, configPath) {
|
|
53
|
-
const grant = await
|
|
115
|
+
async function bundle(value, configPath, signal) {
|
|
116
|
+
const grant = await validateUploadTarget({
|
|
117
|
+
projectDirectory: configPath,
|
|
118
|
+
output: value,
|
|
119
|
+
signal,
|
|
120
|
+
});
|
|
54
121
|
const config = await configFor(grant.project);
|
|
55
|
-
|
|
56
|
-
|
|
122
|
+
if (
|
|
123
|
+
!value &&
|
|
124
|
+
config.output &&
|
|
125
|
+
path.resolve(grant.project, config.output) !== grant.output
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"klosedoc.json selects a different output. Supply that directory explicitly as path; configuration cannot expand the selected scope.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const selected = grant.selected;
|
|
132
|
+
if (path.isAbsolute(selected))
|
|
133
|
+
throw new Error("Output path must be relative to the project");
|
|
57
134
|
const target = path.resolve(grant.project, selected);
|
|
58
135
|
async function localPath(filename) {
|
|
59
|
-
if (!within(grant.output, filename))
|
|
136
|
+
if (!within(grant.output, filename))
|
|
137
|
+
throw new Error("Path is outside the selected output directory");
|
|
60
138
|
return checkedPath(filename);
|
|
61
139
|
}
|
|
62
140
|
await localPath(target);
|
|
63
141
|
const stat = await fs.lstat(target);
|
|
64
142
|
if (stat.isFile()) {
|
|
65
|
-
if (!/\.(html?|zip)$/i.test(target))
|
|
66
|
-
|
|
143
|
+
if (!/\.(html?|zip)$/i.test(target))
|
|
144
|
+
throw new Error("Expected an HTML or ZIP file");
|
|
145
|
+
return {
|
|
146
|
+
bytes: await readFile(
|
|
147
|
+
target,
|
|
148
|
+
/\.zip$/i.test(target) ? maxBytes : 10 * 1024 * 1024,
|
|
149
|
+
),
|
|
150
|
+
filename: path.basename(target),
|
|
151
|
+
config,
|
|
152
|
+
};
|
|
67
153
|
}
|
|
68
|
-
if (!stat.isDirectory())
|
|
154
|
+
if (!stat.isDirectory())
|
|
155
|
+
throw new Error("Select a dedicated output directory inside the project");
|
|
69
156
|
const files = Object.create(null);
|
|
70
|
-
let total = 0,
|
|
157
|
+
let total = 0,
|
|
158
|
+
count = 0;
|
|
71
159
|
async function walk(directory) {
|
|
72
|
-
for (const entry of (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
160
|
+
for (const entry of (
|
|
161
|
+
await fs.readdir(directory, { withFileTypes: true })
|
|
162
|
+
).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
163
|
+
if (
|
|
164
|
+
entry.name.startsWith(".") ||
|
|
165
|
+
entry.name === "node_modules" ||
|
|
166
|
+
entry.name === "klosedoc.json"
|
|
167
|
+
)
|
|
168
|
+
continue;
|
|
169
|
+
const filename = await localPath(path.join(directory, entry.name));
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
await walk(filename);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
!entry.isFile() ||
|
|
176
|
+
!extensions.has(path.extname(filename).toLowerCase())
|
|
177
|
+
)
|
|
178
|
+
throw new Error("Unsupported output file: " + entry.name);
|
|
179
|
+
if (++count > 1000) throw new Error("Maximum 1000 files");
|
|
180
|
+
const bytes = await readFile(filename, 10 * 1024 * 1024);
|
|
80
181
|
total += bytes.length;
|
|
81
|
-
if (total > maxBytes) throw new Error(
|
|
82
|
-
const relative = path
|
|
83
|
-
|
|
182
|
+
if (total > maxBytes) throw new Error("Output exceeds 50 MB");
|
|
183
|
+
const relative = path
|
|
184
|
+
.relative(target, filename)
|
|
185
|
+
.split(path.sep)
|
|
186
|
+
.join("/");
|
|
187
|
+
files[relative] = [bytes, { mtime: new Date("2020-01-01T00:00:00Z") }];
|
|
84
188
|
}
|
|
85
189
|
}
|
|
86
190
|
await walk(target);
|
|
87
|
-
if (!count) throw new Error(
|
|
88
|
-
return {
|
|
191
|
+
if (!count) throw new Error("Output directory is empty");
|
|
192
|
+
return {
|
|
193
|
+
bytes: zipSync(files, { level: 6 }),
|
|
194
|
+
filename: "slides.zip",
|
|
195
|
+
config,
|
|
196
|
+
};
|
|
89
197
|
}
|
|
90
|
-
async function request(route =
|
|
91
|
-
const options = {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
198
|
+
async function request(route = "", input, key, bytes, signal) {
|
|
199
|
+
const options = {
|
|
200
|
+
method: input === undefined && !bytes ? "GET" : "POST",
|
|
201
|
+
redirect: "error",
|
|
202
|
+
headers: { Authorization: "Bearer " + token },
|
|
203
|
+
signal: signal
|
|
204
|
+
? AbortSignal.any([signal, AbortSignal.timeout(120000)])
|
|
205
|
+
: AbortSignal.timeout(120000),
|
|
206
|
+
};
|
|
207
|
+
if (key) options.headers["Idempotency-Key"] = key;
|
|
208
|
+
if (bytes) {
|
|
209
|
+
options.headers["Content-Type"] = "application/octet-stream";
|
|
210
|
+
options.body = bytes;
|
|
211
|
+
} else if (input !== undefined) {
|
|
212
|
+
options.headers["Content-Type"] = "application/json";
|
|
213
|
+
options.body = JSON.stringify(input);
|
|
214
|
+
}
|
|
215
|
+
const response = await fetch(
|
|
216
|
+
base.toString().replace(/\/$/, "") + route,
|
|
217
|
+
options,
|
|
218
|
+
);
|
|
97
219
|
const payload = await response.json();
|
|
98
|
-
if (!response.ok)
|
|
220
|
+
if (!response.ok)
|
|
221
|
+
throw new Error(
|
|
222
|
+
payload.error?.message ||
|
|
223
|
+
"KloseDoc request failed (" + response.status + ")",
|
|
224
|
+
);
|
|
99
225
|
return payload.data;
|
|
100
226
|
}
|
|
101
227
|
function register(name, description, schema, action, readOnly = false) {
|
|
102
|
-
server.registerTool(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
228
|
+
server.registerTool(
|
|
229
|
+
name,
|
|
230
|
+
{
|
|
231
|
+
description,
|
|
232
|
+
inputSchema: schema,
|
|
233
|
+
annotations: {
|
|
234
|
+
readOnlyHint: readOnly,
|
|
235
|
+
destructiveHint: !readOnly,
|
|
236
|
+
idempotentHint: true,
|
|
237
|
+
openWorldHint: true,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
async (input, extra) => {
|
|
241
|
+
try {
|
|
242
|
+
const result = await action(input, extra);
|
|
243
|
+
return {
|
|
244
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
245
|
+
structuredContent: result,
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
const message = error.message.replaceAll(token, "[redacted]");
|
|
249
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
);
|
|
109
253
|
}
|
|
110
254
|
const project = z.string().uuid();
|
|
111
|
-
const idempotencyKey = z
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
register(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
255
|
+
const idempotencyKey = z
|
|
256
|
+
.string()
|
|
257
|
+
.regex(/^[A-Za-z0-9_.:-]{8,160}$/)
|
|
258
|
+
.describe("Reuse the same key when retrying the same operation.");
|
|
259
|
+
register(
|
|
260
|
+
"klosedoc_presentation_create",
|
|
261
|
+
"Create a Web Slides project. Never publishes content.",
|
|
262
|
+
{
|
|
263
|
+
title: z.string().min(1).max(160),
|
|
264
|
+
folderId: project.optional(),
|
|
265
|
+
idempotencyKey,
|
|
266
|
+
},
|
|
267
|
+
(input) =>
|
|
268
|
+
request(
|
|
269
|
+
"",
|
|
270
|
+
{
|
|
271
|
+
title: input.title,
|
|
272
|
+
...(input.folderId ? { folderId: input.folderId } : {}),
|
|
273
|
+
},
|
|
274
|
+
input.idempotencyKey,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
register(
|
|
278
|
+
"klosedoc_publication_status",
|
|
279
|
+
"Read status and version IDs, or list presentations. Preview links require the owner to sign in.",
|
|
280
|
+
{
|
|
281
|
+
projectId: project.optional(),
|
|
282
|
+
query: z.string().optional(),
|
|
283
|
+
page: z.number().int().min(1).optional(),
|
|
284
|
+
},
|
|
285
|
+
(input) =>
|
|
286
|
+
request(
|
|
287
|
+
input.projectId
|
|
288
|
+
? "/" + input.projectId
|
|
289
|
+
: "?" +
|
|
290
|
+
new URLSearchParams({
|
|
291
|
+
query: input.query || "",
|
|
292
|
+
page: String(input.page || 1),
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
true,
|
|
296
|
+
);
|
|
297
|
+
register(
|
|
298
|
+
"klosedoc_presentation_upload",
|
|
299
|
+
"Automatically read the explicitly selected local presentation output and upload a draft. No form elicitation or directory authorization is needed. Does not publish. Follow the user’s requested files and destination; do not change client approval policies.",
|
|
300
|
+
{
|
|
301
|
+
projectId: project.optional(),
|
|
302
|
+
path: z
|
|
303
|
+
.string()
|
|
304
|
+
.optional()
|
|
305
|
+
.describe(
|
|
306
|
+
"Relative dedicated output directory or HTML/ZIP file; defaults to dist. Specify custom output explicitly.",
|
|
307
|
+
),
|
|
308
|
+
configDirectory: z
|
|
309
|
+
.string()
|
|
310
|
+
.describe(
|
|
311
|
+
"Absolute local project directory. Select only the presentation output requested by the user. Never sent to the KloseDoc service.",
|
|
312
|
+
),
|
|
313
|
+
entry: z.string().optional(),
|
|
314
|
+
idempotencyKey,
|
|
315
|
+
},
|
|
316
|
+
async (input, extra) => {
|
|
317
|
+
const packed = await bundle(
|
|
318
|
+
input.path,
|
|
319
|
+
input.configDirectory,
|
|
320
|
+
extra.signal,
|
|
321
|
+
);
|
|
322
|
+
extra.signal.throwIfAborted();
|
|
323
|
+
const id = project.parse(input.projectId || packed.config.projectId);
|
|
324
|
+
const query = new URLSearchParams({
|
|
325
|
+
filename: packed.filename,
|
|
326
|
+
entry: input.entry || packed.config.entry || "",
|
|
327
|
+
});
|
|
328
|
+
const output = await request(
|
|
329
|
+
"/" + id + "/upload?" + query,
|
|
330
|
+
undefined,
|
|
331
|
+
input.idempotencyKey,
|
|
332
|
+
packed.bytes,
|
|
333
|
+
extra.signal,
|
|
334
|
+
);
|
|
335
|
+
return {
|
|
336
|
+
...output,
|
|
337
|
+
checksum: crypto.createHash("sha256").update(packed.bytes).digest("hex"),
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
for (const operation of ["publish", "rollback"])
|
|
342
|
+
register(
|
|
343
|
+
"klosedoc_publication_" + operation,
|
|
344
|
+
operation === "publish"
|
|
345
|
+
? "Publish the specified version. Requires user authorization and a publish-enabled token; preserves audience and expiry."
|
|
346
|
+
: "Restore a historical version as the draft. The live link stays unchanged until a separate publish operation.",
|
|
347
|
+
{
|
|
348
|
+
projectId: project,
|
|
349
|
+
versionId: project,
|
|
350
|
+
expectedCurrentVersionId: project.nullable(),
|
|
351
|
+
expectedDraftVersionId: project.nullable(),
|
|
352
|
+
idempotencyKey,
|
|
353
|
+
},
|
|
354
|
+
(input) =>
|
|
355
|
+
request(
|
|
356
|
+
"/" + input.projectId + "/" + operation,
|
|
357
|
+
{
|
|
358
|
+
versionId: input.versionId,
|
|
359
|
+
expectedCurrentVersionId: input.expectedCurrentVersionId,
|
|
360
|
+
expectedDraftVersionId: input.expectedDraftVersionId,
|
|
361
|
+
},
|
|
362
|
+
input.idempotencyKey,
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
register(
|
|
366
|
+
"klosedoc_publication_revoke",
|
|
367
|
+
"Take the presentation offline without deleting versions. Requires publish permission.",
|
|
368
|
+
{
|
|
369
|
+
projectId: project,
|
|
370
|
+
idempotencyKey,
|
|
371
|
+
},
|
|
372
|
+
(input) =>
|
|
373
|
+
request("/" + input.projectId + "/revoke", {}, input.idempotencyKey),
|
|
374
|
+
);
|
|
134
375
|
await server.connect(new StdioServerTransport());
|