@klosedoc/mcp 0.1.1 → 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 CHANGED
@@ -12,7 +12,7 @@
12
12
  - 可访问的 KloseDoc 地址,以及在“账户菜单 → Agent 接入”创建的用户级令牌。
13
13
  - 各个项目中已存在的演示产物目录(例如 dist),项目可分散放置。
14
14
 
15
- 在已开放接入的 KloseDoc 页面选择客户端并复制配置。客户端通过 `npx -y @klosedoc/mcp@0.1.1` 启动适配器;不要使用未固定版本的 `latest`。0.1.1 必须发布并验证后才会开放此版本配置;旧版 0.1.0 仍需要终端授权。
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,39 +23,24 @@
23
23
 
24
24
  本地 HTTP 地址只能连接本机 KloseDoc,不能用于给其他人分享。
25
25
 
26
- ## 上传时确认(0.1.1 起)
26
+ ## 自动上传(0.1.2 起)
27
27
 
28
- 配置完成后,直接让 Agent 上传项目中的演示,不必先执行授权命令。客户端需要支持 MCP 表单交互确认(form elicitation)。
28
+ 统一使用自动上传,不再选择模式、不调用 MCP 表单确认,也不需要逐个目录运行授权命令。用户安装并配置此版本后,Agent 可以读取其指定的项目产物及根目录的 `klosedoc.json`,上传到配置的 KloseDoc 站点。
29
29
 
30
- 1. Agent 指定绝对项目路径 `configDirectory` 和项目内的产物 `path`(例如 `dist`)。
31
- 2. 客户端弹出确认,展示项目目录、实际读取路径、接收站点和演示项目。确认也包括读取项目根目录的 `klosedoc.json`。
32
- 3. 你勾选允许并提交后,适配器才读取文件并上传草稿;拒绝、关闭、超时或取消调用都会停止,不会自动改用其他授权途径。
33
- 4. 发布仍是单独操作,需要你明确要求,以及具备发布权限的令牌。
30
+ 1. 在接入页创建令牌,选择客户端并保存生成的配置;只需要服务地址和令牌。
31
+ 2. 重启 MCP 连接,让 Agent 指定绝对项目路径 `configDirectory`、相对产物路径 `path` 和入口 `entry`。
32
+ 3. 适配器校验并打包所选产物,上传为草稿。
33
+ 4. 发布仍是独立工具调用,需要用户明确要求以及具备发布权限的令牌。
34
34
 
35
- 每次上传都确认,不保存长期目录授权;没有 `approved: true` 之类可由 Agent 代填的参数。只列出演示、创建空项目或查询状态无需本地目录授权。不同客户端是否支持确认,取决于初始化时声明的能力;适配器依赖客户端真实展示给用户,无法替代客户端权限控制或操作系统沙箱。
35
+ `path` 默认是 `dist`。自定义目录必须显式传入,`klosedoc.json` 不能扩大或替换本次选择的读取范围。项目可分散存放,本地路径不发送至 KloseDoc 服务器。禁止整个项目目录、主目录、文件系统根目录、隐藏路径和符号链接。
36
36
 
37
- `path` 默认是 `dist`。自定义产物目录必须显式传入 `path`,不能通过 `klosedoc.json` 把已确认的读取范围扩大或换成另一个目录。禁止整个项目目录、用户主目录、文件系统根目录、隐藏路径及符号链接。
37
+ 自动上传不是目录白名单。仅向你信任的 Agent 配置令牌,使用不含私密信息的独立产物目录。过滤不能识别 HTML、JSON ZIP 中的所有敏感内容。本包不修改客户端自身的工具审批策略;客户端仍可能提示确认或阻止工具调用。
38
38
 
39
- ## 不支持交互确认的客户端
39
+ ### 旧配置升级
40
40
 
41
- 适配器会明确提示缺少确认能力,不会默认放开本地读取。可换用支持 form elicitation 的客户端,或使用备用的终端授权:
41
+ `authorize`、`unauthorize`、`authorizations` 命令已移除,旧的目录授权文件不再读取或写入。不要继续依赖它限制上传范围。客户端仅配置 `KLOSEDOCPUBLISH_URL` 和 `KLOSEDOCPUBLISH_TOKEN`;若检测到旧的 `KLOSEDOCPUBLISH_ROOT` 或 `KLOSEDOCPUBLISH_UPLOAD_MODE`,适配器会停止并提示用户检查和更换配置,不会静默忽略旧的限制。更新固定包版本前,请先了解自动上传的行为变化。
42
42
 
