drafted 1.17.15 → 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 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
@@ -2328,7 +2359,7 @@ tool('comment', {
2328
2359
  action: z.enum(['list', 'add', 'resolve', 'reopen', 'delete']).describe('list: read comments (paginated). add: post a comment or a reply. resolve/reopen: flip a comment\'s state. delete: remove one.'),
2329
2360
  target: z.string().optional().describe('[list, add] The frame: a path (/projects/<project>/<layer>/<lane>/<file>), a frame URL, or a frame UUID. Required for add. For list, omit it to read the whole project.'),
2330
2361
  projectId: z.string().optional().describe('[list] Project to read when no target is given — UUID, slug, or /projects/<name>. Defaults to the session\'s bound project.'),
2331
- body: z.string().optional().describe('[add] The comment text. Be specific and actionable: quote what you mean and say what should change. Prefix with [MUST] / [NICE] / [Q] so a reviewer can triage.'),
2362
+ body: z.string().optional().describe('[add] The comment text. Be specific and actionable: quote what you mean and say what should change. Prefix with [MUST] / [NICE] / [Q] so a reviewer can triage. To notify a person, put their EMAIL ADDRESS after an @ — "@sam@acme.com". A bare "@sam" only works when exactly one person on the frame has that local-part; when two do, it is ignored rather than guessed at.'),
2332
2363
  replyTo: z.string().optional().describe('[add] Comment UUID this replies to. A reply cannot carry its own anchor — it inherits the thread\'s.'),
2333
2364
  anchorRef: z.string().optional().describe('[add] CSS selector for the element this comment is about, e.g. "h1" or ".email-body > p:nth-of-type(3)". The frame must load /vendor/frame-guide.js (markdown and HTML content frames get it automatically). Without it the comment lands on the whole frame.'),
2334
2365
  anchorText: z.string().optional().describe('[add] The text of the anchored element, stored as a snapshot. Supply it whenever you pass anchorRef: a selector is an nth-of-type path that breaks when the frame is re-authored, and this snapshot is the only way to re-find what you meant.'),
@@ -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
- if (!content) return err(new Error('write to /wiki/... requires content'));
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 || !content) return err(new Error('write /skills/<slug> requires content'));
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.15",
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.16.0",
75
+ "ws": "^8.21.3",
76
76
  "yaml": "^2.8.3",
77
77
  "zod": "^4.3.6"
78
78
  },