drafted 1.18.1 → 1.18.3
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 +28 -16
- package/agent-instructions/global.md +3 -0
- package/cli/drafted.mjs +1 -1
- package/cli/repo-scan.mjs +156 -0
- package/install-mcp.sh +1269 -0
- package/mcp/active-project-store.mjs +81 -0
- package/mcp/gates.mjs +187 -0
- package/mcp/server.mjs +167 -33
- package/mcp/test-file-path-text.mjs +58 -0
- package/mcp/test-org-guards.mjs +172 -0
- package/mcp/test-project-index.mjs +88 -0
- package/mcp/test-skill-paths.mjs +29 -0
- package/mcp/widgets/canvas-overview.html +229 -0
- package/mcp/widgets/frame-preview.html +162 -0
- package/package.json +80 -16
- package/plugin/commands/create-project.md +20 -0
- package/plugin/commands/create-skill.md +18 -0
- package/plugin/commands/extract.md +16 -0
- package/plugin/commands/improve-project-harness.md +16 -0
- package/plugin/commands/improve-skill.md +14 -0
- package/plugin/commands/improve-wiki.md +14 -0
- package/plugin/commands/ingest.md +20 -0
- package/plugin/commands/onboard-drafted.md +17 -0
- package/plugin/skills/drafted/SKILL.md +90 -0
- package/server/lib/umami.mjs +162 -0
- package/src/shared/excalidraw.mjs +84 -0
- package/src/shared/gate-budget.mjs +30 -0
- package/src/shared/minion-presets.mjs +67 -0
- package/src/shared/okf-log.mjs +62 -0
- package/src/shared/record-conformance.mjs +167 -0
- package/src/shared/test-excalidraw-merge.mjs +53 -0
- package/src/shared/wiki-excalidraw.mjs +90 -0
- package/skills/import-website-to-drafted.md +0 -317
- /package/{shared → src/shared}/constants.mjs +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Active-project persistence for the stdio MCP.
|
|
3
|
+
*
|
|
4
|
+
* The active project lives only in the in-memory sessionStates Map, so a
|
|
5
|
+
* `system restart mode=mcp` / skill-edit pass that re-spawns the stdio MCP
|
|
6
|
+
* wipes it — the recurring "active project keeps resetting, re-open it" churn.
|
|
7
|
+
* Persisting it lets the long-lived stdio process rehydrate transparently after
|
|
8
|
+
* a restart.
|
|
9
|
+
*
|
|
10
|
+
* Keyed by SERVER URL + cwd so two concurrent same-machine agent sessions (each
|
|
11
|
+
* its own stdio MCP, same launch config) don't clobber each other's active
|
|
12
|
+
* project. The server is part of the key because project and org ids are only
|
|
13
|
+
* meaningful within one server's DATABASE: this repo registers both a prod
|
|
14
|
+
* (`https://drafted.live`) and a local-dev (`http://localhost:3477`) stdio MCP,
|
|
15
|
+
* and with a cwd-only key the local MCP's project+boundOrgId were rehydrated by
|
|
16
|
+
* the prod MCP at boot — which pinned the prod session's working org to an org
|
|
17
|
+
* id that exists only in the local dev DB (`get_org` reported a workingOrg with
|
|
18
|
+
* name:null that was in no membership, and every project-less call failed with
|
|
19
|
+
* `not a member of org "<local-uuid>"`). Cross-database state must never share a
|
|
20
|
+
* key. Only the stdio process persists here; HTTP/remote user sessions are keyed
|
|
21
|
+
* by a real Drafted session id and never call into this store.
|
|
22
|
+
*
|
|
23
|
+
* Side-effect-free on import (no network, no eager writes) so it can be unit
|
|
24
|
+
* tested against the real code without booting the MCP server.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
27
|
+
import { join, dirname } from 'path';
|
|
28
|
+
import { homedir } from 'os';
|
|
29
|
+
|
|
30
|
+
const DEFAULT_FILE = process.env.DRAFTED_MCP_STATE_FILE || join(homedir(), '.drafted', 'mcp-state.json');
|
|
31
|
+
|
|
32
|
+
function defaultCwd() {
|
|
33
|
+
try { return process.cwd(); } catch { return '__default__'; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// serverUrl first: a URL never contains "|", so the composite key is unambiguous.
|
|
37
|
+
function stateKey(cwd, serverUrl) {
|
|
38
|
+
return serverUrl ? `${serverUrl}|${cwd}` : cwd;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
|
|
42
|
+
try {
|
|
43
|
+
const all = JSON.parse(readFileSync(file, 'utf8'));
|
|
44
|
+
// Pre-composite-key entries (bare cwd) are NOT read back: they carry no record of
|
|
45
|
+
// which server minted their ids, and adopting one is exactly the cross-DB bleed
|
|
46
|
+
// above. Worst case the agent re-opens its project once.
|
|
47
|
+
const e = all && all[stateKey(cwd, serverUrl)];
|
|
48
|
+
if (e && e.activeProjectId) {
|
|
49
|
+
return {
|
|
50
|
+
activeProjectId: e.activeProjectId,
|
|
51
|
+
activeProjectMeta: e.activeProjectMeta || null,
|
|
52
|
+
boundOrgId: e.boundOrgId || null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
} catch { /* no state file yet / unreadable — start fresh */ }
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd(), serverUrl = '' } = {}) {
|
|
60
|
+
try {
|
|
61
|
+
let all = {};
|
|
62
|
+
try { all = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* recreate */ }
|
|
63
|
+
const key = stateKey(cwd, serverUrl);
|
|
64
|
+
if (key !== cwd) delete all[cwd]; // drop the un-namespaced legacy entry for this cwd
|
|
65
|
+
if (entry && entry.activeProjectId) {
|
|
66
|
+
all[key] = {
|
|
67
|
+
activeProjectId: entry.activeProjectId,
|
|
68
|
+
activeProjectMeta: entry.activeProjectMeta || null,
|
|
69
|
+
boundOrgId: entry.boundOrgId || null,
|
|
70
|
+
};
|
|
71
|
+
} else {
|
|
72
|
+
delete all[key];
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
75
|
+
writeFileSync(file, JSON.stringify(all), { mode: 0o600 });
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort; persistence is an optimization, never block a tool on it.
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/mcp/gates.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Pure gate logic for the Drafted compounding harness.
|
|
2
|
+
//
|
|
3
|
+
// These helpers are intentionally side-effect free (no `api()`, no network, no
|
|
4
|
+
// module state) so they can be unit tested in isolation. mcp/server.mjs imports
|
|
5
|
+
// them and wires the decisions into the tool handlers + per-session getState().
|
|
6
|
+
//
|
|
7
|
+
// See docs/plans/compounding-harness.md and the Drafted "gates-checklist" frame.
|
|
8
|
+
|
|
9
|
+
// One combined per-project budget for the auto-injected priming set:
|
|
10
|
+
// attached-skill bodies + project anchor bodies + the active layer's rules.
|
|
11
|
+
// Single source of truth lives in src/shared so the server-side deposit caps
|
|
12
|
+
// enforce the identical limit. Re-exported here for the MCP gate helpers.
|
|
13
|
+
import { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget } from '../src/shared/gate-budget.mjs';
|
|
14
|
+
export { PROJECT_CONTEXT_BUDGET_CHARS, selectWithinBudget };
|
|
15
|
+
|
|
16
|
+
// ── Per-session gate flags (reset every MCP session) ──────────────────────────
|
|
17
|
+
|
|
18
|
+
export function createGateState() {
|
|
19
|
+
return { wikiSearched: false, skillSearched: false, templateSearched: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// kind: 'wiki' | 'skill' | 'template'
|
|
23
|
+
export function markSearched(gateState, kind) {
|
|
24
|
+
if (kind === 'wiki') gateState.wikiSearched = true;
|
|
25
|
+
else if (kind === 'skill') gateState.skillSearched = true;
|
|
26
|
+
else if (kind === 'template') gateState.templateSearched = true;
|
|
27
|
+
return gateState;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Enforce gates (create chain). Return an error string if blocked, else null ─
|
|
31
|
+
|
|
32
|
+
export function g1Block(gateState, wikiIndex) {
|
|
33
|
+
if (gateState.wikiSearched) return null;
|
|
34
|
+
let msg =
|
|
35
|
+
'G1: search the org wiki before reading or editing anything. ' +
|
|
36
|
+
'Call fs(search, path="/wiki", query="<relevant terms>") first, then retry. ' +
|
|
37
|
+
'More knowledge = less searching — start by drawing on what the org already knows.';
|
|
38
|
+
if (wikiIndex) msg += `\n\nWiki index (what exists to search):\n${wikiIndex}`;
|
|
39
|
+
return msg;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function g2Block(gateState) {
|
|
43
|
+
if (gateState.skillSearched) return null;
|
|
44
|
+
return (
|
|
45
|
+
'G2: search for prior-art skills before creating one. ' +
|
|
46
|
+
'Call fs(search, path="/skills", query="<topic>") first — if a close match exists, improve it ' +
|
|
47
|
+
'(/drafted:improve-skill) instead of duplicating — then retry skill(action="add").'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function g3Block(gateState) {
|
|
52
|
+
const missing = [];
|
|
53
|
+
if (!gateState.wikiSearched) missing.push('fs(search, path="/wiki")');
|
|
54
|
+
if (!gateState.skillSearched) missing.push('fs(search, path="/skills")');
|
|
55
|
+
if (!gateState.templateSearched) missing.push('fs(ls, path="/skills")');
|
|
56
|
+
if (missing.length === 0) return null;
|
|
57
|
+
return (
|
|
58
|
+
`G3: search before creating a project. Run ${missing.join(', ')} first ` +
|
|
59
|
+
'(reuse existing knowledge, skills, and templates), then retry project(action="create").'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Per-project context budget (auto-inject set) ──────────────────────────────
|
|
64
|
+
|
|
65
|
+
function itemChars(it) {
|
|
66
|
+
if (typeof it === 'number') return it;
|
|
67
|
+
if (typeof it === 'string') return it.length;
|
|
68
|
+
if (it && typeof it === 'object') {
|
|
69
|
+
if (typeof it.chars === 'number') return it.chars;
|
|
70
|
+
if (it.content != null) return String(it.content).length;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sumChars(items) {
|
|
76
|
+
if (!Array.isArray(items)) return 0;
|
|
77
|
+
return items.reduce((total, it) => total + itemChars(it), 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function budgetRemaining(currentTotal, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
81
|
+
return Math.max(0, budget - currentTotal);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function wouldExceedBudget(currentTotal, addChars, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
85
|
+
return currentTotal + addChars > budget;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function budgetError(currentTotal, addChars, label, budget = PROJECT_CONTEXT_BUDGET_CHARS) {
|
|
89
|
+
const remaining = budgetRemaining(currentTotal, budget);
|
|
90
|
+
return (
|
|
91
|
+
`Per-project context budget exceeded: ${label} needs ${addChars} chars but only ${remaining} ` +
|
|
92
|
+
`of ${budget} remain. Tighten existing attached skills / anchors / layer rules first ` +
|
|
93
|
+
`(see /drafted:improve-project-harness).`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── G6 layer-rule default detection ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
// A layer prompt is "default" (skip G6 inject) when it is empty or byte-identical
|
|
100
|
+
// to the template's default prompt for that layer key.
|
|
101
|
+
export function isLayerPromptDefault(layerPrompt, templateDefaultPrompt) {
|
|
102
|
+
const p = (layerPrompt ?? '').trim();
|
|
103
|
+
if (p === '') return true;
|
|
104
|
+
const d = (templateDefaultPrompt ?? '').trim();
|
|
105
|
+
return d !== '' && p === d;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function shouldInjectLayerPrompt(layerPrompt, templateDefaultPrompt) {
|
|
109
|
+
return !isLayerPromptDefault(layerPrompt, templateDefaultPrompt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Wiki index formatting (bounded map injected with the G1 block) ────────────
|
|
113
|
+
|
|
114
|
+
export function formatWikiIndex(pages, max = 50) {
|
|
115
|
+
if (!Array.isArray(pages) || pages.length === 0) return '(wiki is empty — no pages yet)';
|
|
116
|
+
const lines = pages.slice(0, max).map((p) => {
|
|
117
|
+
const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
|
|
118
|
+
const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
|
|
119
|
+
return ` ${path}${title}`;
|
|
120
|
+
});
|
|
121
|
+
const more = pages.length > max ? `\n …and ${pages.length - max} more` : '';
|
|
122
|
+
return lines.join('\n') + more;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Project index formatting ──────────────────────────────────────────────────
|
|
126
|
+
// `ls /projects` is an ls, not a stat: one addressable line per project. The
|
|
127
|
+
// previous shape returned the raw /api/projects rows — ~2.4KB each once the
|
|
128
|
+
// per-project `layers` config (912B avg on prod) is pretty-printed — so an org
|
|
129
|
+
// with 180 projects blew the 90KB tool-result cap and came back truncated
|
|
130
|
+
// mid-JSON at 38. Names and paths are what an agent needs to pick one; layers,
|
|
131
|
+
// format, template and share rollups belong on `ls` of the single project.
|
|
132
|
+
|
|
133
|
+
function relAge(then, now = Date.now()) {
|
|
134
|
+
const t = then ? new Date(then).getTime() : NaN;
|
|
135
|
+
if (!Number.isFinite(t)) return '';
|
|
136
|
+
const d = Math.max(0, Math.floor((now - t) / 86400000));
|
|
137
|
+
if (d === 0) return 'today';
|
|
138
|
+
if (d < 7) return `${d}d`;
|
|
139
|
+
if (d < 60) return `${Math.floor(d / 7)}w`;
|
|
140
|
+
if (d < 730) return `${Math.floor(d / 30)}mo`;
|
|
141
|
+
return `${Math.floor(d / 365)}y`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function projectPath(p) {
|
|
145
|
+
const org = p.orgSlug || p.orgId || '';
|
|
146
|
+
const name = p.slug || p.name || p.id;
|
|
147
|
+
const folder = p.folder ? `${p.folder}/` : '';
|
|
148
|
+
return `/o/${org}/projects/${folder}${name}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One line per project, most-recently-touched first, with a header naming where
|
|
153
|
+
* this session is bound. `boundPath` comes from the MCP session's own state —
|
|
154
|
+
* never the shared active-project row (DRAFT-36: nothing another session can
|
|
155
|
+
* rewrite may drive what this one reports).
|
|
156
|
+
*/
|
|
157
|
+
export function formatProjectIndex(projects, { max = 50, boundPath = null, now = Date.now() } = {}) {
|
|
158
|
+
if (!Array.isArray(projects) || projects.length === 0) {
|
|
159
|
+
return '(no projects — create one with fs(mkdir, path="/o/<org>/projects/<name>"))';
|
|
160
|
+
}
|
|
161
|
+
const sorted = [...projects].sort((a, b) => {
|
|
162
|
+
const at = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
|
163
|
+
const bt = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
|
164
|
+
return bt - at;
|
|
165
|
+
});
|
|
166
|
+
const shown = sorted.slice(0, max);
|
|
167
|
+
const paths = shown.map(projectPath);
|
|
168
|
+
const width = Math.min(60, Math.max(...paths.map((s) => s.length)));
|
|
169
|
+
const lines = shown.map((p, i) => {
|
|
170
|
+
const n = Number(p.frameCount);
|
|
171
|
+
const frames = Number.isFinite(n) && p.frameCount != null ? `${n} frame${n === 1 ? '' : 's'}` : '';
|
|
172
|
+
const age = relAge(p.updatedAt || p.createdAt, now);
|
|
173
|
+
const detail = [frames, age].filter(Boolean).join(' ');
|
|
174
|
+
return ` ${paths[i].padEnd(width)} ${detail}`.trimEnd();
|
|
175
|
+
});
|
|
176
|
+
const orgs = new Set(projects.map((p) => p.orgSlug || p.orgId).filter(Boolean));
|
|
177
|
+
const header = [
|
|
178
|
+
`${projects.length} project${projects.length === 1 ? '' : 's'}`,
|
|
179
|
+
orgs.size > 1 ? `${orgs.size} orgs` : null,
|
|
180
|
+
boundPath ? `bound: ${boundPath}` : null,
|
|
181
|
+
].filter(Boolean).join(' · ');
|
|
182
|
+
const more = projects.length > max
|
|
183
|
+
? `\n …and ${projects.length - max} more (recency order — narrow with pattern="<glob>" or fs(search, path="/projects", query="<terms>"))`
|
|
184
|
+
: '';
|
|
185
|
+
return `${header}\n\n${lines.join('\n')}${more}`;
|
|
186
|
+
}
|
|
187
|
+
|
package/mcp/server.mjs
CHANGED
|
@@ -18,10 +18,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
18
18
|
import { z } from 'zod';
|
|
19
19
|
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
20
20
|
import WebSocket from 'ws';
|
|
21
|
-
import { LAYERS } from '../shared/constants.mjs';
|
|
21
|
+
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
22
|
import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
23
23
|
import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
|
|
24
|
-
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
24
|
+
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
25
25
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
26
26
|
|
|
27
27
|
// Frame actions that mutate content — gated by G1 (wiki search before editing).
|
|
@@ -273,6 +273,19 @@ export function stripUrlOrigin(p) {
|
|
|
273
273
|
try { return new URL(s).pathname; } catch { return s; }
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// A skill is a DIRECTORY: /skills/<slug>/<file>. SKILL.md is the skill's content
|
|
277
|
+
// (the skills table row); every other path is a supporting file (README.md,
|
|
278
|
+
// references/, scripts/) living in skill_files. The handler used to keep only
|
|
279
|
+
// segment 0, so `write /skills/<slug>/README.md` silently rewrote SKILL.md — and
|
|
280
|
+
// the create-time README gate then looked unsatisfiable from this tool.
|
|
281
|
+
// Pure + exported (asserted in mcp/test-skill-paths.mjs).
|
|
282
|
+
export function splitSkillPath(p) {
|
|
283
|
+
const rest = /^\/skills\/?$/.test(p || '') ? '' : String(p || '').replace(/^\/skills\/?/, '');
|
|
284
|
+
const segs = rest.split('/').filter(Boolean);
|
|
285
|
+
const filePath = segs.slice(1).join('/');
|
|
286
|
+
return { slug: segs[0] || '', filePath, isSkillMd: !filePath || filePath === 'SKILL.md' };
|
|
287
|
+
}
|
|
288
|
+
|
|
276
289
|
|
|
277
290
|
export function createMcpServer(transport) {
|
|
278
291
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
@@ -1483,6 +1496,18 @@ function withProjectOverride(meta, fn) {
|
|
|
1483
1496
|
);
|
|
1484
1497
|
}
|
|
1485
1498
|
|
|
1499
|
+
// Run `fn` with NO project scope, so api() stops auto-appending ?projectId=<bound>.
|
|
1500
|
+
// Org-wide questions (searching /projects for a project that exists SOMEWHERE) must
|
|
1501
|
+
// not silently inherit whatever project this session happens to be bound to —
|
|
1502
|
+
// that scopes the answer to one project and reports "no matches" for the rest.
|
|
1503
|
+
function withoutProjectScope(fn) {
|
|
1504
|
+
const base = getState();
|
|
1505
|
+
return requestState.run(
|
|
1506
|
+
{ ...base, projectId: null, projectMeta: null, _session: base._session || getSessionState() },
|
|
1507
|
+
fn,
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1486
1511
|
// Tools that address frames/assets/layout inside ONE project. These accept the
|
|
1487
1512
|
// per-call `projectId` override; everything else resolves org-side.
|
|
1488
1513
|
const PROJECT_SCOPED_TOOLS = new Set(['fs']);
|
|
@@ -1531,8 +1556,11 @@ async function connectAgentWs() {
|
|
|
1531
1556
|
// suggestedName (basename of the working directory) lets the server offer it in the
|
|
1532
1557
|
// name-before-work gate message and as the tab placeholder.
|
|
1533
1558
|
try { agentWs.send(JSON.stringify({ type: 'agent-hello', agentLabel: getAgentLabel(), suggestedName: getSuggestedSessionName() })); } catch {}
|
|
1559
|
+
// Same open-race guard as the hello above: the socket can leave 'open' before
|
|
1560
|
+
// this second send lands, and an unguarded throw here kills the stdio child
|
|
1561
|
+
// (taking the agent's whole MCP connection with it) for a presence message.
|
|
1534
1562
|
if (getState().projectId) {
|
|
1535
|
-
agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() }));
|
|
1563
|
+
try { agentWs.send(JSON.stringify({ type: 'join', projectId: getState().projectId, agent: true, agentLabel: getAgentLabel() })); } catch {}
|
|
1536
1564
|
}
|
|
1537
1565
|
});
|
|
1538
1566
|
|
|
@@ -1603,11 +1631,7 @@ async function joinAgentWsRoom(projectId) {
|
|
|
1603
1631
|
}
|
|
1604
1632
|
if (!agentWs) return;
|
|
1605
1633
|
const msg = JSON.stringify({ type: 'join', projectId, agent: true, agentLabel: getAgentLabel() });
|
|
1606
|
-
|
|
1607
|
-
agentWs.send(msg);
|
|
1608
|
-
} else if (agentWs.readyState === WebSocket.CONNECTING) {
|
|
1609
|
-
agentWs.once('open', () => agentWs.send(msg));
|
|
1610
|
-
}
|
|
1634
|
+
sendAgentWsWhenOpen(msg);
|
|
1611
1635
|
}
|
|
1612
1636
|
|
|
1613
1637
|
// Tools that are pure introspection/sign-in, not "the agent started working" — excluded from
|
|
@@ -1625,11 +1649,26 @@ async function announceSubstantiveWork() {
|
|
|
1625
1649
|
}
|
|
1626
1650
|
if (!agentWs) return;
|
|
1627
1651
|
const msg = JSON.stringify({ type: 'agent-active', projectId: getState().projectId || null });
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1652
|
+
sendAgentWsWhenOpen(msg);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Fire-and-forget send that can NEVER crash the MCP process: captures the socket so a
|
|
1656
|
+
// concurrent connectAgentWs() can't swap the global mid-flight (the once('open') closure
|
|
1657
|
+
// used to read the global — a reassignment left it sending on a still-CONNECTING socket,
|
|
1658
|
+
// the uncaught throw killed the stdio child, and every subsequent MCP call was dead).
|
|
1659
|
+
// A dropped ping is harmless (presence/announce only); a dead MCP server is not.
|
|
1660
|
+
function sendAgentWsWhenOpen(msg) {
|
|
1661
|
+
const ws = agentWs;
|
|
1662
|
+
if (!ws) return;
|
|
1663
|
+
try {
|
|
1664
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1665
|
+
ws.send(msg);
|
|
1666
|
+
} else if (ws.readyState === WebSocket.CONNECTING) {
|
|
1667
|
+
ws.once('open', () => {
|
|
1668
|
+
try { if (ws.readyState === WebSocket.OPEN) ws.send(msg); } catch { /* raced close */ }
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
} catch { /* fire-and-forget */ }
|
|
1633
1672
|
}
|
|
1634
1673
|
|
|
1635
1674
|
// Clone session and connect WebSocket on startup (delayed to let server be ready).
|
|
@@ -2736,7 +2775,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2736
2775
|
ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames. Apply ONLY against a fresh fs(read) of the frame: each op = {type, lineHash, newContent} where type is one of replace | delete | insertAfter | insertBefore, and lineHash is the FULL anchor from the read output (line number + hash, e.g. the token left of the |, like 42srt) — NOT a bare hash. delete/replace consume the target line; at most one replace/delete per line per edit (combine into one replace). If an op targets a line that changed since read it is rejected — re-read and retry. Element ops for excalidraw ({id,x,y,...}), or structured ops for office'),
|
|
2737
2776
|
state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
|
|
2738
2777
|
metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
|
|
2739
|
-
readme: z.string().optional().describe('[write] README.md markdown
|
|
2778
|
+
readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> — optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
|
|
2740
2779
|
recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
|
|
2741
2780
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
|
|
2742
2781
|
pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
|
|
@@ -2910,16 +2949,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2910
2949
|
|
|
2911
2950
|
// ── Root: /skills/... ──────────────────────────────────────────
|
|
2912
2951
|
if (p.startsWith('/skills')) {
|
|
2913
|
-
const slug
|
|
2952
|
+
const { slug, filePath, isSkillMd } = splitSkillPath(p);
|
|
2953
|
+
const fileUrl = (id) => `/api/skills/${id}/files/${filePath.split('/').map(encodeURIComponent).join('/')}`;
|
|
2914
2954
|
if (['write', 'mv', 'rm'].includes(action)) {
|
|
2915
2955
|
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
2916
2956
|
}
|
|
2917
2957
|
switch (action) {
|
|
2918
2958
|
case 'ls': {
|
|
2919
|
-
// A specific slug is a targeted ls:
|
|
2959
|
+
// A specific slug is a targeted ls: the skill's directory — SKILL.md
|
|
2960
|
+
// plus its supporting files, so a README is discoverable.
|
|
2920
2961
|
if (slug) {
|
|
2921
2962
|
const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2922
|
-
|
|
2963
|
+
if (!s) return ok([]);
|
|
2964
|
+
const listed = await api('GET', `/api/skills/${s.id}/files`, undefined, orgHeader).catch(() => null);
|
|
2965
|
+
const files = (listed?.files || []).map((f) => f.path || f).filter((f) => f !== 'SKILL.md');
|
|
2966
|
+
return ok({ slug: s.slug, name: s.name, description: s.description, files: ['SKILL.md', ...files] });
|
|
2923
2967
|
}
|
|
2924
2968
|
const list = await api('GET', '/api/skills', undefined, orgHeader);
|
|
2925
2969
|
const skills = Array.isArray(list) ? list : (list?.skills || []);
|
|
@@ -2927,6 +2971,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2927
2971
|
}
|
|
2928
2972
|
case 'read': {
|
|
2929
2973
|
if (!slug) return err(new Error('read /skills/<slug>'));
|
|
2974
|
+
if (!isSkillMd) {
|
|
2975
|
+
const owner = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2976
|
+
if (!owner?.id) return err(new Error(`no Drafted-authored skill "${slug}" — supporting files exist only for skills stored in Drafted; a repo-indexed skill lives in git (read its SKILL.md with fs(read, path="/skills/${slug}"))`));
|
|
2977
|
+
const f = await api('GET', fileUrl(owner.id), undefined, orgHeader).catch(() => null);
|
|
2978
|
+
if (f?.content == null) return err(new Error(`file not found in skill ${slug}: ${filePath} (fs(ls, path="/skills/${slug}") lists what is there)`));
|
|
2979
|
+
return ok(f.content);
|
|
2980
|
+
}
|
|
2930
2981
|
let s = null;
|
|
2931
2982
|
try { s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader); } catch { /* not in the skills table — fall through to the repo index */ }
|
|
2932
2983
|
if (s) return ok(s?.content || '');
|
|
@@ -2945,6 +2996,15 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2945
2996
|
}
|
|
2946
2997
|
if (!text) return err(new Error('write /skills/<slug> requires content or file_path'));
|
|
2947
2998
|
const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
|
|
2999
|
+
if (!isSkillMd) {
|
|
3000
|
+
// Supporting file inside an existing skill. It can't create the skill —
|
|
3001
|
+
// the skill row is what owns the file — so say what call does.
|
|
3002
|
+
if (!existing?.id) {
|
|
3003
|
+
return err(new Error(`skill ${slug} does not exist yet — create it first with fs(write, path="/skills/${slug}", content=<SKILL.md markdown>) (add readme=<README.md markdown> to ship a README in the same call), then write supporting files into it.`));
|
|
3004
|
+
}
|
|
3005
|
+
const written = await api('PUT', fileUrl(existing.id), { content: text }, orgHeader);
|
|
3006
|
+
return ok(written || { slug, path: filePath, written: true });
|
|
3007
|
+
}
|
|
2948
3008
|
if (!existing) {
|
|
2949
3009
|
const g2 = g2Block(gs);
|
|
2950
3010
|
if (g2) return err(new Error(g2));
|
|
@@ -2953,9 +3013,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2953
3013
|
// Derive a description from the slug when the agent didn't pass one
|
|
2954
3014
|
// (the API requires a non-empty description on create).
|
|
2955
3015
|
const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3016
|
+
let result;
|
|
3017
|
+
if (existing) {
|
|
3018
|
+
result = await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader);
|
|
3019
|
+
// A `readme` passed with an update is a README write, not a no-op.
|
|
3020
|
+
if (readme) await api('PUT', `/api/skills/${existing.id}/files/README.md`, { content: readme }, orgHeader);
|
|
3021
|
+
} else {
|
|
3022
|
+
result = await api('POST', '/api/skills', { slug, name, content: text, description, ...(readme ? { readme } : {}) }, orgHeader);
|
|
3023
|
+
}
|
|
2959
3024
|
// Writing an archived skill restores it (update flow un-archives).
|
|
2960
3025
|
if (result?.id && existing?.archived) {
|
|
2961
3026
|
try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
|
|
@@ -2964,6 +3029,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2964
3029
|
}
|
|
2965
3030
|
case 'mv': {
|
|
2966
3031
|
if (!to || !to.startsWith('/skills')) return err(new Error('mv within /skills requires to=/skills/...'));
|
|
3032
|
+
if (!isSkillMd) return err(new Error('mv moves a whole skill (/skills/<slug>), not a file inside one — rewrite the file at its new path and rm the old one'));
|
|
2967
3033
|
const toSlug = to.replace(/^\/skills\/?/, '').split('/')[0];
|
|
2968
3034
|
const result = await api('POST', '/api/skills/fork', { from: slug, to: toSlug, ...(org ? { org } : {}) }, orgHeader);
|
|
2969
3035
|
return ok(result);
|
|
@@ -2971,6 +3037,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2971
3037
|
case 'rm': {
|
|
2972
3038
|
const s = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader);
|
|
2973
3039
|
if (!s?.id) return err(new Error(`skill not found: ${slug}`));
|
|
3040
|
+
if (!isSkillMd) return ok(await api('DELETE', fileUrl(s.id), undefined, orgHeader));
|
|
2974
3041
|
return ok(await api('DELETE', `/api/skills/${s.id}`, undefined, orgHeader));
|
|
2975
3042
|
}
|
|
2976
3043
|
case 'search': {
|
|
@@ -2990,21 +3057,75 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
2990
3057
|
// Resolve the project from the path: <folder?>/<project>/<layer>/<lane>/<file>
|
|
2991
3058
|
let projectRef = null, layer, lane, filename;
|
|
2992
3059
|
if (parts.length === 0) {
|
|
2993
|
-
// /projects or /o/<org>/projects —
|
|
2994
|
-
//
|
|
2995
|
-
//
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3060
|
+
// /projects or /o/<org>/projects — the root itself. Only ls and search are
|
|
3061
|
+
// meaningful here; everything else needs a project in the path. This used
|
|
3062
|
+
// to return the listing for EVERY action, so fs(search, path="/projects")
|
|
3063
|
+
// silently answered a search with the full project dump.
|
|
3064
|
+
if (action !== 'ls' && action !== 'search') {
|
|
3065
|
+
return err(new Error(`fs ${action} needs a project in the path — /projects is the root (e.g. /o/<org>/projects/<project>/<layer>/<lane>/<file>). Use ls to list or search to find one.`));
|
|
3066
|
+
}
|
|
3067
|
+
// Archived projects live in the Archive bin and are hidden from the agent's
|
|
3068
|
+
// active list — mirroring the web UI. An org-scoped root lists ONLY that
|
|
3069
|
+
// org's projects (Shape A).
|
|
3070
|
+
const listing = await api('GET', '/api/projects');
|
|
3071
|
+
let rows = (Array.isArray(listing?.projects) ? listing.projects : []).filter(x => x.folder !== '__archived');
|
|
3072
|
+
if (orgFromPath) {
|
|
3001
3073
|
const kept = [];
|
|
3002
|
-
for (const x of
|
|
3074
|
+
for (const x of rows) {
|
|
3003
3075
|
if (await pathOrgMatches(orgFromPath, x)) kept.push(x);
|
|
3004
3076
|
}
|
|
3005
|
-
|
|
3077
|
+
rows = kept;
|
|
3078
|
+
}
|
|
3079
|
+
// The bound project is read from THIS session's state, never the shared
|
|
3080
|
+
// active-project row (DRAFT-36 concurrency invariant).
|
|
3081
|
+
const bound = getState().projectMeta;
|
|
3082
|
+
const boundPath = bound?.id ? projectPath(bound) : null;
|
|
3083
|
+
|
|
3084
|
+
if (action === 'ls') {
|
|
3085
|
+
if (pattern) {
|
|
3086
|
+
const rx = new RegExp('^' + String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
|
|
3087
|
+
rows = rows.filter(x => rx.test(x.slug || '') || rx.test(x.name || ''));
|
|
3088
|
+
}
|
|
3089
|
+
return ok(formatProjectIndex(rows, { boundPath }));
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
// search: "is there already a project for X?" — match project names first,
|
|
3093
|
+
// then frame labels across the org, so the answer is a handful of
|
|
3094
|
+
// addressable paths instead of the whole inventory.
|
|
3095
|
+
const q = String(query || '').trim();
|
|
3096
|
+
if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/projects", query="<terms>")'));
|
|
3097
|
+
const needle = q.toLowerCase();
|
|
3098
|
+
const hitProjects = rows.filter(x =>
|
|
3099
|
+
[x.name, x.slug, x.description].some(v => String(v || '').toLowerCase().includes(needle))
|
|
3100
|
+
);
|
|
3101
|
+
let frames = [];
|
|
3102
|
+
let frameError = null;
|
|
3103
|
+
try {
|
|
3104
|
+
// Unscoped: this is the org-wide "does anything for X exist?" question.
|
|
3105
|
+
const res = await withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}`));
|
|
3106
|
+
frames = Array.isArray(res) ? res : (res?.results || []);
|
|
3107
|
+
} catch (e) {
|
|
3108
|
+
// Project matches still answer "does this exist?", so don't fail the whole
|
|
3109
|
+
// call — but say the frame leg broke rather than implying zero hits.
|
|
3110
|
+
frameError = e?.message || String(e);
|
|
3006
3111
|
}
|
|
3007
|
-
|
|
3112
|
+
if (orgFromPath) {
|
|
3113
|
+
const allowed = new Set(rows.map(x => x.id));
|
|
3114
|
+
frames = frames.filter(f => allowed.has(f.projectId));
|
|
3115
|
+
}
|
|
3116
|
+
const out = [];
|
|
3117
|
+
out.push(hitProjects.length
|
|
3118
|
+
? `Projects matching "${q}":\n${formatProjectIndex(hitProjects, { boundPath })}`
|
|
3119
|
+
: `No project name or description matches "${q}".`);
|
|
3120
|
+
if (frames.length) {
|
|
3121
|
+
const lines = frames.slice(0, 25).map(f =>
|
|
3122
|
+
` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
|
|
3123
|
+
);
|
|
3124
|
+
out.push(`\nFrames matching "${q}" (${frames.length}${frames.length > 25 ? ', first 25' : ''}):\n${lines.join('\n')}`);
|
|
3125
|
+
} else if (frameError) {
|
|
3126
|
+
out.push(`\n(frame search unavailable: ${frameError} — project matches above are complete, frame matches were not checked)`);
|
|
3127
|
+
}
|
|
3128
|
+
return ok(out.join('\n'));
|
|
3008
3129
|
}
|
|
3009
3130
|
if (parts.length >= 4) {
|
|
3010
3131
|
projectRef = parts.length === 4 ? parts[0] : parts[1]; // no-folder vs folder form
|
|
@@ -3200,8 +3321,21 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
|
|
|
3200
3321
|
return ok(result);
|
|
3201
3322
|
}
|
|
3202
3323
|
case 'search': {
|
|
3203
|
-
|
|
3204
|
-
|
|
3324
|
+
// /api/fs/search has never existed — this 404'd on every call (no fs
|
|
3325
|
+
// route matches a single `/search` segment). /api/search is the real
|
|
3326
|
+
// frame-search route; scope it to the project from the path.
|
|
3327
|
+
const q = String(query || '').trim();
|
|
3328
|
+
if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/projects/<project>", query="<terms>")'));
|
|
3329
|
+
// No explicit &projectId here: run() executes inside withProjectOverride,
|
|
3330
|
+
// so api() already appends the path's project. Adding a second one makes
|
|
3331
|
+
// Express parse projectId as an ARRAY and the scope match nothing.
|
|
3332
|
+
const res = await api('GET', `/api/search?q=${encodeURIComponent(q)}`);
|
|
3333
|
+
const frames = Array.isArray(res) ? res : (res?.results || []);
|
|
3334
|
+
if (!frames.length) return ok(`No frames matching "${q}".`);
|
|
3335
|
+
const lines = frames.slice(0, 50).map(f =>
|
|
3336
|
+
` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
|
|
3337
|
+
);
|
|
3338
|
+
return ok(`${frames.length} frame${frames.length === 1 ? '' : 's'} matching "${q}":\n${lines.join('\n')}`);
|
|
3205
3339
|
}
|
|
3206
3340
|
default:
|
|
3207
3341
|
return err(new Error(`fs ${action} not supported for /projects`));
|
|
@@ -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);
|