43
- ```sh
44
- mkdir -p dist
45
- npx -y @klosedoc/mcp@0.1.1 authorize . dist
46
- ```
47
-
48
- 内部测试使用已安装的入口替代 npx,例如:
49
-
50
- ```sh
51
- node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
52
- ```
53
-
54
- 命令会显示项目的规范绝对路径和产物目录,输入 yes 后授权。此备用方式每个项目单独执行一次;无需移动项目或修改全局 MCP 配置。授权仅允许读取指定产物目录,以及项目根目录的 klosedoc.json。修改 klosedoc.json 的 output 不会扩大授权。支持交互确认的客户端始终走当次确认,不因旧的终端授权而跳过确认。
55
-
56
- 使用相同入口的 `authorizations` 查看授权,`unauthorize .` 撤销当前项目;之后的上传会立即拒绝,已发布内容不受影响。授权记录保存在本机 `~/.config/klosedoc/authorizations.json`,不发送至服务器。不要把该文件交给 Agent 修改。交互确认不是操作系统沙箱;拥有同一用户任意文件写权限或终端控制权限的程序仍能改变授权记录,需由客户端限制这些权限。
57
-
58
- 不支持旧的 KLOSEDOCPUBLISH_ROOT 环境变量。上传必须提供绝对项目路径 configDirectory,path/output 相对于项目;不默认使用 MCP 进程工作目录。本地路径不会发送到 KloseDoc 服务端,只在本地 MCP 适配器与客户端之间用于操作和确认。
43
+ 取消尚未完成的工具调用可以中止本次传输;服务端已提交的版本不会因此删除。撤销 KloseDoc 令牌可阻止后续服务访问,移除客户端 MCP 配置可停止接入;已发布内容需单独下线。
59
44
 
60
45
  ## 关联项目
61
46
 
@@ -71,7 +56,7 @@ node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
71
56
 
72
57
  已有项目也可以使用配置文件:
73
58
 
74
- 在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html`),不包含令牌。上传时传入项目的绝对路径 `configDirectory`;非默认产物目录同时传入 `path`,以便准确展示确认范围。
59
+ 在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html`),不包含令牌。上传时传入项目的绝对路径 `configDirectory`;非默认产物目录同时传入 `path`,以便限定本次读取范围。
75
60
 
76
61
  让 Agent 调用 `klosedoc_presentation_upload` 上传输出目录,生成草稿。确认预览后再调用 `klosedoc_publication_publish`;默认令牌没有发布权限。也可用 `klosedoc_presentation_create` 创建项目、`klosedoc_publication_status` 读取状态、`klosedoc_publication_rollback` 恢复草稿、`klosedoc_publication_revoke` 下线。
77
62
 
@@ -79,7 +64,7 @@ node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
79
64
 
80
65
  ## 安全边界
81
66
 
82
- 适配器仅打包本次已确认的路径,或不支持确认客户端的终端授权产物目录,不执行构建脚本,不支持产物符号链接,目录上传要求独立输出目录。目录打包跳过隐藏文件、`node_modules` 和 `klosedoc.json`。单文件最大 10 MB,包解压后最大 50 MB、1000 个文件;账户配额与权限由服务端检查。确认前不读取配置或产物内容;确认后重新检查路径。授权期间不要让不可信进程并发替换目录;本地检查不能替代操作系统级文件隔离。
67
+ 适配器仅打包本次指定的输出路径,不执行构建脚本,不支持符号链接或硬链接,禁止整个项目、主目录和根目录。目录打包跳过隐藏文件、`node_modules` 和 `klosedoc.json`,非允许类型文件会拒绝上传。单文件最大 10 MB,包解压后最大 50 MB、1000 个文件;账户配额、令牌权限和有效性由服务端检查。操作期间不要让不可信进程并发替换目录;路径检查不能替代操作系统级文件隔离。
83
68
 
84
69
  本包仅含客户端适配器,不包含 KloseDoc 的服务端、数据库、密钥或其他产品源码。
85
70
 
package/authorization.mjs CHANGED
@@ -1,120 +1,73 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import os from 'node:os';
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 relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
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)) throw new Error('An absolute project path is required');
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.slice(current.length).split(path.sep).filter(Boolean)) {
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()) throw new Error('Symlinks are not supported');
24
+ if ((await fs.lstat(current)).isSymbolicLink())
25
+ throw new Error("Symlinks are not supported");
18
26
  }
