@klosedoc/mcp 0.1.0 → 0.1.1

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.0` 启动适配器;不要使用未固定版本的 `latest`。
15
+ 在已开放接入的 KloseDoc 页面选择客户端并复制配置。客户端通过 `npx -y @klosedoc/mcp@0.1.1` 启动适配器;不要使用未固定版本的 `latest`。0.1.1 必须发布并验证后才会开放此版本配置;旧版 0.1.0 仍需要终端授权。
16
16
 
17
17
  配置所需环境变量:
18
18
 
@@ -23,13 +23,26 @@
23
23
 
24
24
  本地 HTTP 地址只能连接本机 KloseDoc,不能用于给其他人分享。
25
25
 
26
- ## 本地项目授权
26
+ ## 上传时确认(0.1.1 起)
27
27
 
28
- 无需在网页填写目录。进入要发布的项目,在终端执行(安装包发布后):
28
+ 配置完成后,直接让 Agent 上传项目中的演示,不必先执行授权命令。客户端需要支持 MCP 表单交互确认(form elicitation)。
29
+
30
+ 1. Agent 指定绝对项目路径 `configDirectory` 和项目内的产物 `path`(例如 `dist`)。
31
+ 2. 客户端弹出确认,展示项目目录、实际读取路径、接收站点和演示项目。确认也包括读取项目根目录的 `klosedoc.json`。
32
+ 3. 你勾选允许并提交后,适配器才读取文件并上传草稿;拒绝、关闭、超时或取消调用都会停止,不会自动改用其他授权途径。
33
+ 4. 发布仍是单独操作,需要你明确要求,以及具备发布权限的令牌。
34
+
35
+ 每次上传都确认,不保存长期目录授权;没有 `approved: true` 之类可由 Agent 代填的参数。只列出演示、创建空项目或查询状态无需本地目录授权。不同客户端是否支持确认,取决于初始化时声明的能力;适配器依赖客户端真实展示给用户,无法替代客户端权限控制或操作系统沙箱。
36
+
37
+ `path` 默认是 `dist`。自定义产物目录必须显式传入 `path`,不能通过 `klosedoc.json` 把已确认的读取范围扩大或换成另一个目录。禁止整个项目目录、用户主目录、文件系统根目录、隐藏路径及符号链接。
38
+
39
+ ## 不支持交互确认的客户端
40
+
41
+ 适配器会明确提示缺少确认能力,不会默认放开本地读取。可换用支持 form elicitation 的客户端,或使用备用的终端授权:
29
42
 
30
43
  ```sh
31
44
  mkdir -p dist
32
- npx -y @klosedoc/mcp@0.1.0 authorize . dist
45
+ npx -y @klosedoc/mcp@0.1.1 authorize . dist
33
46
  ```
34
47
 
35
48
  内部测试使用已安装的入口替代 npx,例如:
@@ -38,11 +51,11 @@ npx -y @klosedoc/mcp@0.1.0 authorize . dist
38
51
  node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
39
52
  ```
40
53
 
41
- 命令会显示项目的规范绝对路径和产物目录,输入 yes 后授权。每个项目单独执行一次;无需移动项目或修改全局 MCP 配置。授权仅允许读取指定产物目录,以及项目根目录的 klosedoc.json。修改 klosedoc.json 的 output 不会扩大授权。
54
+ 命令会显示项目的规范绝对路径和产物目录,输入 yes 后授权。此备用方式每个项目单独执行一次;无需移动项目或修改全局 MCP 配置。授权仅允许读取指定产物目录,以及项目根目录的 klosedoc.json。修改 klosedoc.json 的 output 不会扩大授权。支持交互确认的客户端始终走当次确认,不因旧的终端授权而跳过确认。
42
55
 
43
56
  使用相同入口的 `authorizations` 查看授权,`unauthorize .` 撤销当前项目;之后的上传会立即拒绝,已发布内容不受影响。授权记录保存在本机 `~/.config/klosedoc/authorizations.json`,不发送至服务器。不要把该文件交给 Agent 修改。交互确认不是操作系统沙箱;拥有同一用户任意文件写权限或终端控制权限的程序仍能改变授权记录,需由客户端限制这些权限。
44
57
 
