@klosedoc/mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +75 -0
- package/authorization.mjs +70 -0
- package/package.json +13 -0
- package/server.mjs +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KloseDoc contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# KloseDoc MCP
|
|
2
|
+
|
|
3
|
+
轻量的本地 MCP 适配器,无需获取或安装 KloseDoc 服务端源码。当前版本仅支持网页演示相关功能;尚不支持 Markdown、LaTeX 或 Typst 项目的读写、编译。
|
|
4
|
+
|
|
5
|
+
## 安装方式
|
|
6
|
+
|
|
7
|
+
本包通过 npm 独立分发,不需要访问 KloseDoc 闭源仓库。以下命令适用于 npm 上已发布的对应版本;未发布版本可使用维护者提供的 `.tgz` 包。KloseDoc 接入页面会在维护者验证发布后开放配置复制。
|
|
8
|
+
|
|
9
|
+
## 使用条件
|
|
10
|
+
|
|
11
|
+
- 本机 Node.js 24 或以上(包含 npm/npx)。
|
|
12
|
+
- 可访问的 KloseDoc 地址,以及在“账户菜单 → Agent 接入”创建的用户级令牌。
|
|
13
|
+
- 各个项目中已存在的演示产物目录(例如 dist),项目可分散放置。
|
|
14
|
+
|
|
15
|
+
在已开放接入的 KloseDoc 页面选择客户端并复制配置。客户端通过 `npx -y @klosedoc/mcp@0.1.0` 启动适配器;不要使用未固定版本的 `latest`。
|
|
16
|
+
|
|
17
|
+
配置所需环境变量:
|
|
18
|
+
|
|
19
|
+
| 变量 | 含义 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `KLOSEDOCPUBLISH_URL` | KloseDoc 的 `/api/agent/slides` 地址;生产环境必须为 HTTPS |
|
|
22
|
+
| `KLOSEDOCPUBLISH_TOKEN` | 用户级令牌;不要提交到仓库或发送给其他人 |
|
|
23
|
+
|
|
24
|
+
本地 HTTP 地址只能连接本机 KloseDoc,不能用于给其他人分享。
|
|
25
|
+
|
|
26
|
+
## 本地项目授权
|
|
27
|
+
|
|
28
|
+
无需在网页填写目录。进入要发布的项目,在终端执行(安装包发布后):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
mkdir -p dist
|
|
32
|
+
npx -y @klosedoc/mcp@0.1.0 authorize . dist
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
内部测试使用已安装的入口替代 npx,例如:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
命令会显示项目的规范绝对路径和产物目录,输入 yes 后授权。每个项目单独执行一次;无需移动项目或修改全局 MCP 配置。授权仅允许读取指定产物目录,以及项目根目录的 klosedoc.json。修改 klosedoc.json 的 output 不会扩大授权。
|
|
42
|
+
|
|
43
|
+
使用相同入口的 `authorizations` 查看授权,`unauthorize .` 撤销当前项目;之后的上传会立即拒绝,已发布内容不受影响。授权记录保存在本机 `~/.config/klosedoc/authorizations.json`,不发送至服务器。不要把该文件交给 Agent 修改。交互确认不是操作系统沙箱;拥有同一用户任意文件写权限或终端控制权限的程序仍能改变授权记录,需由客户端限制这些权限。
|
|
44
|
+
|
|
45
|
+
不兼容旧配置:删除 MCP 环境变量中的 KLOSEDOCPUBLISH_ROOT,然后重新按项目授权。上传必须提供授权命令显示的绝对项目路径 configDirectory,path/output 相对于项目;不再默认使用 MCP 进程工作目录。
|
|
46
|
+
|
|
47
|
+
## 关联项目
|
|
48
|
+
|
|
49
|
+
首次配置好 MCP 客户端、服务地址和用户令牌后,不需要通过网页创建项目:Agent 可先调用 `klosedoc_presentation_create`,再把返回的项目 ID 传入 `klosedoc_presentation_upload`,最后读取状态并调用 `klosedoc_publication_publish`。上传时显式传入 `projectId`、本地 `path` 和 `entry`,无需预先创建 `klosedoc.json`。发布要求令牌具有发布权限,并且用户明确授权发布。
|
|
50
|
+
|
|
51
|
+
示例指令:
|
|
52
|
+
|
|
53
|
+
> 将工作区的 dist/index.html 及其静态资源作为演示发布到 KloseDoc,项目名为“研究汇报”。先创建项目,再上传 dist,然后读取最新状态并发布刚上传的版本,返回演示链接。记录项目 ID,以后更新同一项目。不要修改其他项目。
|
|
54
|
+
|
|
55
|
+
本地预览和文件检查可以由 Agent 完成;不要求人先打开 KloseDoc 网页。一次指令会编排多个 MCP 操作,并不是一个跨步骤原子事务。网络失败时保留已返回的项目 ID,复用相同操作的幂等键重试,避免重复创建项目。没有发布权限时只能完成草稿上传,不会自动绕过权限。
|
|
56
|
+
|
|
57
|
+
首次获取令牌目前仍需通过 KloseDoc 的“账户菜单 → Agent 接入”;本版本尚未实现 CLI 登录或设备授权流程。令牌通过 MCP 客户端环境变量配置,不应粘贴进聊天消息。
|
|
58
|
+
|
|
59
|
+
已有项目也可以使用配置文件:
|
|
60
|
+
|
|
61
|
+
在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html`),不包含令牌。上传时传入已授权项目的绝对路径 `configDirectory`,该路径仅供本地适配器使用。
|
|
62
|
+
|
|
63
|
+
让 Agent 调用 `klosedoc_presentation_upload` 上传输出目录,生成草稿。确认预览后再调用 `klosedoc_publication_publish`;默认令牌没有发布权限。也可用 `klosedoc_presentation_create` 创建项目、`klosedoc_publication_status` 读取状态、`klosedoc_publication_rollback` 恢复草稿、`klosedoc_publication_revoke` 下线。
|
|
64
|
+
|
|
65
|
+
每次发布或回滚前读取最新版本状态,传入 `expectedCurrentVersionId` 和 `expectedDraftVersionId`。同一操作重试时复用 `idempotencyKey`,冲突后重新读取状态并确认。上传、创建和回滚都不会隐式更新线上链接。
|
|
66
|
+
|
|
67
|
+
## 安全边界
|
|
68
|
+
|
|
69
|
+
适配器仅打包本地已授权的产物目录,不执行构建脚本,不支持产物符号链接,目录上传要求独立输出目录。目录打包跳过隐藏文件、`node_modules` 和 `klosedoc.json`。单文件最大 10 MB,包解压后最大 50 MB、1000 个文件;账户配额与权限由服务端检查。授权期间不要让不可信进程并发替换目录;本地检查不能替代操作系统级文件隔离。
|
|
70
|
+
|
|
71
|
+
本包仅含客户端适配器,不包含 KloseDoc 的服务端、数据库、密钥或其他产品源码。
|
|
72
|
+
|
|
73
|
+
## 许可
|
|
74
|
+
|
|
75
|
+
`@klosedoc/mcp` 适配器及其随附文档采用 [MIT 许可](LICENSE)。该许可仅适用于此适配器包,不适用于 KloseDoc 服务端或仓库中的其他产品代码,也不改变主项目的许可。第三方依赖遵循各自的许可。
|
|
@@ -0,0 +1,70 @@
|
|
|
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';
|
|
5
|
+
|
|
6
|
+
const registry = process.env.KLOSEDOC_AUTH_FILE || path.join(os.homedir(), '.config', 'klosedoc', 'authorizations.json');
|
|
7
|
+
export function within(parent, target) {
|
|
8
|
+
const relative = path.relative(parent, target);
|
|
9
|
+
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
|
|
10
|
+
}
|
|
11
|
+
export async function checkedPath(value) {
|
|
12
|
+
if (!value || !path.isAbsolute(value)) throw new Error('An absolute project path is required');
|
|
13
|
+
const resolved = path.resolve(value);
|
|
14
|
+
let current = path.parse(resolved).root;
|
|
15
|
+
for (const part of resolved.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
16
|
+
current = path.join(current, part);
|
|
17
|
+
if ((await fs.lstat(current)).isSymbolicLink()) throw new Error('Symlinks are not supported');
|
|
18
|
+
}
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
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
|
+
export async function authorizationCommand(command, args) {
|
|
38
|
+
if (command === 'authorizations') { console.log(JSON.stringify((await load()).projects, null, 2)); return; }
|
|
39
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Authorization changes require an interactive terminal; no unattended approval is supported.');
|
|
40
|
+
const requested = path.resolve(args[0] || '.');
|
|
41
|
+
const project = await fs.realpath(requested).catch(error => {
|
|
42
|
+
if (command === 'unauthorize' && error.code === 'ENOENT') return requested;
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
if (project === path.parse(project).root || project === os.homedir()) throw new Error('Choose an individual project, not a home directory or filesystem root');
|
|
46
|
+
const data = await load();
|
|
47
|
+
let output;
|
|
48
|
+
if (command === 'authorize') {
|
|
49
|
+
await checkedPath(project);
|
|
50
|
+
if (!(await fs.stat(project)).isDirectory()) throw new Error('Choose a project directory');
|
|
51
|
+
const relative = args[1] || 'dist';
|
|
52
|
+
if (path.isAbsolute(relative)) throw new Error('Output must be relative to the project');
|
|
53
|
+
output = await checkedPath(path.resolve(project, relative));
|
|
54
|
+
if (output === project || !within(project, output) || !(await fs.stat(output)).isDirectory()) throw new Error('Choose an existing dedicated output directory inside the project');
|
|
55
|
+
}
|
|
56
|
+
console.log(`${command === 'authorize' ? 'Allow uploads from' : 'Revoke access to'} project: ${project}`);
|
|
57
|
+
if (output) console.log(`Output: ${output}\nAlso allows reading only klosedoc.json at the project root. Paths remain local.`);
|
|
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.');
|
|
70
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@klosedoc/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP adapter for KloseDoc. Currently supports presentation workflows only.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": ["server.mjs", "authorization.mjs", "README.md", "LICENSE"],
|
|
7
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": { "klosedoc-mcp": "server.mjs" },
|
|
10
|
+
"scripts": { "start": "node server.mjs" },
|
|
11
|
+
"engines": { "node": ">=24" },
|
|
12
|
+
"dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "fflate": "^0.8.3", "zod": "^3.25.0" }
|
|
13
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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 { authorization, authorizationCommand, checkedPath, within } from './authorization.mjs';
|
|
11
|
+
import metadata from './package.json' with { type: 'json' };
|
|
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.\nLocal access: authorize [project-directory] [output-directory=dist], authorizations, unauthorize [project-directory].\nSee the package README for setup and safety boundaries.');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
if (['authorize', 'unauthorize', 'authorizations'].includes(process.argv[2])) {
|
|
19
|
+
await authorizationCommand(process.argv[2], process.argv.slice(3));
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
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');
|
|
27
|
+
const token = process.env.KLOSEDOCPUBLISH_TOKEN;
|
|
28
|
+
if (!token) throw new Error('KLOSEDOCPUBLISH_TOKEN is required');
|
|
29
|
+
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']);
|
|
31
|
+
async function readFile(file, limit) {
|
|
32
|
+
await checkedPath(file);
|
|
33
|
+
const handle = await fs.open(file,constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
34
|
+
try {
|
|
35
|
+
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');
|
|
38
|
+
const bytes = await handle.readFile();
|
|
39
|
+
await checkedPath(file);
|
|
40
|
+
if (bytes.length > limit) throw new Error('File exceeds the size limit');
|
|
41
|
+
return bytes;
|
|
42
|
+
} finally { await handle.close(); }
|
|
43
|
+
}
|
|
44
|
+
async function configFor(directory) {
|
|
45
|
+
const filename = path.join(directory,'klosedoc.json');
|
|
46
|
+
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');
|
|
49
|
+
return value;
|
|
50
|
+
} catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Invalid klosedoc.json: ' + error.message); }
|
|
51
|
+
}
|
|
52
|
+
async function bundle(value, configPath) {
|
|
53
|
+
const grant = await authorization(configPath);
|
|
54
|
+
const config = await configFor(grant.project);
|
|
55
|
+
const selected = value || config.output || path.relative(grant.project, grant.output);
|
|
56
|
+
if (path.isAbsolute(selected)) throw new Error('Output path must be relative to the project');
|
|
57
|
+
const target = path.resolve(grant.project, selected);
|
|
58
|
+
async function localPath(filename) {
|
|
59
|
+
if (!within(grant.output, filename)) throw new Error('Path is outside the authorized output directory');
|
|
60
|
+
return checkedPath(filename);
|
|
61
|
+
}
|
|
62
|
+
await localPath(target);
|
|
63
|
+
const stat = await fs.lstat(target);
|
|
64
|
+
if (stat.isFile()) {
|
|
65
|
+
if (!/\.(html?|zip)$/i.test(target)) throw new Error('Expected an HTML or ZIP file');
|
|
66
|
+
return { bytes: await readFile(target, /\.zip$/i.test(target) ? maxBytes : 10*1024*1024), filename: path.basename(target), config };
|
|
67
|
+
}
|
|
68
|
+
if (!stat.isDirectory()) throw new Error('Select a dedicated output directory, inside the authorized project');
|
|
69
|
+
const files = Object.create(null);
|
|
70
|
+
let total = 0, count = 0;
|
|
71
|
+
async function walk(directory) {
|
|
72
|
+
for (const entry of (await fs.readdir(directory,{withFileTypes:true})).sort((a,b)=>a.name.localeCompare(b.name))) {
|
|
73
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'klosedoc.json') continue;
|
|
74
|
+
const filename = await localPath(path.join(directory,entry.name));
|
|
75
|
+
if (entry.isSymbolicLink()) throw new Error('Symlinks are not supported');
|
|
76
|
+
if (entry.isDirectory()) { await walk(filename); continue; }
|
|
77
|
+
if (!entry.isFile() || !extensions.has(path.extname(filename).toLowerCase())) throw new Error('Unsupported output file: ' + entry.name);
|
|
78
|
+
if (++count > 1000) throw new Error('Maximum 1000 files');
|
|
79
|
+
const bytes = await readFile(filename,10*1024*1024);
|
|
80
|
+
total += bytes.length;
|
|
81
|
+
if (total > maxBytes) throw new Error('Output exceeds 50 MB');
|
|
82
|
+
const relative = path.relative(target,filename).split(path.sep).join('/');
|
|
83
|
+
files[relative] = [bytes,{mtime:new Date('2020-01-01T00:00:00Z')}];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await walk(target);
|
|
87
|
+
if (!count) throw new Error('Output directory is empty');
|
|
88
|
+
return { bytes: zipSync(files,{level:6}), filename:'slides.zip', config };
|
|
89
|
+
}
|
|
90
|
+
async function request(route = '', input, key, bytes) {
|
|
91
|
+
const options = { method: input === undefined && !bytes ? 'GET' : 'POST', redirect:'error',
|
|
92
|
+
headers:{Authorization:'Bearer ' + token}, signal:AbortSignal.timeout(120000) };
|
|
93
|
+
if (key) options.headers['Idempotency-Key'] = key;
|
|
94
|
+
if (bytes) { options.headers['Content-Type']='application/octet-stream'; options.body=bytes; }
|
|
95
|
+
else if (input !== undefined) { options.headers['Content-Type']='application/json'; options.body=JSON.stringify(input); }
|
|
96
|
+
const response = await fetch(base.toString().replace(/\/$/,'') + route,options);
|
|
97
|
+
const payload = await response.json();
|
|
98
|
+
if (!response.ok) throw new Error(payload.error?.message || 'KloseDoc request failed (' + response.status + ')');
|
|
99
|
+
return payload.data;
|
|
100
|
+
}
|
|
101
|
+
function register(name, description, schema, action, readOnly = false) {
|
|
102
|
+
server.registerTool(name,{ description, inputSchema:schema,
|
|
103
|
+
annotations:{readOnlyHint:readOnly,destructiveHint:!readOnly,idempotentHint:true,openWorldHint:true} },async input => {
|
|
104
|
+
try {
|
|
105
|
+
const result = await action(input);
|
|
106
|
+
return { content:[{type:'text',text:JSON.stringify(result)}], structuredContent:result };
|
|
107
|
+
} catch (error) { return { isError:true,content:[{type:'text',text:error.message.replaceAll(token,'[redacted]')}] }; }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const project = z.string().uuid();
|
|
111
|
+
const idempotencyKey = z.string().regex(/^[A-Za-z0-9_.:-]{8,160}$/).describe('Reuse the same key when retrying the same operation.');
|
|
112
|
+
register('klosedoc_presentation_create','Create a Web Slides project. Never publishes content.',{
|
|
113
|
+
title:z.string().min(1).max(160),folderId:project.optional(),idempotencyKey,
|
|
114
|
+
},input=>request('',{title:input.title,...(input.folderId?{folderId:input.folderId}:{})},input.idempotencyKey));
|
|
115
|
+
register('klosedoc_publication_status','Read status and version IDs, or list presentations. Preview links require the owner to sign in.',{
|
|
116
|
+
projectId:project.optional(),query:z.string().optional(),page:z.number().int().min(1).optional(),
|
|
117
|
+
},input=>request(input.projectId ? '/' + input.projectId : '?' + new URLSearchParams({query:input.query||'',page:String(input.page||1)})),true);
|
|
118
|
+
register('klosedoc_presentation_upload','Upload a local HTML, ZIP or dedicated output directory inside a locally authorized project output directory as a draft. Reads klosedoc.json; never auto-publishes or creates a project implicitly.',{
|
|
119
|
+
projectId:project.optional(),path:z.string().optional(),configDirectory:z.string().describe('Absolute local project directory previously authorized by the user in a terminal. Never sent to the server.'),entry:z.string().optional(),idempotencyKey,
|
|
120
|
+
},async input=>{
|
|
121
|
+
const packed=await bundle(input.path,input.configDirectory);
|
|
122
|
+
const id=project.parse(input.projectId || packed.config.projectId);
|
|
123
|
+
const query=new URLSearchParams({filename:packed.filename,entry:input.entry||packed.config.entry||''});
|
|
124
|
+
const output=await request('/'+id+'/upload?'+query,undefined,input.idempotencyKey,packed.bytes);
|
|
125
|
+
return {...output,checksum:crypto.createHash('sha256').update(packed.bytes).digest('hex')};
|
|
126
|
+
});
|
|
127
|
+
for (const operation of ['publish','rollback']) register('klosedoc_publication_'+operation,
|
|
128
|
+
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.',{
|
|
129
|
+
projectId:project,versionId:project,expectedCurrentVersionId:project.nullable(),expectedDraftVersionId:project.nullable(),idempotencyKey,
|
|
130
|
+
},input=>request('/'+input.projectId+'/'+operation,{versionId:input.versionId,expectedCurrentVersionId:input.expectedCurrentVersionId,expectedDraftVersionId:input.expectedDraftVersionId},input.idempotencyKey));
|
|
131
|
+
register('klosedoc_publication_revoke','Take the presentation offline without deleting versions. Requires publish permission.',{
|
|
132
|
+
projectId:project,idempotencyKey,
|
|
133
|
+
},input=>request('/'+input.projectId+'/revoke',{},input.idempotencyKey));
|
|
134
|
+
await server.connect(new StdioServerTransport());
|