19
27
  return resolved;
20
28
  }
21
- async function load() {
22
- try {
23
- await checkedPath(registry);
24
- const data = JSON.parse(await fs.readFile(registry, 'utf8'));
25
- if (data.version !== 1 || !Array.isArray(data.projects)) throw new Error('Invalid local authorization registry');
26
- return data;
27
- } catch (error) { if (error.code === 'ENOENT') return { version: 1, projects: [] }; throw error; }
28
- }
29
- export async function authorization(projectDirectory) {
30
- const project = await checkedPath(projectDirectory);
31
- const grant = (await load()).projects.find(item => item.project === project);
32
- if (!grant) throw new Error('Project is not authorized. Run klosedoc-mcp authorize in your terminal.');
33
- const output = await checkedPath(grant.output);
34
- if (output === project || !within(project, output)) throw new Error('Invalid authorized output directory');
35
- return { project, output };
36
- }
37
-
38
- // Approval comes from the MCP client, never from an agent-supplied tool argument.
39
- // This grant lives for one upload only and is not written to the local registry.
40
- export async function uploadAuthorization({ server, projectDirectory, output, destination, projectId, signal }) {
41
- const capability = server.getClientCapabilities()?.elicitation;
42
- const supportsForm = capability && (capability.form !== undefined || Object.keys(capability).length === 0);
43
- if (!supportsForm) {
44
- try { return await authorization(projectDirectory); }
45
- catch (error) {
46
- if (!error.message.includes('not authorized')) throw error;
47
- throw new Error('This client does not support MCP form confirmation. Use a client with elicitation support, or run klosedoc-mcp authorize "' + projectDirectory + '" dist in an interactive terminal (replace dist with your output directory). No files were uploaded.');
48
- }
49
- }
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();
50
36
  const project = await checkedPath(projectDirectory);
51
- if (project === path.parse(project).root || project === os.homedir() || !(await fs.stat(project)).isDirectory()) {
52
- throw new Error('Choose an individual project, not a home directory or filesystem root');
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
+ );
53
45
  }
54
- const selected = output || 'dist';
55
- if (path.isAbsolute(selected)) throw new Error('Output path must be relative to the project');
46
+ const selected = output || "dist";
47
+ if (path.isAbsolute(selected))
48
+ throw new Error("Output path must be relative to the project");
56
49
  const target = path.resolve(project, selected);
57
- if (target === project || !within(project, target)) throw new Error('Choose a dedicated output directory or HTML/ZIP file inside the project');
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
+ );
58
54
  const segments = path.relative(project, target).split(path.sep);
59
- if (segments.some(part => part.startsWith('.') || part === 'node_modules') || path.basename(target) === 'klosedoc.json') {
60
- throw new Error('Hidden paths, node_modules and project configuration cannot be uploaded');
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
+ );
61
62
  }
62
63
  await checkedPath(target);
63
64
  const stat = await fs.stat(target);
64
- if (!stat.isDirectory() && !(stat.isFile() && /\.(html?|zip)$/i.test(target))) {
65
- throw new Error('Choose a dedicated output directory or HTML/ZIP file');
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");
66
70
  }
67
71
  signal?.throwIfAborted();