45
- 不兼容旧配置:删除 MCP 环境变量中的 KLOSEDOCPUBLISH_ROOT,然后重新按项目授权。上传必须提供授权命令显示的绝对项目路径 configDirectory,path/output 相对于项目;不再默认使用 MCP 进程工作目录。
58
+ 不支持旧的 KLOSEDOCPUBLISH_ROOT 环境变量。上传必须提供绝对项目路径 configDirectory,path/output 相对于项目;不默认使用 MCP 进程工作目录。本地路径不会发送到 KloseDoc 服务端,只在本地 MCP 适配器与客户端之间用于操作和确认。
46
59
 
47
60
  ## 关联项目
48
61
 
@@ -58,7 +71,7 @@ node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
58
71
 
59
72
  已有项目也可以使用配置文件:
60
73
 
61
- 在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html`),不包含令牌。上传时传入已授权项目的绝对路径 `configDirectory`,该路径仅供本地适配器使用。
74
+ 在演示详情页“来源与同步 → Agent 项目配置”复制 `klosedoc.json`,保存到本地项目根目录。文件包含 `projectId`、相对于配置文件的 `output`(例如 `dist`)和 `entry`(例如 `index.html`),不包含令牌。上传时传入项目的绝对路径 `configDirectory`;非默认产物目录同时传入 `path`,以便准确展示确认范围。
62
75
 
63
76
  让 Agent 调用 `klosedoc_presentation_upload` 上传输出目录,生成草稿。确认预览后再调用 `klosedoc_publication_publish`;默认令牌没有发布权限。也可用 `klosedoc_presentation_create` 创建项目、`klosedoc_publication_status` 读取状态、`klosedoc_publication_rollback` 恢复草稿、`klosedoc_publication_revoke` 下线。
64
77
 
@@ -66,7 +79,7 @@ node /path/to/installed/@klosedoc/mcp/server.mjs authorize . dist
66
79
 
67
80
  ## 安全边界
68
81
 
69
- 适配器仅打包本地已授权的产物目录,不执行构建脚本,不支持产物符号链接,目录上传要求独立输出目录。目录打包跳过隐藏文件、`node_modules` 和 `klosedoc.json`。单文件最大 10 MB,包解压后最大 50 MB、1000 个文件;账户配额与权限由服务端检查。授权期间不要让不可信进程并发替换目录;本地检查不能替代操作系统级文件隔离。
82
+ 适配器仅打包本次已确认的路径,或不支持确认客户端的终端授权产物目录,不执行构建脚本,不支持产物符号链接,目录上传要求独立输出目录。目录打包跳过隐藏文件、`node_modules` 和 `klosedoc.json`。单文件最大 10 MB,包解压后最大 50 MB、1000 个文件;账户配额与权限由服务端检查。确认前不读取配置或产物内容;确认后重新检查路径。授权期间不要让不可信进程并发替换目录;本地检查不能替代操作系统级文件隔离。
70
83
 
71
84
  本包仅含客户端适配器,不包含 KloseDoc 的服务端、数据库、密钥或其他产品源码。
72
85
 
package/authorization.mjs CHANGED
@@ -34,6 +34,56 @@ export async function authorization(projectDirectory) {
34
34
  if (output === project || !within(project, output)) throw new Error('Invalid authorized output directory');
35
35
  return { project, output };
36
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
+ }
50
+ 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');
53
+ }
54
+ const selected = output || 'dist';
55
+ if (path.isAbsolute(selected)) throw new Error('Output path must be relative to the project');
56
+ 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');
58
+ 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');
61
+ }
62
+ await checkedPath(target);
63
+ 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');
66
+ }
67
+ 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
+ }
37
87
  export async function authorizationCommand(command, args) {
38
88
  if (command === 'authorizations') { console.log(JSON.stringify((await load()).projects, null, 2)); return; }
39
89
  if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('Authorization changes require an interactive terminal; no unattended approval is supported.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klosedoc/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
@@ -7,12 +7,12 @@ import { zipSync } from 'fflate';
7
7
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
9
  import { z } from 'zod';
10
- import { authorization, authorizationCommand, checkedPath, within } from './authorization.mjs';
10
+ import { uploadAuthorization, authorizationCommand, checkedPath, within } from './authorization.mjs';
11
11
  import metadata from './package.json' with { type: 'json' };
12
12
 
13
13
  if (process.argv.includes('--version')) { console.log(metadata.version); process.exit(0); }
14
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.');
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.');
16
16
  process.exit(0);
17
17
  }
18
18
  if (['authorize', 'unauthorize', 'authorizations'].includes(process.argv[2])) {
@@ -49,10 +49,13 @@ async function configFor(directory) {
49
49
  return value;
50
50
  } catch (error) { if (error.code === 'ENOENT') return {}; throw new Error('Invalid klosedoc.json: ' + error.message); }
51
51
  }
52
- async function bundle(value, configPath) {
53
- const grant = await authorization(configPath);
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 });
54
54
  const config = await configFor(grant.project);
55
- const selected = value || config.output || path.relative(grant.project, grant.output);
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.');
57
+ }
58
+ const selected = grant.oneTime ? grant.selected : value || config.output || path.relative(grant.project, grant.output);
56
59
  if (path.isAbsolute(selected)) throw new Error('Output path must be relative to the project');
57
60
  const target = path.resolve(grant.project, selected);
58
61
  async function localPath(filename) {
@@ -72,7 +75,6 @@ async function bundle(value, configPath) {
72
75
  for (const entry of (await fs.readdir(directory,{withFileTypes:true})).sort((a,b)=>a.name.localeCompare(b.name))) {
73
76
  if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'klosedoc.json') continue;
74
77
  const filename = await localPath(path.join(directory,entry.name));
75
- if (entry.isSymbolicLink()) throw new Error('Symlinks are not supported');
76
78
  if (entry.isDirectory()) { await walk(filename); continue; }
77
79
  if (!entry.isFile() || !extensions.has(path.extname(filename).toLowerCase())) throw new Error('Unsupported output file: ' + entry.name);
78
80
  if (++count > 1000) throw new Error('Maximum 1000 files');
@@ -87,9 +89,9 @@ async function bundle(value, configPath) {
87
89
  if (!count) throw new Error('Output directory is empty');
88
90
  return { bytes: zipSync(files,{level:6}), filename:'slides.zip', config };
89
91
  }
90
- async function request(route = '', input, key, bytes) {
92
+ async function request(route = '', input, key, bytes, signal) {
91
93
  const options = { method: input === undefined && !bytes ? 'GET' : 'POST', redirect:'error',
92
- headers:{Authorization:'Bearer ' + token}, signal:AbortSignal.timeout(120000) };
94
+ headers:{Authorization:'Bearer ' + token}, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120000)]) : AbortSignal.timeout(120000) };
93
95
  if (key) options.headers['Idempotency-Key'] = key;
94
96
  if (bytes) { options.headers['Content-Type']='application/octet-stream'; options.body=bytes; }
95
97
  else if (input !== undefined) { options.headers['Content-Type']='application/json'; options.body=JSON.stringify(input); }
@@ -100,9 +102,9 @@ async function request(route = '', input, key, bytes) {
100
102
  }
101
103
  function register(name, description, schema, action, readOnly = false) {
102
104
  server.registerTool(name,{ description, inputSchema:schema,
103
- annotations:{readOnlyHint:readOnly,destructiveHint:!readOnly,idempotentHint:true,openWorldHint:true} },async input => {
105
+ annotations:{readOnlyHint:readOnly,destructiveHint:!readOnly,idempotentHint:true,openWorldHint:true} },async (input, extra) => {
104
106
  try {
105
- const result = await action(input);
107
+ const result = await action(input, extra);
106
108
  return { content:[{type:'text',text:JSON.stringify(result)}], structuredContent:result };
107
109
  } catch (error) { return { isError:true,content:[{type:'text',text:error.message.replaceAll(token,'[redacted]')}] }; }
108
110
  });
@@ -115,13 +117,14 @@ register('klosedoc_presentation_create','Create a Web Slides project. Never publ
115
117
  register('klosedoc_publication_status','Read status and version IDs, or list presentations. Preview links require the owner to sign in.',{
116
118
  projectId:project.optional(),query:z.string().optional(),page:z.number().int().min(1).optional(),
117
119
  },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);
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();
122
125
  const id=project.parse(input.projectId || packed.config.projectId);
123
126
  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);
127
+ const output=await request('/'+id+'/upload?'+query,undefined,input.idempotencyKey,packed.bytes,extra.signal);
125
128
  return {...output,checksum:crypto.createHash('sha256').update(packed.bytes).digest('hex')};
126
129
  });
127
130
  for (const operation of ['publish','rollback']) register('klosedoc_publication_'+operation,