drafted 1.17.16 → 1.17.17
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/mcp/server.mjs +47 -7
- package/mcp/test-file-path-text.mjs +58 -0
- package/package.json +2 -2
package/mcp/server.mjs
CHANGED
|
@@ -129,6 +129,37 @@ export function runWithRequestState(initial, fn) {
|
|
|
129
129
|
// Stripped from the advertised schema on remote transports.
|
|
130
130
|
const LOCAL_ONLY_PARAMS = ['file_path'];
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Read a local file as UTF-8 text for a `file_path` write to /wiki or /skills.
|
|
134
|
+
*
|
|
135
|
+
* Those roots are markdown — they take `content`, not bytes — so this is a
|
|
136
|
+
* different seam from the /projects upload path, which base64s the file and
|
|
137
|
+
* sends a contentType. Without this, writing a page or a SKILL.md that already
|
|
138
|
+
* exists on disk means pasting the whole file through the caller's context,
|
|
139
|
+
* which for anything sizeable is both slow and lossy.
|
|
140
|
+
*
|
|
141
|
+
* stdio only: `file_path` is stripped from the advertised schema on remote
|
|
142
|
+
* transports (LOCAL_ONLY_PARAMS), because the server has no access to the
|
|
143
|
+
* caller's filesystem.
|
|
144
|
+
*
|
|
145
|
+
* Exported for `mcp/test-file-path-text.mjs`.
|
|
146
|
+
*/
|
|
147
|
+
export function textFromLocalFile(filePath) {
|
|
148
|
+
let resolved = filePath;
|
|
149
|
+
try { resolved = resolve(filePath); } catch { /* keep as-is */ }
|
|
150
|
+
if (!existsSync(resolved)) throw new Error(`file not found: ${filePath}`);
|
|
151
|
+
if (statSync(resolved).isDirectory()) {
|
|
152
|
+
throw new Error(`${filePath} is a directory — pass a file`);
|
|
153
|
+
}
|
|
154
|
+
const buf = readFileSync(resolved);
|
|
155
|
+
// A NUL byte means this is not text. Writing it would store mojibake that
|
|
156
|
+
// only shows up much later, when someone reads the page back.
|
|
157
|
+
if (buf.includes(0)) {
|
|
158
|
+
throw new Error(`${filePath} looks binary — /wiki and /skills take text (markdown)`);
|
|
159
|
+
}
|
|
160
|
+
return buf.toString('utf8');
|
|
161
|
+
}
|
|
162
|
+
|
|
132
163
|
// Per-tool params whose value is arbitrary / deeply-nested JSON. Zod renders
|
|
133
164
|
// these (z.any(), z.array(z.any()), z.object({}).passthrough()) as an UNTYPED
|
|
134
165
|
// schema — an empty `{}` or an array whose `items` has no `type`. ChatGPT's
|
|
@@ -2697,7 +2728,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2697
2728
|
to: z.string().optional().describe('[mv] destination path'),
|
|
2698
2729
|
query: z.string().optional().describe('[search] term to match against names/content'),
|
|
2699
2730
|
content: z.string().optional().describe('[write] inline HTML/markdown/text'),
|
|
2700
|
-
file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only)'),
|
|
2731
|
+
file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only). Under /projects it uploads bytes (images, PDFs, office files); under /wiki and /skills it reads the file as UTF-8 text, so a markdown file on disk can be written straight to a page or a SKILL.md without pasting it.'),
|
|
2701
2732
|
base64: z.string().optional().describe('[write] base64-encoded binary content'),
|
|
2702
2733
|
googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] explicit Google Workspace type (also derived from .google-* filename extension)'),
|
|
2703
2734
|
title: z.string().optional().describe('[write + googleType] title for a new native file'),
|
|
@@ -2808,7 +2839,11 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2808
2839
|
return ok(page.content);
|
|
2809
2840
|
}
|
|
2810
2841
|
case 'write': {
|
|
2811
|
-
|
|
2842
|
+
let text = content;
|
|
2843
|
+
if (text === undefined && file_path) {
|
|
2844
|
+
try { text = textFromLocalFile(file_path); } catch (e) { return err(e); }
|
|
2845
|
+
}
|
|
2846
|
+
if (!text) return err(new Error('write to /wiki/... requires content or file_path'));
|
|
2812
2847
|
// OKF boundary rule: /a/b.md ≡ a/b — strip a trailing .md so both spellings work.
|
|
2813
2848
|
const canonPath = wikiPath.replace(/\.md$/, '');
|
|
2814
2849
|
const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
|
|
@@ -2817,9 +2852,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2817
2852
|
// here silently forced every MCP-created page to type "Page" and a
|
|
2818
2853
|
// path-segment title, discarding the author's frontmatter. The server
|
|
2819
2854
|
// lifts both from the preamble and falls back to the path segment.
|
|
2820
|
-
const body = { path: canonPath, content };
|
|
2855
|
+
const body = { path: canonPath, content: text };
|
|
2821
2856
|
const result = existing
|
|
2822
|
-
? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content }, orgHeader)
|
|
2857
|
+
? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content: text }, orgHeader)
|
|
2823
2858
|
: await api('POST', '/api/wiki/pages', body, orgHeader);
|
|
2824
2859
|
return ok(result || { path: canonPath, written: true });
|
|
2825
2860
|
}
|
|
@@ -2894,7 +2929,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2894
2929
|
return ok(s?.content || '');
|
|
2895
2930
|
}
|
|
2896
2931
|
case 'write': {
|
|
2897
|
-
if (!slug
|
|
2932
|
+
if (!slug) return err(new Error('write /skills/<slug> requires a slug'));
|
|
2933
|
+
let text = content;
|
|
2934
|
+
if (text === undefined && file_path) {
|
|
2935
|
+
try { text = textFromLocalFile(file_path); } catch (e) { return err(e); }
|
|
2936
|
+
}
|
|
2937
|
+
if (!text) return err(new Error('write /skills/<slug> requires content or file_path'));
|
|
2898
2938
|
const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2899
2939
|
if (!existing) {
|
|
2900
2940
|
const g2 = g2Block(gs);
|
|
@@ -2905,8 +2945,8 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2905
2945
|
// (the API requires a non-empty description on create).
|
|
2906
2946
|
const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
|
|
2907
2947
|
const result = existing
|
|
2908
|
-
? await api('PUT', `/api/skills/${existing.id}`, { content }, orgHeader)
|
|
2909
|
-
: await api('POST', '/api/skills', { slug, name, content, description }, orgHeader);
|
|
2948
|
+
? await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader)
|
|
2949
|
+
: await api('POST', '/api/skills', { slug, name, content: text, description }, orgHeader);
|
|
2910
2950
|
// Writing an archived skill restores it (update flow un-archives).
|
|
2911
2951
|
if (result?.id && existing?.archived) {
|
|
2912
2952
|
try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for textFromLocalFile() — the `file_path` text seam used by
|
|
3
|
+
* fs(write) on /wiki and /skills.
|
|
4
|
+
*
|
|
5
|
+
* node mcp/test-file-path-text.mjs
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: /projects uploads bytes (base64 + contentType), /wiki and
|
|
8
|
+
* /skills take markdown text. Routing a file down the wrong seam stores
|
|
9
|
+
* mojibake that only surfaces when someone reads the page back, so the binary
|
|
10
|
+
* guard below is the load-bearing assertion, not a nicety.
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { textFromLocalFile } from './server.mjs';
|
|
17
|
+
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'drafted-filepath-'));
|
|
19
|
+
|
|
20
|
+
// Plain markdown round-trips byte-for-byte.
|
|
21
|
+
const md = join(dir, 'page.md');
|
|
22
|
+
const body = '# Title\n\nBody with a UTF-8 em dash — and an accent é.\n';
|
|
23
|
+
writeFileSync(md, body, 'utf8');
|
|
24
|
+
assert.equal(textFromLocalFile(md), body);
|
|
25
|
+
|
|
26
|
+
// Multi-byte characters survive — a latin1 read would mangle these.
|
|
27
|
+
assert.ok(textFromLocalFile(md).includes('—'));
|
|
28
|
+
assert.ok(textFromLocalFile(md).includes('é'));
|
|
29
|
+
|
|
30
|
+
// Empty file returns '' rather than throwing. The caller decides: both write
|
|
31
|
+
// sites treat falsy text as "no content supplied" and error with guidance.
|
|
32
|
+
const empty = join(dir, 'empty.md');
|
|
33
|
+
writeFileSync(empty, '');
|
|
34
|
+
assert.equal(textFromLocalFile(empty), '');
|
|
35
|
+
|
|
36
|
+
// Binary is refused. A PNG header contains NUL bytes.
|
|
37
|
+
const png = join(dir, 'shot.png');
|
|
38
|
+
writeFileSync(png, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]));
|
|
39
|
+
assert.throws(() => textFromLocalFile(png), /looks binary/);
|
|
40
|
+
|
|
41
|
+
// Directories are refused with a clear message, not an EISDIR stack trace.
|
|
42
|
+
const sub = join(dir, 'nested');
|
|
43
|
+
mkdirSync(sub);
|
|
44
|
+
assert.throws(() => textFromLocalFile(sub), /is a directory/);
|
|
45
|
+
|
|
46
|
+
// Missing files name the path the caller passed, not the resolved one.
|
|
47
|
+
assert.throws(() => textFromLocalFile(join(dir, 'nope.md')), /file not found/);
|
|
48
|
+
|
|
49
|
+
// Relative paths resolve against cwd rather than being read blindly.
|
|
50
|
+
assert.throws(() => textFromLocalFile('definitely-not-here-xyz.md'), /file not found/);
|
|
51
|
+
|
|
52
|
+
console.log('file_path text seam ok — utf8 preserved, binary and dirs refused');
|
|
53
|
+
|
|
54
|
+
// Importing server.mjs opens the MCP WebSocket at module scope, which pins the
|
|
55
|
+
// event loop open forever. mcp/test-org-guards.mjs only exits because it
|
|
56
|
+
// finishes in ~176ms — before the socket connects — so it passes on a race
|
|
57
|
+
// rather than by design. Exit explicitly instead of inheriting that luck.
|
|
58
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.17",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"react": "^18.3.1",
|
|
73
73
|
"react-dom": "^18.3.1",
|
|
74
74
|
"sharp": "^0.34.5",
|
|
75
|
-
"ws": "^8.
|
|
75
|
+
"ws": "^8.21.3",
|
|
76
76
|
"yaml": "^2.8.3",
|
|
77
77
|
"zod": "^4.3.6"
|
|
78
78
|
},
|