68
- const reply = await server.elicitInput({
69
- mode: 'form',
70
- message: `KloseDoc: allow this upload?\nProject directory: ${JSON.stringify(project)}\nUpload path: ${JSON.stringify(target)}\nDestination: ${destination}\nPresentation: ${projectId || 'the projectId in klosedoc.json'}\nThis reads the selected file/directory and klosedoc.json at the project root, then uploads a draft. It does not publish. Approval applies to this upload only.`,
71
- requestedSchema: {
72
- type: 'object',
73
- properties: { allowUpload: { type: 'boolean', title: 'Allow this upload', default: false } },
74
- required: ['allowUpload'],
75
- },
76
- }, { signal, timeout: 120000 });
77
- signal?.throwIfAborted();
78
- if (reply.action !== 'accept' || reply.content?.allowUpload !== true) {
79
- throw new Error('Upload cancelled: local files were not read or uploaded.');
80
- }
81
- // Repeat path checks after the user interaction; never authorize a symlink
82
- // swapped into place while the confirmation was open.
83
- await checkedPath(project);
84
- await checkedPath(target);
85
- return { project, output: target, selected, oneTime: true };
86
- }
87
- export async function authorizationCommand(command, args) {
88
- if (command === 'authorizations') { console.log(JSON.stringify((await load()).projects, null, 2)); return; }
89
- if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Authorization changes require an interactive terminal; no unattended approval is supported.');
90
- const requested = path.resolve(args[0] || '.');
91
- const project = await fs.realpath(requested).catch(error => {
92
- if (command === 'unauthorize' && error.code === 'ENOENT') return requested;
93
- throw error;
94
- });
95
- if (project === path.parse(project).root || project === os.homedir()) throw new Error('Choose an individual project, not a home directory or filesystem root');
96
- const data = await load();
97
- let output;
98
- if (command === 'authorize') {
99
- await checkedPath(project);
100
- if (!(await fs.stat(project)).isDirectory()) throw new Error('Choose a project directory');
101
- const relative = args[1] || 'dist';
102
- if (path.isAbsolute(relative)) throw new Error('Output must be relative to the project');
103
- output = await checkedPath(path.resolve(project, relative));
104
- if (output === project || !within(project, output) || !(await fs.stat(output)).isDirectory()) throw new Error('Choose an existing dedicated output directory inside the project');
105
- }
106
- console.log(`${command === 'authorize' ? 'Allow uploads from' : 'Revoke access to'} project: ${project}`);
107
- if (output) console.log(`Output: ${output}\nAlso allows reading only klosedoc.json at the project root. Paths remain local.`);
108
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
109
- let answer;
110
- try { answer = await prompt.question('Type yes to confirm: '); } finally { prompt.close(); }
111
- if (answer.trim() !== 'yes') throw new Error('Cancelled; authorization unchanged');
112
- data.projects = data.projects.filter(item => item.project !== project);
113
- if (output) data.projects.push({ project, output });
114
- await fs.mkdir(path.dirname(registry), { recursive: true, mode: 0o700 });
115
- await checkedPath(path.dirname(registry));
116
- const temporary = registry + '.' + process.pid + '.tmp';
117
- await fs.writeFile(temporary, JSON.stringify(data, null, 2), { mode: 0o600, flag: 'wx' });
118
- await fs.rename(temporary, registry);
119
- console.log('Local authorization updated.');
72
+ return { project, output: target, selected };
120
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klosedoc/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "MCP adapter for KloseDoc. Currently supports presentation workflows only.",
5
5
  "license": "MIT",
6
6
  "files": ["server.mjs", "authorization.mjs", "README.md", "LICENSE"],
package/server.mjs CHANGED
@@ -1,137 +1,375 @@
1
1
  #!/usr/bin/env node
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 { uploadAuthorization, authorizationCommand, checkedPath, within } from './authorization.mjs';
11
- import metadata from './package.json' with { type: 'json' };
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('--version')) { console.log(metadata.version); process.exit(0); }
14
- if (process.argv.includes('--help')) {
15
- console.log('KloseDoc MCP (stdio) — currently supports presentations only.\nRequires Node.js 24+.\nSet KLOSEDOCPUBLISH_URL, KLOSEDOCPUBLISH_TOKEN in your MCP client.\nUploads ask for one-time path confirmation via MCP form elicitation before reading files.\nFallback for clients without form confirmation: 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 (['authorize', 'unauthorize', 'authorizations'].includes(process.argv[2])) {
19
- await authorizationCommand(process.argv[2], process.argv.slice(3));
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.env.KLOSEDOCPUBLISH_ROOT) throw new Error('KLOSEDOCPUBLISH_ROOT is no longer supported. Use local project authorization.');
23
- const server = new McpServer({ name: 'klosedoc-mcp', version: metadata.version });
24
- const base = new URL(process.env.KLOSEDOCPUBLISH_URL || 'http://127.0.0.1:3000/api/agent/slides');
25
- if ((base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(base.hostname)))
26
- || base.username || base.password || base.search || base.hash || base.pathname.replace(/\/$/, '') !== '/api/agent/slides') throw new Error('Use an HTTPS KloseDoc URL, or HTTP on localhost');
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('KLOSEDOCPUBLISH_TOKEN is required');
56
+ if (!token) throw new Error("KLOSEDOCPUBLISH_TOKEN is required");
29
57
  const maxBytes = 50 * 1024 * 1024;
