drafted 1.11.17 → 1.11.19

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.
Files changed (2) hide show
  1. package/mcp/server.mjs +58 -19
  2. package/package.json +1 -1
package/mcp/server.mjs CHANGED
@@ -766,6 +766,23 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
766
766
  const scopedPath = pid ? `${path}${sep}projectId=${pid}` : path;
767
767
  const url = `${getServerUrl()}${scopedPath}`;
768
768
  const headers = { ...getAuthHeaders(), ...extraHeaders };
769
+ // Bind every data request to the org this MCP session is actually working in
770
+ // (the active project's org, or the org chosen via get_org switch). Without
771
+ // this, project-scoped routes that resolve org from the SHARED server-side
772
+ // session row (notably /api/fs ls + frame read, via effectiveOrgId) break when
773
+ // a concurrent agent session for the same user flips that row to another org:
774
+ // the active project then "isn't in the current org", the 404 handler below
775
+ // clears it, and the agent sees the "active project no longer in the current
776
+ // org" churn. X-Drafted-Org is validated against the caller's memberships
777
+ // server-side and never mutates the session, so it scopes resolution
778
+ // per-request — exactly like the org-less /project/:slug link that already
779
+ // works. An explicit override in extraHeaders (e.g. the wiki/skill `org` arg)
780
+ // always wins, and /auth/* is left untouched so it reports true session state.
781
+ const boundOrg = getSessionState().boundOrgId;
782
+ const hasExplicitOrg = Object.keys(headers).some((k) => k.toLowerCase() === 'x-drafted-org');
783
+ if (boundOrg && !hasExplicitOrg && !path.startsWith('/auth/')) {
784
+ headers['X-Drafted-Org'] = boundOrg;
785
+ }
769
786
  const opts = { method, headers };
770
787
 
771
788
  if (body !== undefined) {
@@ -964,25 +981,6 @@ function setMcpActiveProject(projectId, meta = null) {
964
981
  if (meta?.orgId) sess.boundOrgId = meta.orgId;
965
982
  }
966
983
 
967
- // Clear the active project if its orgId no longer matches the current org.
968
- // Catches the silent drift where org switches (via get_org switch, browser
969
- // tab, parallel agent, etc.) leave the MCP session pointing at a project
970
- // the API can no longer see — which surfaces as confusing "Project not
971
- // found" errors on otherwise-valid tool calls.
972
- async function ensureProjectMatchesOrg() {
973
- const s = getState();
974
- const meta = s.projectMeta;
975
- if (!s.projectId || !meta?.orgId) return;
976
- const currentOrgId = await getCurrentOrgId();
977
- if (currentOrgId && currentOrgId !== meta.orgId) {
978
- s.projectId = null;
979
- s.projectMeta = null;
980
- const sess = getSessionState();
981
- sess.activeProjectId = null;
982
- sess.activeProjectMeta = null;
983
- }
984
- }
985
-
986
984
  // Returns { id, slug, name, orgId } for the project this MCP session most
987
985
  // recently opened — what frame mutations will actually target. Echoed on
988
986
  // every mutation so silent cross-project drift is visible.
@@ -1084,6 +1082,36 @@ async function getCurrentOrgContext() {
1084
1082
  } catch { return null; }
1085
1083
  }
1086
1084
 
1085
+ // Project-less mutations (wiki, skills) have no resource to derive the org from.
1086
+ // If the agent never bound an org this session (no project opened, no get_org
1087
+ // switch) and passed no explicit org, refuse to GUESS when the user belongs to
1088
+ // more than one org — otherwise the write silently lands in whatever org the
1089
+ // session happened to inherit (the "wiki write into the wrong org" failure).
1090
+ // Single-org users are unambiguous and proceed untouched. (DRAFT-36 Phase 4.)
1091
+ async function requireBoundOrgForProjectlessMutation(explicitOrg) {
1092
+ if (explicitOrg) return;
1093
+ const sess = getSessionState();
1094
+ if (sess.boundOrgId) return; // bound via project open / get_org switch
1095
+ if (getState().projectId) return; // an active project implies its org
1096
+ let count = sess.cachedOrgCount;
1097
+ if (count == null) {
1098
+ try {
1099
+ const d = await api('GET', '/api/orgs');
1100
+ count = (d.orgs || d || []).length;
1101
+ sess.cachedOrgCount = count;
1102
+ } catch { return; } // can't determine membership — don't block a legit write
1103
+ }
1104
+ if (count > 1) {
1105
+ throw new Error(
1106
+ `Refusing to guess the org: no project is open and no org was bound this ` +
1107
+ `session, but you belong to ${count} orgs — a project-less wiki/skill write ` +
1108
+ `would land in whichever org the session inherited. Call ` +
1109
+ `get_org(action="switch", orgId=...) or project(action="open") first ` +
1110
+ `(skill calls may also pass org=...).`
1111
+ );
1112
+ }
1113
+ }
1114
+
1087
1115
  async function getOrgSkills(orgId) {
1088
1116
  if (!orgId) return [];
1089
1117
  // No cache: gate freshness > the ~5ms HTTP roundtrip. Otherwise an attach
@@ -2869,6 +2897,11 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
2869
2897
  }, async (args) => {
2870
2898
  try {
2871
2899
  const { action } = args;
2900
+ // Org-creating/editing skill actions on the project-less surface must have a
2901
+ // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
2902
+ if (['add', 'update', 'remove', 'push', 'fork'].includes(action)) {
2903
+ await requireBoundOrgForProjectlessMutation(args.org);
2904
+ }
2872
2905
  switch (action) {
2873
2906
  case 'search': {
2874
2907
  const { query, tags, scope = 'all', limit, offset, compact } = args;
@@ -3103,6 +3136,12 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3103
3136
  try {
3104
3137
  const { action } = args;
3105
3138
 
3139
+ // Project-less wiki mutations must have a bound org when the user is
3140
+ // multi-org, so a write never silently lands in an inherited org. (Ph4.)
3141
+ if (['write', 'edit', 'mv', 'rm', 'bulk-write'].includes(action)) {
3142
+ await requireBoundOrgForProjectlessMutation(undefined);
3143
+ }
3144
+
3106
3145
  // Resolve org context once for the wiki tool. Each MCP session is bound
3107
3146
  // to one org (parallel agents on different orgs is supported by design).
3108
3147
  const orgCtx = await getCurrentOrgContext();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.11.17",
3
+ "version": "1.11.19",
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": [