drafted 1.14.28 → 1.14.30
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 +38 -4
- package/mcp/test-org-guards.mjs +49 -1
- package/package.json +1 -1
package/mcp/server.mjs
CHANGED
|
@@ -202,6 +202,21 @@ export function boundOrgRejected({ message, boundOrgId, hasExplicitOrg }) {
|
|
|
202
202
|
return /not a member of org/i.test(msg) && msg.includes(boundOrgId);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// Which org should a mutation receipt name?
|
|
206
|
+
//
|
|
207
|
+
// A UUID-addressed resource (pageId=…) self-derives its org SERVER-side, so a
|
|
208
|
+
// write can land in an org this session isn't bound to. Echoing the session's
|
|
209
|
+
// working org in that case reproduces the bug the URL builder had: `org:` names
|
|
210
|
+
// one org while `url:` points at another. Prefer the org carried on the returned
|
|
211
|
+
// row; fall back to the session context only when the response doesn't name one.
|
|
212
|
+
// Pure + exported so the truth table is assertable (mcp/test-org-guards.mjs).
|
|
213
|
+
export function receiptOrg({ resourceOrgId, sessionOrg, orgList }) {
|
|
214
|
+
const rid = resourceOrgId || null;
|
|
215
|
+
if (!rid || rid === sessionOrg?.id) return sessionOrg;
|
|
216
|
+
const found = (orgList || []).find((o) => o.id === rid);
|
|
217
|
+
return found || { id: rid, name: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
205
220
|
export function createMcpServer(transport) {
|
|
206
221
|
// Remote transports (hosted HTTP MCP for claude.ai / ChatGPT) run on the
|
|
207
222
|
// server, not the user's machine, so local-filesystem params like `file_path`
|
|
@@ -230,7 +245,9 @@ BREADCRUMBS: When a frame you write or read corresponds to a file in the user's
|
|
|
230
245
|
CONTEXT RULES (follow these before every action):
|
|
231
246
|
- WIKI CHECK: Before acting on any request, search the org wiki for relevant conventions, existing designs, and prior decisions. Use wiki(action="search") with relevant keywords.
|
|
232
247
|
- LAYER CONTEXT: Before reading or mutating a frame, read all anchored frames in the same layer. Anchored frames are per-layer required reading (style guides, design systems, conventions). The server enforces this mechanically for writes/edits/deletes/moves — but proactively reading anchored frames before any frame operation prevents wasted work.
|
|
233
|
-
IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs
|
|
248
|
+
IMPORTANT: Any URL containing /f/{uuid} is a Drafted frame link — ALWAYS use read(path=URL) to get frame content, focus(target=URL) to pan the canvas to it. Never curl or WebFetch Drafted URLs.
|
|
249
|
+
|
|
250
|
+
LINKING: link the user to the narrowest thing you touched, never the project as a stand-in. Wrote or edited ONE frame → give its \`frameUrl\` (from the write/read/ls response). Touched a whole lane → its \`laneUrl\`; a whole layer → its \`layerUrl\` (both on the matching \`ls\` entries). Only when the work spans the project is the project URL the right link. A project link where a frame link was available makes the user hunt the surface for what you just did.`,
|
|
234
251
|
}, {
|
|
235
252
|
// Initialize instructions: the agent-identity contract, so an agent learns its own
|
|
236
253
|
// tab name + the right way to read it WITHOUT having to "think to" call a tool.
|
|
@@ -1245,6 +1262,10 @@ function withFrameBreadcrumb(result, { hint = false } = {}) {
|
|
|
1245
1262
|
const out = { ...result, breadcrumb: `drafted:${result.id}` };
|
|
1246
1263
|
if (hint) {
|
|
1247
1264
|
out.breadcrumbHint = `If this frame maps to a file in the user's codebase, paste "drafted:${result.id}" as a comment in that file so future agents discover the link.`;
|
|
1265
|
+
// The write path is where the project URL keeps getting substituted for the frame
|
|
1266
|
+
// the agent just wrote. Name the right link at the moment it's produced.
|
|
1267
|
+
const link = out.frameUrl || `${getServerUrl()}/f/${result.id}`;
|
|
1268
|
+
out.linkHint = `Link the user to THIS frame — ${link} — not to the project URL.`;
|
|
1248
1269
|
}
|
|
1249
1270
|
return out;
|
|
1250
1271
|
}
|
|
@@ -3383,7 +3404,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
3383
3404
|
|
|
3384
3405
|
tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="open") to see layers, workflow, and confirm you\'re in the right project.', {
|
|
3385
3406
|
projectId: PROJECT_OVERRIDE_PARAM,
|
|
3386
|
-
path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID).'),
|
|
3407
|
+
path: z.string().optional().default('/').describe('Directory path: / (layers), /{layer} (lanes), /{layer}/{lane} (frames). Frame entries include frameUrl (canvas deep link) and id (frame UUID); layer entries include layerUrl and lane entries laneUrl — relay those to the user instead of the project URL when your work was scoped to one layer or lane.'),
|
|
3387
3408
|
recursive: z.boolean().optional().describe('List contents of subdirectories. When true, forces summary mode (metadata only, no full content) to keep results under the 25k token cap.'),
|
|
3388
3409
|
summary: z.boolean().optional().describe('Include size, updatedAt, title for frames'),
|
|
3389
3410
|
pattern: z.string().optional().describe('Glob pattern to filter filenames (e.g. "*.html")'),
|
|
@@ -4127,8 +4148,11 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4127
4148
|
// reports the session's INHERITED org, so echoing it made a correctly-placed write
|
|
4128
4149
|
// look misfiled — and an agent trusting that echo would "fix" a page that was fine.
|
|
4129
4150
|
const working = workingOrgId();
|
|
4151
|
+
// Cached for 30s, so this is a cache hit on all but the first wiki call in a
|
|
4152
|
+
// session — cheap enough to always have on hand for the receipt below.
|
|
4153
|
+
const orgList = await getOrgList();
|
|
4130
4154
|
let orgCtx = working
|
|
4131
|
-
? (
|
|
4155
|
+
? (orgList.find(o => o.id === working) || { id: working, name: null })
|
|
4132
4156
|
: await getCurrentOrgContext();
|
|
4133
4157
|
if (args.org) {
|
|
4134
4158
|
const d = await api('GET', '/api/orgs');
|
|
@@ -4141,7 +4165,17 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4141
4165
|
const orgId = orgCtx?.id || null;
|
|
4142
4166
|
// Mutation responses include `org` so the agent always sees where the
|
|
4143
4167
|
// write landed — eliminates silent cross-org confusion.
|
|
4144
|
-
|
|
4168
|
+
//
|
|
4169
|
+
// A UUID-addressed resource (pageId=…) SELF-DERIVES its org server-side, so
|
|
4170
|
+
// for those the write can land in an org this session isn't bound to. Echoing
|
|
4171
|
+
// the session's working org there is the same defect the URL builder had: the
|
|
4172
|
+
// receipt would name one org while `url` pointed at another. Prefer the org
|
|
4173
|
+
// carried on the returned row, and fall back to the session context only when
|
|
4174
|
+
// the response doesn't name one.
|
|
4175
|
+
const withOrg = (result, resourceOrgId) => ({
|
|
4176
|
+
...result,
|
|
4177
|
+
org: receiptOrg({ resourceOrgId: resourceOrgId || result?.orgId, sessionOrg: orgCtx, orgList }),
|
|
4178
|
+
});
|
|
4145
4179
|
// Org-qualify every browser URL this tool emits (shadows the module fn for
|
|
4146
4180
|
// all call sites below) so links are portable across the viewer's orgs.
|
|
4147
4181
|
// A UUID-addressed page SELF-DERIVES its org server-side, so its URL must be
|
package/mcp/test-org-guards.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
|
|
|
5
5
|
import { mkdtempSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { projectlessMutationNeedsOrg, boundOrgRejected } from './server.mjs';
|
|
8
|
+
import { projectlessMutationNeedsOrg, boundOrgRejected, receiptOrg } from './server.mjs';
|
|
9
9
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
10
10
|
|
|
11
11
|
// One rule governs create AND fork (a fork is a create). A write proceeds when its
|
|
@@ -97,6 +97,54 @@ assert.equal(
|
|
|
97
97
|
assert.equal(boundOrgRejected({ message: 'Project not found', boundOrgId: stale }), false, 'unrelated error → no heal');
|
|
98
98
|
assert.equal(boundOrgRejected({ message: `not a member of org "${stale}"` }), false, 'nothing bound → nothing to drop');
|
|
99
99
|
|
|
100
|
+
// ── receiptOrg: the mutation receipt must name where the write LANDED ─────────
|
|
101
|
+
// Regression for the sibling of the foreign-org bug: a UUID-addressed page
|
|
102
|
+
// self-derives its org server-side, so echoing the session's working org made
|
|
103
|
+
// `org:` disagree with the (correct) `url:` on a cross-org edit.
|
|
104
|
+
{
|
|
105
|
+
const ORGS = [
|
|
106
|
+
{ id: 'org-a', name: 'Alpha' },
|
|
107
|
+
{ id: 'org-b', name: 'Bravo' },
|
|
108
|
+
];
|
|
109
|
+
const SESSION = { id: 'org-a', name: 'Alpha' };
|
|
110
|
+
|
|
111
|
+
// resource lives in the session's own org → session context, unchanged
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
receiptOrg({ resourceOrgId: 'org-a', sessionOrg: SESSION, orgList: ORGS }),
|
|
114
|
+
SESSION,
|
|
115
|
+
'same-org write should echo the session org',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// response carries no org (path-addressed) → fall back to session context
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
receiptOrg({ resourceOrgId: null, sessionOrg: SESSION, orgList: ORGS }),
|
|
121
|
+
SESSION,
|
|
122
|
+
'no resource org should fall back to the session org',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// THE BUG: resource lives elsewhere → must name the resource's org, not the session's
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
receiptOrg({ resourceOrgId: 'org-b', sessionOrg: SESSION, orgList: ORGS }),
|
|
128
|
+
{ id: 'org-b', name: 'Bravo' },
|
|
129
|
+
'cross-org write must echo the org the write landed in',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// resource org not in the membership list → still name it, honestly, rather
|
|
133
|
+
// than silently substituting the session org
|
|
134
|
+
assert.deepEqual(
|
|
135
|
+
receiptOrg({ resourceOrgId: 'org-z', sessionOrg: SESSION, orgList: ORGS }),
|
|
136
|
+
{ id: 'org-z', name: null },
|
|
137
|
+
'unknown resource org should be named with a null name, not swapped out',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// no session org at all (unbound) and a resource org present
|
|
141
|
+
assert.deepEqual(
|
|
142
|
+
receiptOrg({ resourceOrgId: 'org-b', sessionOrg: null, orgList: ORGS }),
|
|
143
|
+
{ id: 'org-b', name: 'Bravo' },
|
|
144
|
+
'unbound session should still name the resource org',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
100
148
|
console.log('org-guard policy OK');
|
|
101
149
|
// Importing server.mjs builds the stdio MCP singleton, which opens a WS reconnect
|
|
102
150
|
// loop that keeps the event loop alive. Assertions are done — exit deterministically.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.30",
|
|
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": [
|