30
- const extensions = new Set(['.html','.htm','.css','.js','.mjs','.json','.png','.jpg','.jpeg','.gif','.webp','.avif','.svg','.ico','.woff','.woff2','.ttf','.otf','.mp4','.webm','.mp3','.ogg','.wav','.txt','.pdf']);
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('Hard links are not supported');
37
- if (!stat.isFile() || stat.size > limit) throw new Error('File exceeds the 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('File exceeds the size limit');
95
+ if (bytes.length > limit) throw new Error("File exceeds the size limit");
41
96
  return bytes;
42
- } finally { await handle.close(); }
97
+ } finally {
98
+ await handle.close();
99
+ }
43
100
  }
44
101
  async function configFor(directory) {
45
- const filename = path.join(directory,'klosedoc.json');
102
+ const filename = path.join(directory, "klosedoc.json");
46
103
  try {
47
- const value = JSON.parse((await readFile(filename,64*1024)).toString('utf8'));
48
- if (value.type && value.type !== 'web_slides') throw new Error('type must be web_slides');
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) { if (error.code === 'ENOENT') return {}; throw new Error('Invalid klosedoc.json: ' + error.message); }
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, projectId, signal) {
53
- const grant = await uploadAuthorization({ server: server.server, projectDirectory: configPath, output: value, destination: base.toString(), projectId, signal });
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
- if (grant.oneTime && !value && config.output && path.resolve(grant.project, config.output) !== grant.output) {
56
- throw new Error('klosedoc.json selects a different output. Retry with an explicit path so the client can confirm that directory.');
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
+ );
57
130
  }
58
- const selected = grant.oneTime ? grant.selected : value || config.output || path.relative(grant.project, grant.output);
59
- if (path.isAbsolute(selected)) throw new Error('Output path must be relative to the project');
131
+ const selected = grant.selected;
132
+ if (path.isAbsolute(selected))
133
+ throw new Error("Output path must be relative to the project");
60
134
  const target = path.resolve(grant.project, selected);
61
135
  async function localPath(filename) {
62
- if (!within(grant.output, filename)) throw new Error('Path is outside the authorized output directory');
136
+ if (!within(grant.output, filename))
137
+ throw new Error("Path is outside the selected output directory");
63
138
  return checkedPath(filename);
64
139
  }
65
140
  await localPath(target);
66
141
  const stat = await fs.lstat(target);
67
142
  if (stat.isFile()) {
68
- if (!/\.(html?|zip)$/i.test(target)) throw new Error('Expected an HTML or ZIP file');
69
- return { bytes: await readFile(target, /\.zip$/i.test(target) ? maxBytes : 10*1024*1024), filename: path.basename(target), config };
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
+ };
70
153
  }
71
- if (!stat.isDirectory()) throw new Error('Select a dedicated output directory, inside the authorized project');
154
+ if (!stat.isDirectory())
155
+ throw new Error("Select a dedicated output directory inside the project");
72
156
  const files = Object.create(null);
73
- let total = 0, count = 0;
157
+ let total = 0,
158
+ count = 0;
74
159
  async function walk(directory) {
75
- for (const entry of (await fs.readdir(directory,{withFileTypes:true})).sort((a,b)=>a.name.localeCompare(b.name))) {
76
- if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'klosedoc.json') continue;
77
- const filename = await localPath(path.join(directory,entry.name));
78
- if (entry.isDirectory()) { await walk(filename); continue; }
79
- if (!entry.isFile() || !extensions.has(path.extname(filename).toLowerCase())) throw new Error('Unsupported output file: ' + entry.name);
80
- if (++count > 1000) throw new Error('Maximum 1000 files');
81
- const bytes = await readFile(filename,10*1024*1024);
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);
82
181
  total += bytes.length;
83
- if (total > maxBytes) throw new Error('Output exceeds 50 MB');
84
- const relative = path.relative(target,filename).split(path.sep).join('/');
85
- files[relative] = [bytes,{mtime:new Date('2020-01-01T00:00:00Z')}];
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") }];
86
188
  }
87
189
  }
88
190
  await walk(target);
89
- if (!count) throw new Error('Output directory is empty');
90
- return { bytes: zipSync(files,{level:6}), filename:'slides.zip', config };
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
+ };
91
197
  }
92
- async function request(route = '', input, key, bytes, signal) {
93
- const options = { method: input === undefined && !bytes ? 'GET' : 'POST', redirect:'error',
94
- headers:{Authorization:'Bearer ' + token}, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120000)]) : AbortSignal.timeout(120000) };
95
- if (key) options.headers['Idempotency-Key'] = key;
96
- if (bytes) { options.headers['Content-Type']='application/octet-stream'; options.body=bytes; }
97
- else if (input !== undefined) { options.headers['Content-Type']='application/json'; options.body=JSON.stringify(input); }
98
- const response = await fetch(base.toString().replace(/\/$/,'') + route,options);
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
+ );
99
219
  const payload = await response.json();
