drafted 1.11.23 → 1.11.25
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/active-project-store.mjs +63 -0
- package/mcp/server.mjs +47 -4
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
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 cwd so two concurrent same-machine agent sessions (each its own
|
|
11
|
+
* stdio MCP, same launch config) don't clobber each other's active project.
|
|
12
|
+
* Only the stdio process persists here; HTTP/remote user sessions are keyed by
|
|
13
|
+
* a real Drafted session id and never call into this store.
|
|
14
|
+
*
|
|
15
|
+
* Side-effect-free on import (no network, no eager writes) so it can be unit
|
|
16
|
+
* tested against the real code without booting the MCP server.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
20
|
+
import { homedir } from 'os';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_FILE = process.env.DRAFTED_MCP_STATE_FILE || join(homedir(), '.drafted', 'mcp-state.json');
|
|
23
|
+
|
|
24
|
+
function defaultCwd() {
|
|
25
|
+
try { return process.cwd(); } catch { return '__default__'; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadPersistedProject({ file = DEFAULT_FILE, cwd = defaultCwd() } = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const all = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
const e = all && all[cwd];
|
|
32
|
+
if (e && e.activeProjectId) {
|
|
33
|
+
return {
|
|
34
|
+
activeProjectId: e.activeProjectId,
|
|
35
|
+
activeProjectMeta: e.activeProjectMeta || null,
|
|
36
|
+
boundOrgId: e.boundOrgId || null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
} catch { /* no state file yet / unreadable — start fresh */ }
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function savePersistedProject(entry, { file = DEFAULT_FILE, cwd = defaultCwd() } = {}) {
|
|
44
|
+
try {
|
|
45
|
+
let all = {};
|
|
46
|
+
try { all = JSON.parse(readFileSync(file, 'utf8')) || {}; } catch { /* recreate */ }
|
|
47
|
+
if (entry && entry.activeProjectId) {
|
|
48
|
+
all[cwd] = {
|
|
49
|
+
activeProjectId: entry.activeProjectId,
|
|
50
|
+
activeProjectMeta: entry.activeProjectMeta || null,
|
|
51
|
+
boundOrgId: entry.boundOrgId || null,
|
|
52
|
+
};
|
|
53
|
+
} else {
|
|
54
|
+
delete all[cwd];
|
|
55
|
+
}
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
writeFileSync(file, JSON.stringify(all), { mode: 0o600 });
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
// Best-effort; persistence is an optimization, never block a tool on it.
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/mcp/server.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import WebSocket from 'ws';
|
|
|
21
21
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
22
22
|
import { emptyExcalidrawScene, excalidrawSceneFromMermaid, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
23
23
|
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
24
|
+
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
24
25
|
|
|
25
26
|
// Frame actions that mutate content — gated by G1 (wiki search before editing).
|
|
26
27
|
// Read-style actions (read, search, versions, get_*/read_*) are exempt.
|
|
@@ -92,6 +93,9 @@ function getOrCreateSessionState(sid) {
|
|
|
92
93
|
cachedOrgIdTime: 0,
|
|
93
94
|
wsSessionId: null,
|
|
94
95
|
pendingDeviceCode: null,
|
|
96
|
+
// Only the long-lived stdio bucket persists its active project to disk;
|
|
97
|
+
// HTTP/remote sessions (keyed by a real Drafted session id) must not.
|
|
98
|
+
_stdio: key === '__stdio__',
|
|
95
99
|
};
|
|
96
100
|
sessionStates.set(key, s);
|
|
97
101
|
}
|
|
@@ -439,6 +443,28 @@ registerAppResource(
|
|
|
439
443
|
const AUTH_FILE = process.env.DRAFTED_AUTH_FILE || join(homedir(), '.drafted', 'auth.json');
|
|
440
444
|
const PENDING_AUTH_FILE = process.env.DRAFTED_PENDING_AUTH_FILE || `${AUTH_FILE}.pending`;
|
|
441
445
|
|
|
446
|
+
// Active-project persistence (stdio only): see mcp/active-project-store.mjs.
|
|
447
|
+
// Persisted so a `system restart mode=mcp` / skill-edit pass that re-spawns the
|
|
448
|
+
// stdio MCP rehydrates the active project instead of dropping it (the recurring
|
|
449
|
+
// "active project keeps resetting" churn). Keyed by cwd; HTTP/remote sessions
|
|
450
|
+
// (real Drafted session id, never the __stdio__ bucket) never touch this file.
|
|
451
|
+
|
|
452
|
+
// Rehydrate the stdio MCP's active project at process start so the first tool
|
|
453
|
+
// call after a restart is already scoped. Seeds both standaloneState (what
|
|
454
|
+
// api() reads for projectId scoping in stdio mode) and the __stdio__ session
|
|
455
|
+
// bucket (boundOrgId → the X-Drafted-Org per-request scope). No-op when the
|
|
456
|
+
// file is absent (fresh install, or the hosted server process).
|
|
457
|
+
(function rehydrateStdioActiveProject() {
|
|
458
|
+
const p = loadPersistedProject();
|
|
459
|
+
if (!p) { getOrCreateSessionState(null); return; }
|
|
460
|
+
standaloneState.projectId = p.activeProjectId;
|
|
461
|
+
standaloneState.projectMeta = p.activeProjectMeta || null;
|
|
462
|
+
const sess = getOrCreateSessionState(null);
|
|
463
|
+
sess.activeProjectId = p.activeProjectId;
|
|
464
|
+
sess.activeProjectMeta = p.activeProjectMeta || null;
|
|
465
|
+
if (p.boundOrgId) sess.boundOrgId = p.boundOrgId;
|
|
466
|
+
})();
|
|
467
|
+
|
|
442
468
|
function getServerUrl() {
|
|
443
469
|
if (process.env.DRAFTED_SERVER) return process.env.DRAFTED_SERVER.replace(/\/$/, '');
|
|
444
470
|
if (getState().publicUrl) return getState().publicUrl.replace(/\/$/, '');
|
|
@@ -835,6 +861,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
|
|
|
835
861
|
const sess = getSessionState();
|
|
836
862
|
sess.activeProjectId = null;
|
|
837
863
|
sess.activeProjectMeta = null;
|
|
864
|
+
if (sess._stdio) savePersistedProject(null);
|
|
838
865
|
throw new Error(
|
|
839
866
|
`${msg} — the active project (${meta?.slug || pid}) is no longer in the current org. ` +
|
|
840
867
|
`Active project cleared. Call project(action="open") to set a new one, or proceed without one for org-scoped tools (wiki, skill).`
|
|
@@ -979,6 +1006,10 @@ function setMcpActiveProject(projectId, meta = null) {
|
|
|
979
1006
|
// inheriting the root login's current org. Only set when known — a (null,null)
|
|
980
1007
|
// clear must NOT wipe the bound org (the switch handler sets it explicitly).
|
|
981
1008
|
if (meta?.orgId) sess.boundOrgId = meta.orgId;
|
|
1009
|
+
// Persist for the stdio process so an MCP restart rehydrates this project.
|
|
1010
|
+
if (sess._stdio) {
|
|
1011
|
+
savePersistedProject(projectId ? { activeProjectId: projectId, activeProjectMeta: meta, boundOrgId: sess.boundOrgId } : null);
|
|
1012
|
+
}
|
|
982
1013
|
}
|
|
983
1014
|
|
|
984
1015
|
// Returns { id, slug, name, orgId } for the project this MCP session most
|
|
@@ -1107,6 +1138,18 @@ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
|
|
|
1107
1138
|
const sess = getSessionState();
|
|
1108
1139
|
if (sess.boundOrgId) return; // bound via project open / get_org switch
|
|
1109
1140
|
if (getState().projectId) return; // an active project implies its org
|
|
1141
|
+
// Remote/web sessions are isolated per connection: each one gets its OWN
|
|
1142
|
+
// server-side session row with its own org_id set on connect, so the session
|
|
1143
|
+
// org IS this session's binding — there's no shared, long-lived session to
|
|
1144
|
+
// confuse here the way stdio has. Adopt that org automatically instead of
|
|
1145
|
+
// forcing the agent to call get_org switch / project open before a project-less
|
|
1146
|
+
// wiki or skill write. The DRAFT-36 Phase 4 "refuse to guess" guard below
|
|
1147
|
+
// therefore applies to stdio only.
|
|
1148
|
+
if (isRemote) {
|
|
1149
|
+
const ctx = await getCurrentOrgContext();
|
|
1150
|
+
if (ctx?.id) sess.boundOrgId = ctx.id;
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1110
1153
|
let count = sess.cachedOrgCount;
|
|
1111
1154
|
if (count == null) {
|
|
1112
1155
|
try {
|
|
@@ -2057,7 +2100,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2057
2100
|
color: z.string().optional().describe('[write] CSS color for frame border (e.g. #ff0000, red).'),
|
|
2058
2101
|
operations: z.array(z.object({
|
|
2059
2102
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
2060
|
-
lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number +
|
|
2103
|
+
lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash "vix": that is rejected, since the same hash can recur on multiple lines.'),
|
|
2061
2104
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
2062
2105
|
})).optional().describe('[edit] hashline edit operations'),
|
|
2063
2106
|
from: z.string().optional().describe('[mv] source path /{layer}/{lane}/{filename}'),
|
|
@@ -2576,9 +2619,9 @@ tool('batch', 'Batch operations on the ACTIVE PROJECT. Response includes "projec
|
|
|
2576
2619
|
to: z.string().optional().describe('Destination path (for mv)'),
|
|
2577
2620
|
operations: z.array(z.object({
|
|
2578
2621
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']),
|
|
2579
|
-
lineHash: z.string().describe('The full line anchor from read output — line number +
|
|
2622
|
+
lineHash: z.string().describe('The full line anchor from read output — line number + 3-char hash, e.g. "182vix". NOT the bare hash.'),
|
|
2580
2623
|
newContent: z.string().optional(),
|
|
2581
|
-
})).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "
|
|
2624
|
+
})).optional().describe('Edit operations (for edit). lineHash is the full anchor (lineNum + hash), e.g. "182vix".'),
|
|
2582
2625
|
asset_path: z.string().optional().describe('Relative asset path (for upload_asset, e.g., "css/styles.css")'),
|
|
2583
2626
|
content_type: z.string().optional().describe('MIME type (for upload_asset, auto-detected if omitted)'),
|
|
2584
2627
|
frame_id: z.string().optional().describe('Frame ID to associate asset with (for upload_asset)'),
|
|
@@ -3135,7 +3178,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
3135
3178
|
frontmatter: z.any().optional().describe('[write] frontmatter object'),
|
|
3136
3179
|
operations: z.array(z.object({
|
|
3137
3180
|
type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
|
|
3138
|
-
lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number +
|
|
3181
|
+
lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash.'),
|
|
3139
3182
|
newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
|
|
3140
3183
|
})).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
|
|
3141
3184
|
from: z.string().optional().describe('[mv] source path'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.25",
|
|
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": [
|