100
- if (!response.ok) throw new Error(payload.error?.message || 'KloseDoc request failed (' + response.status + ')');
220
+ if (!response.ok)
221
+ throw new Error(
222
+ payload.error?.message ||
223
+ "KloseDoc request failed (" + response.status + ")",
224
+ );
101
225
  return payload.data;
102
226
  }
103
227
  function register(name, description, schema, action, readOnly = false) {
104
- server.registerTool(name,{ description, inputSchema:schema,
105
- annotations:{readOnlyHint:readOnly,destructiveHint:!readOnly,idempotentHint:true,openWorldHint:true} },async (input, extra) => {
106
- try {
107
- const result = await action(input, extra);
108
- return { content:[{type:'text',text:JSON.stringify(result)}], structuredContent:result };
109
- } catch (error) { return { isError:true,content:[{type:'text',text:error.message.replaceAll(token,'[redacted]')}] }; }
110
- });
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
+ );
111
253
  }
112
254
  const project = z.string().uuid();
113
- const idempotencyKey = z.string().regex(/^[A-Za-z0-9_.:-]{8,160}$/).describe('Reuse the same key when retrying the same operation.');
114
- register('klosedoc_presentation_create','Create a Web Slides project. Never publishes content.',{
115
- title:z.string().min(1).max(160),folderId:project.optional(),idempotencyKey,
116
- },input=>request('',{title:input.title,...(input.folderId?{folderId:input.folderId}:{})},input.idempotencyKey));
117
- register('klosedoc_publication_status','Read status and version IDs, or list presentations. Preview links require the owner to sign in.',{
118
- projectId:project.optional(),query:z.string().optional(),page:z.number().int().min(1).optional(),
119
- },input=>request(input.projectId ? '/' + input.projectId : '?' + new URLSearchParams({query:input.query||'',page:String(input.page||1)})),true);
120
- register('klosedoc_presentation_upload','Upload a draft after the user confirms the exact local path in the MCP client. Requires form elicitation or an existing terminal grant for clients without it. Reads klosedoc.json only after authorization. Never auto-publishes. Do not edit local authorization records or approve on the user’s behalf.',{
121
- projectId:project.optional(),path:z.string().optional().describe('Relative dedicated output directory or HTML/ZIP file; defaults to dist for client confirmation. Specify custom output explicitly.'),configDirectory:z.string().describe('Absolute local project directory. No prior terminal authorization needed with a form-elicitation client. Never sent to the KloseDoc service.'),entry:z.string().optional(),idempotencyKey,
122
- },async (input, extra)=>{
123
- const packed=await bundle(input.path,input.configDirectory,input.projectId,extra.signal);
124
- extra.signal.throwIfAborted();
125
- const id=project.parse(input.projectId || packed.config.projectId);
126
- const query=new URLSearchParams({filename:packed.filename,entry:input.entry||packed.config.entry||''});
127
- const output=await request('/'+id+'/upload?'+query,undefined,input.idempotencyKey,packed.bytes,extra.signal);
128
- return {...output,checksum:crypto.createHash('sha256').update(packed.bytes).digest('hex')};
129
- });
130
- for (const operation of ['publish','rollback']) register('klosedoc_publication_'+operation,
131
- operation==='publish'?'Publish the specified version. Requires user authorization and a publish-enabled token; preserves audience and expiry.':'Restore a historical version as the draft. The live link stays unchanged until a separate publish operation.',{
132
- projectId:project,versionId:project,expectedCurrentVersionId:project.nullable(),expectedDraftVersionId:project.nullable(),idempotencyKey,
133
- },input=>request('/'+input.projectId+'/'+operation,{versionId:input.versionId,expectedCurrentVersionId:input.expectedCurrentVersionId,expectedDraftVersionId:input.expectedDraftVersionId},input.idempotencyKey));
134
- register('klosedoc_publication_revoke','Take the presentation offline without deleting versions. Requires publish permission.',{
135
- projectId:project,idempotencyKey,
136
- },input=>request('/'+input.projectId+'/revoke',{},input.idempotencyKey));
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
+ );
137
375
  await server.connect(new StdioServerTransport());