@myspec/mcp-server 0.1.5-next.62 → 0.1.5-next.63

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 (3) hide show
  1. package/README.md +27 -2
  2. package/dist/index.js +335 -104
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -89,8 +89,8 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
89
89
  |---|---|---|
90
90
  | `list_projects` | `query?`, `limit?`, `offset?` | Paginated list of projects |
91
91
  | `get_project` | `project_id` | Project + active sessions + files + attachments |
92
- | `get_spec_file` | `file_id`, optional `include_download_url` | File metadata; with `include_download_url=N` also returns a download URL targeting revision `N` |
93
- | `read_spec_file` | `file_id`, optional `revision` | UTF-8 content of the file (errors for binary or files > 1 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT` |
92
+ | `get_spec_file` | `file_id`, optional `include_download_url` | File metadata, including `content_version` (see [Concurrent edits](#concurrent-edits-content_version)); with `include_download_url=N` also returns a download URL targeting revision `N` |
93
+ | `read_spec_file` | `file_id`, optional `revision` | UTF-8 content of the file, plus `content_version` when reading the latest revision (errors for binary or files > 1 MiB — use `download_spec_file` for those). Cached on disk under `MYSPEC_DOWNLOAD_ROOT` |
94
94
  | `download_spec_file` | `file_id`, `destination_path`, optional `revision`, optional `overwrite` | Downloads the file's raw bytes to `destination_path` (absolute or cwd-relative; an existing directory gets the basename appended). Handles binary files and files up to 50 MiB. Refuses to replace an existing file unless `overwrite=true`. Returns only metadata (`saved_path`, `bytes_written`, `revision_number`) — **not** the content, so a large file can be saved locally and read in chunks |
95
95
  | `list_spec_file` | `project_id`, optional `path`, optional `trashed` | Lists a project's spec files (`file_id`, `file_path`, `file_type`, `file_size_bytes`, `revision_count`, `session_id`, `updated_at`). Filter to a directory with `path` (exact file or everything under it; `""`/`"/"`/omit = all). `trashed=true` lists the Trash Bin instead of live files |
96
96
  | `move_spec_file_to_trash` | `file_id` | Moves a spec file to the Trash Bin (recoverable — hidden from listings and AI-agent tools, restorable indefinitely). Not a permanent delete |
@@ -112,6 +112,31 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
112
112
  - Omit or `0` → metadata only.
113
113
  - Any positive integer → adds a signed `download_url` and `expires_at`. Attachments do not have revisions, so the integer value is just an on/off flag.
114
114
 
115
+ ### Concurrent edits: `content_version`
116
+
117
+ Several people (and AI sessions) can work the same project at once, so a spec file
118
+ can change between the moment you read it and the moment you write it back.
119
+
120
+ `get_spec_file` and `read_spec_file` return `content_version`, an opaque token for
121
+ the content they returned. Pass it to `update_spec_file` as `expected_version`:
122
+
123
+ - If nothing changed, the update succeeds and the file's version advances.
124
+ - If someone else saved in the meantime, the update is **rejected** — their version
125
+ is kept, nothing of theirs is overwritten — and the error names who changed it and
126
+ what to do: re-read, decide how the two changes combine, then update again.
127
+
128
+ Omitting `expected_version` falls back to the file's version as of the update call,
129
+ which only detects a change racing that one call. Always pass the token from the read
130
+ your new content was based on.
131
+
132
+ Notes:
133
+
134
+ - `content_version` is **not** `revision_count`: a revision revert decrements the
135
+ count, so the same count can describe different content.
136
+ - It is omitted rather than zeroed when the platform does not report one, and
137
+ `read_spec_file` omits it for a historical `revision` because it describes the
138
+ file's current content, not the revision you asked for.
139
+
115
140
  ## Claude Desktop config snippet
116
141
 
117
142
  ```json
package/dist/index.js CHANGED
@@ -873,6 +873,153 @@ async function withRetry(fn, config = DEFAULT_RETRY_CONFIG, shouldRetry = defaul
873
873
  };
874
874
  }
875
875
 
876
+ // ../../packages/platform-client/shared/errors.ts
877
+ var PLATFORM_ERROR_BRAND = "__myspecPlatformError";
878
+ var KNOWN_CONFLICT_REASONS = [
879
+ "revision_mismatch",
880
+ "path_taken",
881
+ "precondition_required",
882
+ "precondition_malformed"
883
+ ];
884
+ var PlatformHttpError = class extends Error {
885
+ status;
886
+ details;
887
+ /** Populated once the request layer knows them; useful in logs. */
888
+ method;
889
+ path;
890
+ /** How many attempts were made before giving up. */
891
+ attempts;
892
+ constructor(message, status, details) {
893
+ super(message);
894
+ this.name = "PlatformHttpError";
895
+ this.status = status;
896
+ this.details = details;
897
+ Object.defineProperty(this, PLATFORM_ERROR_BRAND, {
898
+ value: true,
899
+ enumerable: false
900
+ });
901
+ }
902
+ };
903
+ var PlatformConflictError = class extends PlatformHttpError {
904
+ conflict;
905
+ constructor(message, status, details, conflict) {
906
+ super(message, status, details);
907
+ this.name = "PlatformConflictError";
908
+ this.conflict = conflict;
909
+ }
910
+ };
911
+ var PlatformPreconditionRequiredError = class extends PlatformHttpError {
912
+ constructor(message, details) {
913
+ super(message, 428, details);
914
+ this.name = "PlatformPreconditionRequiredError";
915
+ }
916
+ };
917
+ function isPlatformHttpError(err) {
918
+ return typeof err === "object" && err !== null && err[PLATFORM_ERROR_BRAND] === true;
919
+ }
920
+ function conflictOf(err) {
921
+ if (err instanceof PlatformConflictError) {
922
+ return err.conflict;
923
+ }
924
+ if (statusOf(err) !== 409) {
925
+ return void 0;
926
+ }
927
+ const ownDetails = err.details;
928
+ const own = typeof ownDetails === "object" && ownDetails !== null && !Array.isArray(ownDetails) ? ownDetails : void 0;
929
+ const fromMessage = err instanceof Error ? parseHttpErrorDetails(err.message) : void 0;
930
+ return toConflictDetails(
931
+ own || fromMessage ? { ...fromMessage ?? {}, ...own ?? {} } : void 0
932
+ );
933
+ }
934
+ function isPlatformPreconditionRequiredError(err) {
935
+ return isPlatformHttpError(err) && err.status === 428;
936
+ }
937
+ function statusOf(err) {
938
+ if (typeof err !== "object" || err === null) {
939
+ return void 0;
940
+ }
941
+ const status = err.status;
942
+ if (typeof status === "number") {
943
+ return status;
944
+ }
945
+ const message = err.message;
946
+ if (typeof message === "string") {
947
+ const match = /HTTP (\d{3}):/.exec(message);
948
+ if (match) {
949
+ return Number(match[1]);
950
+ }
951
+ }
952
+ return void 0;
953
+ }
954
+ function parseHttpErrorDetails(message) {
955
+ const start = message.lastIndexOf("[details: ");
956
+ if (start === -1) {
957
+ return void 0;
958
+ }
959
+ const jsonStart = start + "[details: ".length;
960
+ for (let end = message.lastIndexOf("]"); end > jsonStart; end = message.lastIndexOf("]", end - 1)) {
961
+ try {
962
+ const parsed = JSON.parse(message.slice(jsonStart, end));
963
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
964
+ return parsed;
965
+ }
966
+ } catch {
967
+ }
968
+ }
969
+ return void 0;
970
+ }
971
+ function numberField(details, key) {
972
+ const value = details?.[key];
973
+ return typeof value === "number" ? value : void 0;
974
+ }
975
+ function stringField(details, key) {
976
+ const value = details?.[key];
977
+ return typeof value === "string" ? value : void 0;
978
+ }
979
+ function toConflictDetails(details) {
980
+ const rawReason = stringField(details, "reason");
981
+ const reason = KNOWN_CONFLICT_REASONS.includes(rawReason) ? rawReason : "unknown";
982
+ return {
983
+ reason,
984
+ currentVersion: numberField(details, "current_version"),
985
+ expectedVersion: numberField(details, "expected_version"),
986
+ currentRevision: numberField(details, "current_revision"),
987
+ currentChecksum: stringField(details, "current_checksum"),
988
+ changedBy: stringField(details, "last_changed_by"),
989
+ changedByKind: stringField(details, "last_changed_by_kind"),
990
+ changedAt: stringField(details, "last_modified_at")
991
+ };
992
+ }
993
+ function createPlatformError(message, status, details) {
994
+ if (status === 409) {
995
+ return new PlatformConflictError(message, status, details, toConflictDetails(details));
996
+ }
997
+ if (status === 428) {
998
+ return new PlatformPreconditionRequiredError(message, details);
999
+ }
1000
+ return new PlatformHttpError(message, status, details);
1001
+ }
1002
+ function truncate(text) {
1003
+ const MAX_ERROR_LENGTH = 500;
1004
+ return text.length > MAX_ERROR_LENGTH ? text.slice(0, MAX_ERROR_LENGTH) + "... [truncated]" : text;
1005
+ }
1006
+ async function throwFromResponse(response, fallbackMessage) {
1007
+ const rawBody = await response.text().catch(() => "");
1008
+ let message = fallbackMessage;
1009
+ let details;
1010
+ if (rawBody) {
1011
+ try {
1012
+ const parsed = JSON.parse(rawBody);
1013
+ details = parsed.details;
1014
+ const suffix = details ? ` [details: ${JSON.stringify(details)}]` : "";
1015
+ message = truncate(parsed.message ?? rawBody) + suffix;
1016
+ } catch {
1017
+ message = truncate(rawBody);
1018
+ }
1019
+ }
1020
+ throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details);
1021
+ }
1022
+
876
1023
  // ../../packages/shared/logger.ts
877
1024
  var LOG_LEVEL_PRIORITY = {
878
1025
  debug: 0,
@@ -1002,6 +1149,12 @@ var BaseHttpClient = class {
1002
1149
  path: path4,
1003
1150
  attempts: result.attempts
1004
1151
  });
1152
+ if (isPlatformHttpError(error)) {
1153
+ error.method = method;
1154
+ error.path = path4;
1155
+ error.attempts = result.attempts;
1156
+ throw error;
1157
+ }
1005
1158
  const wrapped = new Error(
1006
1159
  `${method} ${path4} failed after ${result.attempts} attempts: ${error.message}`
1007
1160
  );
@@ -1030,23 +1183,7 @@ var BaseHttpClient = class {
1030
1183
  signal: controller.signal
1031
1184
  });
1032
1185
  if (!response.ok) {
1033
- let errorMessage2;
1034
- const rawBody = await response.text().catch(() => "");
1035
- if (rawBody) {
1036
- try {
1037
- const errorBody = JSON.parse(rawBody);
1038
- const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
1039
- errorMessage2 = (errorBody.message ?? JSON.stringify(errorBody)) + details;
1040
- } catch {
1041
- const MAX_ERROR_LENGTH = 500;
1042
- errorMessage2 = rawBody.length > MAX_ERROR_LENGTH ? rawBody.slice(0, MAX_ERROR_LENGTH) + "... [truncated]" : rawBody;
1043
- }
1044
- } else {
1045
- errorMessage2 = "Unknown error";
1046
- }
1047
- const error = new Error(`HTTP ${response.status}: ${errorMessage2}`);
1048
- error.status = response.status;
1049
- throw error;
1186
+ await throwFromResponse(response, "Unknown error");
1050
1187
  }
1051
1188
  const contentLength = response.headers.get("Content-Length");
1052
1189
  if (contentLength === "0" || response.status === 204) {
@@ -1137,6 +1274,7 @@ function transformSpecFileResponse(raw) {
1137
1274
  fileSizeBytes: raw.file_size_bytes,
1138
1275
  checksum: raw.checksum,
1139
1276
  revisionCount: raw.revision_count,
1277
+ contentVersion: raw.content_version,
1140
1278
  createdAt: new Date(raw.created_at),
1141
1279
  updatedAt: new Date(raw.updated_at),
1142
1280
  trashedAt: raw.trashed_at ? new Date(raw.trashed_at) : void 0,
@@ -1207,6 +1345,9 @@ function transformSpecSessionResponse(raw) {
1207
1345
  };
1208
1346
  }
1209
1347
 
1348
+ // ../../packages/platform-client/project/types/lock.types.ts
1349
+ var SPEC_FILE_LOCK_TTL_MS = 2 * 6e4;
1350
+
1210
1351
  // ../../packages/platform-client/project/http/attachment.http-client.ts
1211
1352
  var UPLOAD_TIMEOUT_MS = 3e4;
1212
1353
  var HttpAttachmentClient = class {
@@ -1333,11 +1474,41 @@ var HttpAttachmentClient = class {
1333
1474
  };
1334
1475
 
1335
1476
  // ../../packages/platform-client/project/http/file.http-client.ts
1477
+ var HEADER_EXPECTED_VERSION = "X-Expected-Version";
1478
+ var HEADER_ACTOR_KIND = "X-Actor-Kind";
1479
+ function parseVersionETag(etag) {
1480
+ if (!etag) {
1481
+ return void 0;
1482
+ }
1483
+ const parsed = Number(etag.replace(/"/g, "").trim());
1484
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
1485
+ }
1336
1486
  var HttpFileClient = class {
1337
1487
  httpClient;
1338
1488
  constructor(httpClient) {
1339
1489
  this.httpClient = httpClient;
1340
1490
  }
1491
+ /**
1492
+ * Headers for the raw binary routes.
1493
+ *
1494
+ * BaseHttpClient is JSON-only, so uploadContent/saveManualRevision hand-roll
1495
+ * their fetch. Centralised here so the write precondition is attached
1496
+ * identically on both — a path that quietly omits it would look guarded while
1497
+ * writing blind, which is the worst possible failure for this mechanism.
1498
+ */
1499
+ binaryWriteHeaders(jwtToken, opts) {
1500
+ const headers = {
1501
+ "Content-Type": "application/octet-stream",
1502
+ Authorization: `Bearer ${jwtToken}`
1503
+ };
1504
+ if (opts?.expectedVersion !== void 0) {
1505
+ headers[HEADER_EXPECTED_VERSION] = String(opts.expectedVersion);
1506
+ }
1507
+ if (opts?.actorKind) {
1508
+ headers[HEADER_ACTOR_KIND] = opts.actorKind;
1509
+ }
1510
+ return headers;
1511
+ }
1341
1512
  async initiateUpload(request, jwtToken) {
1342
1513
  const body = {
1343
1514
  project_id: request.projectId,
@@ -1353,11 +1524,15 @@ var HttpFileClient = class {
1353
1524
  "/project/v1/files/initiate",
1354
1525
  body,
1355
1526
  jwtToken,
1356
- {}
1527
+ request.expectedVersion !== void 0 ? { headers: { [HEADER_EXPECTED_VERSION]: String(request.expectedVersion) } } : {}
1357
1528
  );
1358
- return { fileId: response.file_id };
1529
+ return {
1530
+ fileId: response.file_id,
1531
+ revision: response.revision,
1532
+ contentVersion: response.content_version
1533
+ };
1359
1534
  }
1360
- async uploadContent(fileId, content, jwtToken) {
1535
+ async uploadContent(fileId, content, jwtToken, opts) {
1361
1536
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1362
1537
  const url = `${baseUrl}/project/v1/files/${fileId}/content`;
1363
1538
  const blob = new Blob([content], {
@@ -1365,28 +1540,22 @@ var HttpFileClient = class {
1365
1540
  });
1366
1541
  const response = await fetch(url, {
1367
1542
  method: "PUT",
1368
- headers: {
1369
- "Content-Type": "application/octet-stream",
1370
- Authorization: `Bearer ${jwtToken}`
1371
- },
1543
+ headers: this.binaryWriteHeaders(jwtToken, opts),
1372
1544
  body: blob
1373
1545
  });
1374
1546
  if (!response.ok) {
1375
- const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
1376
- const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
1377
- throw new Error(
1378
- `HTTP ${response.status}: ${errorBody.message ?? "Upload failed"}${details}`
1379
- );
1547
+ await throwFromResponse(response, "Upload failed");
1380
1548
  }
1381
1549
  const result = await response.json();
1382
1550
  return {
1383
1551
  fileId: result.file_id,
1384
1552
  fileRevisionId: result.file_revision_id,
1385
1553
  fileUri: result.file_uri,
1386
- revisionNumber: result.revision_number
1554
+ revisionNumber: result.revision_number,
1555
+ contentVersion: result.content_version
1387
1556
  };
1388
1557
  }
1389
- async saveManualRevision(fileId, content, jwtToken) {
1558
+ async saveManualRevision(fileId, content, jwtToken, opts) {
1390
1559
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1391
1560
  const url = `${baseUrl}/project/v1/files/${fileId}/revisions`;
1392
1561
  const blob = new Blob([content], {
@@ -1394,18 +1563,11 @@ var HttpFileClient = class {
1394
1563
  });
1395
1564
  const response = await fetch(url, {
1396
1565
  method: "POST",
1397
- headers: {
1398
- "Content-Type": "application/octet-stream",
1399
- Authorization: `Bearer ${jwtToken}`
1400
- },
1566
+ headers: this.binaryWriteHeaders(jwtToken, opts),
1401
1567
  body: blob
1402
1568
  });
1403
1569
  if (!response.ok) {
1404
- const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
1405
- const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
1406
- throw new Error(
1407
- `HTTP ${response.status}: ${errorBody.message ?? "Save failed"}${details}`
1408
- );
1570
+ await throwFromResponse(response, "Save failed");
1409
1571
  }
1410
1572
  const result = await response.json();
1411
1573
  return {
@@ -1414,7 +1576,8 @@ var HttpFileClient = class {
1414
1576
  // The manual-revision endpoint does not return a current file URI (the FE only
1415
1577
  // needs the revision id + number); keep the response shape stable with ''.
1416
1578
  fileUri: "",
1417
- revisionNumber: result.revision_number
1579
+ revisionNumber: result.revision_number,
1580
+ contentVersion: result.content_version
1418
1581
  };
1419
1582
  }
1420
1583
  async getFile(fileId, jwtToken) {
@@ -1448,6 +1611,23 @@ var HttpFileClient = class {
1448
1611
  };
1449
1612
  }
1450
1613
  async downloadContent(fileId, jwtToken) {
1614
+ return (await this.downloadContentWithVersion(fileId, jwtToken)).content;
1615
+ }
1616
+ /**
1617
+ * Downloads content together with the concurrency token it was read at.
1618
+ *
1619
+ * Use this, not {@link downloadContent}, when the bytes are going to be edited
1620
+ * and written back. The platform sets `ETag: "<contentVersion>"` on this
1621
+ * response precisely so the two arrive together; fetching the content and then
1622
+ * calling `getFile` for the version is two round trips with a window in
1623
+ * between where they disagree — and basing a write on a version that does not
1624
+ * match the content you edited is the bug the precondition exists to prevent.
1625
+ *
1626
+ * `contentVersion` is undefined when the platform sent no usable ETag (an
1627
+ * older deployment). A caller that needs a guarded write should treat that as
1628
+ * "unknown" rather than substituting a guess.
1629
+ */
1630
+ async downloadContentWithVersion(fileId, jwtToken) {
1451
1631
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1452
1632
  const url = `${baseUrl}/project/v1/files/${fileId}`;
1453
1633
  const response = await fetch(url, {
@@ -1458,14 +1638,13 @@ var HttpFileClient = class {
1458
1638
  }
1459
1639
  });
1460
1640
  if (!response.ok) {
1461
- const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
1462
- const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
1463
- throw new Error(
1464
- `HTTP ${response.status}: ${errorBody.message ?? "Download failed"}${details}`
1465
- );
1641
+ await throwFromResponse(response, "Download failed");
1466
1642
  }
1467
1643
  const buffer = await response.arrayBuffer();
1468
- return new Uint8Array(buffer);
1644
+ return {
1645
+ content: new Uint8Array(buffer),
1646
+ contentVersion: parseVersionETag(response.headers.get("ETag"))
1647
+ };
1469
1648
  }
1470
1649
  async listFiles(projectId, jwtToken, opts) {
1471
1650
  const queryParams = new URLSearchParams();
@@ -1572,11 +1751,7 @@ var HttpFileClient = class {
1572
1751
  }
1573
1752
  });
1574
1753
  if (!response.ok) {
1575
- const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
1576
- const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
1577
- throw new Error(
1578
- `HTTP ${response.status}: ${errorBody.message ?? "Download failed"}${details}`
1579
- );
1754
+ await throwFromResponse(response, "Download failed");
1580
1755
  }
1581
1756
  const buffer = await response.arrayBuffer();
1582
1757
  return new Uint8Array(buffer);
@@ -1840,7 +2015,7 @@ var PlatformClient = class {
1840
2015
  try {
1841
2016
  return await this.withTokenRetry((jwt, c) => c.attachment.getById(attachmentId, jwt));
1842
2017
  } catch (err) {
1843
- if (statusOf(err) === 404) {
2018
+ if (statusOf2(err) === 404) {
1844
2019
  return null;
1845
2020
  }
1846
2021
  throw err;
@@ -1947,19 +2122,37 @@ var PlatformClient = class {
1947
2122
  jwt
1948
2123
  )
1949
2124
  );
1950
- await this.withTokenRetry(
1951
- (jwt, c) => c.file.uploadContent(initiated.fileId, args.content, jwt)
2125
+ const written = await this.withTokenRetry(
2126
+ (jwt, c) => c.file.uploadContent(initiated.fileId, args.content, jwt, {
2127
+ // Phase 1 just set this file's version; Phase 2 must be based on it, or a
2128
+ // second uploader racing us between the two phases silently wins the PUT.
2129
+ ...initiated.contentVersion === void 0 ? {} : { expectedVersion: initiated.contentVersion },
2130
+ actorKind: "ai"
2131
+ })
1952
2132
  );
1953
- return this.getFileMetadata(initiated.fileId);
2133
+ return withWrittenVersion(await this.getFileMetadata(initiated.fileId), written.contentVersion);
1954
2134
  }
1955
2135
  /**
1956
2136
  * Saves a new revision (overwrite) of an existing spec file by id. The
1957
2137
  * platform computes the checksum server-side. Returns the file's updated
1958
2138
  * metadata (including the bumped revision count).
2139
+ *
2140
+ * `expectedVersion` is the file's `contentVersion` as last read. The platform
2141
+ * rejects the write with 409 if the file has moved past it, which is what stops
2142
+ * an MCP overwrite from silently discarding an edit someone made in the webapp
2143
+ * since. Omitting it is a blind write.
2144
+ *
2145
+ * The write is attributed to an AI actor: an MCP client is an assistant acting
2146
+ * on the user's behalf, so a human editing the same file wins any race.
1959
2147
  */
1960
- async saveSpecFileRevision(fileId, content) {
1961
- await this.withTokenRetry((jwt, c) => c.file.saveManualRevision(fileId, content, jwt));
1962
- return this.getFileMetadata(fileId);
2148
+ async saveSpecFileRevision(fileId, content, opts = {}) {
2149
+ const written = await this.withTokenRetry(
2150
+ (jwt, c) => c.file.saveManualRevision(fileId, content, jwt, {
2151
+ ...opts.expectedVersion === void 0 ? {} : { expectedVersion: opts.expectedVersion },
2152
+ actorKind: "ai"
2153
+ })
2154
+ );
2155
+ return withWrittenVersion(await this.getFileMetadata(fileId), written.contentVersion);
1963
2156
  }
1964
2157
  /**
1965
2158
  * Finds a project's spec file by exact file_path, paging through the list.
@@ -2009,7 +2202,7 @@ var PlatformClient = class {
2009
2202
  try {
2010
2203
  return await call(jwt, clients);
2011
2204
  } catch (err) {
2012
- if (statusOf(err) === 401) {
2205
+ if (statusOf2(err) === 401) {
2013
2206
  const refreshed = await this.tokenManager.forceRefresh();
2014
2207
  return await call(refreshed, clients);
2015
2208
  }
@@ -2020,7 +2213,10 @@ var PlatformClient = class {
2020
2213
  function sha256Prefixed(content) {
2021
2214
  return "sha256:" + createHash("sha256").update(content).digest("hex");
2022
2215
  }
2023
- function statusOf(err) {
2216
+ function withWrittenVersion(meta, contentVersion) {
2217
+ return contentVersion === void 0 ? meta : { ...meta, contentVersion };
2218
+ }
2219
+ function statusOf2(err) {
2024
2220
  if (typeof err !== "object" || err === null) {
2025
2221
  return void 0;
2026
2222
  }
@@ -2385,6 +2581,25 @@ var getProjectTool = {
2385
2581
 
2386
2582
  // src/server/tools/get_spec_file.ts
2387
2583
  import { z as z7 } from "zod";
2584
+
2585
+ // src/server/tools/spec-file-output.ts
2586
+ function specFileSummary(file) {
2587
+ return {
2588
+ id: file.id,
2589
+ project_id: file.projectId,
2590
+ session_id: file.sessionId,
2591
+ file_path: file.filePath,
2592
+ file_type: file.fileType,
2593
+ file_size_bytes: file.fileSizeBytes,
2594
+ checksum: file.checksum,
2595
+ revision_count: file.revisionCount,
2596
+ ...file.contentVersion === void 0 ? {} : { content_version: file.contentVersion },
2597
+ created_at: file.createdAt,
2598
+ updated_at: file.updatedAt
2599
+ };
2600
+ }
2601
+
2602
+ // src/server/tools/get_spec_file.ts
2388
2603
  var inputSchema7 = {
2389
2604
  file_id: z7.string().min(1).describe("UUID of the file"),
2390
2605
  include_download_url: z7.number().int().nonnegative().optional().describe(
@@ -2397,18 +2612,7 @@ var getSpecFileTool = {
2397
2612
  inputSchema: inputSchema7,
2398
2613
  handler: async (args, ctx) => {
2399
2614
  const meta = await ctx.client.getFileMetadata(args.file_id);
2400
- const base = {
2401
- id: meta.id,
2402
- project_id: meta.projectId,
2403
- session_id: meta.sessionId,
2404
- file_type: meta.fileType,
2405
- file_path: meta.filePath,
2406
- file_size_bytes: meta.fileSizeBytes,
2407
- checksum: meta.checksum,
2408
- revision_count: meta.revisionCount,
2409
- created_at: meta.createdAt,
2410
- updated_at: meta.updatedAt
2411
- };
2615
+ const base = specFileSummary(meta);
2412
2616
  const requested = args.include_download_url ?? 0;
2413
2617
  if (requested <= 0) {
2414
2618
  return jsonResult(base);
@@ -2551,6 +2755,7 @@ var moveSpecFileToTrashTool = {
2551
2755
 
2552
2756
  // src/server/tools/read_spec_file.ts
2553
2757
  import { promises as fs3 } from "fs";
2758
+ import { createHash as createHash2 } from "crypto";
2554
2759
  import { z as z11 } from "zod";
2555
2760
  var MAX_READ_BYTES = 1 * 1024 * 1024;
2556
2761
  var inputSchema11 = {
@@ -2564,7 +2769,8 @@ var readSpecFileTool = {
2564
2769
  handler: async (args, ctx) => {
2565
2770
  const meta = await ctx.client.getFileMetadata(args.file_id);
2566
2771
  const targetRevision = args.revision ?? meta.revisionCount;
2567
- if (targetRevision === meta.revisionCount && meta.fileSizeBytes > MAX_READ_BYTES) {
2772
+ const isLatestRevision = targetRevision === meta.revisionCount;
2773
+ if (isLatestRevision && meta.fileSizeBytes > MAX_READ_BYTES) {
2568
2774
  return errorResult(buildOversizeMessage2(args.file_id, meta.fileSizeBytes));
2569
2775
  }
2570
2776
  const cacheRoot = resolveDownloadRoot();
@@ -2581,11 +2787,11 @@ var readSpecFileTool = {
2581
2787
  `read_spec_file refused to write cache: ${escape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
2582
2788
  );
2583
2789
  }
2790
+ const cached = await pathExists(cachePath) ? await fs3.readFile(cachePath) : null;
2791
+ const cacheHit = cached !== null && (!isLatestRevision || sha256Prefixed2(cached) === meta.checksum);
2584
2792
  let bytes;
2585
- let cacheHit = false;
2586
- if (await pathExists(cachePath)) {
2587
- bytes = await fs3.readFile(cachePath);
2588
- cacheHit = true;
2793
+ if (cacheHit) {
2794
+ bytes = cached;
2589
2795
  } else {
2590
2796
  const downloaded = await ctx.client.downloadFile(args.file_id, args.revision);
2591
2797
  bytes = downloaded.bytes;
@@ -2613,6 +2819,12 @@ var readSpecFileTool = {
2613
2819
  return jsonResult({
2614
2820
  file_id: meta.id,
2615
2821
  revision_number: targetRevision,
2822
+ // Pass this back as update_spec_file's expected_version so an edit made by
2823
+ // someone else since this read is reported instead of overwritten. It
2824
+ // describes the file's CURRENT content, so it is emitted only when that is
2825
+ // what was read — beside a historical revision it would invite an update
2826
+ // that overwrites everything written since.
2827
+ ...isLatestRevision && meta.contentVersion !== void 0 ? { content_version: meta.contentVersion } : {},
2616
2828
  file_path: meta.filePath,
2617
2829
  file_type: meta.fileType,
2618
2830
  cache_path: maskHomedir(cachePath),
@@ -2632,6 +2844,9 @@ function isBinary(bytes) {
2632
2844
  }
2633
2845
  return false;
2634
2846
  }
2847
+ function sha256Prefixed2(content) {
2848
+ return "sha256:" + createHash2("sha256").update(content).digest("hex");
2849
+ }
2635
2850
 
2636
2851
  // src/server/tools/restore_spec_file_from_trash.ts
2637
2852
  import { z as z12 } from "zod";
@@ -2754,22 +2969,6 @@ async function resolveSpecContent(input, maxBytes = MAX_LOCAL_SPEC_FILE_BYTES) {
2754
2969
  return { bytes: new Uint8Array(raw) };
2755
2970
  }
2756
2971
 
2757
- // src/server/tools/spec-file-output.ts
2758
- function specFileSummary(file) {
2759
- return {
2760
- id: file.id,
2761
- project_id: file.projectId,
2762
- session_id: file.sessionId,
2763
- file_path: file.filePath,
2764
- file_type: file.fileType,
2765
- file_size_bytes: file.fileSizeBytes,
2766
- checksum: file.checksum,
2767
- revision_count: file.revisionCount,
2768
- created_at: file.createdAt,
2769
- updated_at: file.updatedAt
2770
- };
2771
- }
2772
-
2773
2972
  // src/server/tools/update_spec_file.ts
2774
2973
  var inputSchema15 = {
2775
2974
  file_id: z15.string().min(1).optional().describe("UUID of the spec file to overwrite. Provide this or (project_id + file_path)."),
@@ -2780,11 +2979,14 @@ var inputSchema15 = {
2780
2979
  ),
2781
2980
  local_file_path: z15.string().min(1).optional().describe(
2782
2981
  "Path to a local file whose bytes become the new revision; the server reads it directly. Provide this OR content (exactly one). Absolute paths are most reliable; a relative path resolves against the MCP server working directory. Point this only at an intended spec file \u2014 its bytes are uploaded to the project as-is; never use it for secrets or unrelated files."
2982
+ ),
2983
+ expected_version: z15.number().int().nonnegative().optional().describe(
2984
+ "The content_version the new body was based on, from the get_spec_file or read_spec_file you worked from. The update is rejected with an actionable error if someone else changed the file since. Omit only when overwriting unconditionally is genuinely intended \u2014 the project may be configured to reject unversioned updates."
2783
2985
  )
2784
2986
  };
2785
2987
  var updateSpecFileTool = {
2786
2988
  name: "update_spec_file",
2787
- description: "Overwrite an existing spec file by saving a new revision. Provide the new body inline via `content` or by pointing at a local file with `local_file_path` \u2014 exactly one is required. Prefer `local_file_path` when the body is already an existing local file (the server reads it directly); use `content` for text generated in-memory. Reference the file by file_id or by (project_id + file_path).",
2989
+ description: "Overwrite an existing spec file by saving a new revision. Provide the new body inline via `content` or by pointing at a local file with `local_file_path` \u2014 exactly one is required. Prefer `local_file_path` when the body is already an existing local file (the server reads it directly); use `content` for text generated in-memory. Reference the file by file_id or by (project_id + file_path). Pass `expected_version` from the get_spec_file you based the new body on so a concurrent edit by someone else is reported instead of silently overwritten.",
2788
2990
  inputSchema: inputSchema15,
2789
2991
  handler: async (args, ctx) => {
2790
2992
  const resolved = await resolveSpecContent({
@@ -2794,13 +2996,13 @@ var updateSpecFileTool = {
2794
2996
  if (resolved.error || !resolved.bytes) {
2795
2997
  return errorResult(resolved.error ?? "Failed to resolve file content.");
2796
2998
  }
2797
- let fileId;
2999
+ let target;
2798
3000
  if (args.file_id) {
2799
3001
  const meta = await ctx.client.getFileMetadata(args.file_id).catch(() => null);
2800
3002
  if (!meta) {
2801
3003
  return errorResult(`No spec file found with id ${args.file_id}.`);
2802
3004
  }
2803
- fileId = meta.id;
3005
+ target = meta;
2804
3006
  } else if (args.project_id && args.file_path) {
2805
3007
  const match = await ctx.client.findSpecFileByPath(args.project_id, args.file_path);
2806
3008
  if (!match) {
@@ -2808,17 +3010,46 @@ var updateSpecFileTool = {
2808
3010
  `No spec file found at ${args.file_path} in project ${args.project_id}.`
2809
3011
  );
2810
3012
  }
2811
- fileId = match.id;
3013
+ target = match;
2812
3014
  } else {
2813
3015
  return errorResult("Provide either file_id, or both project_id and file_path.");
2814
3016
  }
2815
- const file = await ctx.client.saveSpecFileRevision(fileId, resolved.bytes);
2816
- return jsonResult({ file: specFileSummary(file) });
3017
+ const expectedVersion = args.expected_version;
3018
+ try {
3019
+ const file = await ctx.client.saveSpecFileRevision(target.id, resolved.bytes, {
3020
+ ...expectedVersion === void 0 ? {} : { expectedVersion }
3021
+ });
3022
+ return jsonResult({ file: specFileSummary(file) });
3023
+ } catch (err) {
3024
+ if (isPlatformPreconditionRequiredError(err)) {
3025
+ return errorResult(
3026
+ `${target.filePath} was NOT updated: this project requires every update to declare the version it was based on. Read the file with get_spec_file (or read_spec_file), then call update_spec_file again passing its content_version as expected_version.`
3027
+ );
3028
+ }
3029
+ const conflict = conflictOf(err);
3030
+ if (!conflict || !isVersionRace(conflict)) {
3031
+ throw err;
3032
+ }
3033
+ return errorResult(conflictMessage(target.filePath, conflict));
3034
+ }
2817
3035
  }
2818
3036
  };
3037
+ function isVersionRace(conflict) {
3038
+ return conflict.reason === "revision_mismatch" || conflict.reason === "unknown";
3039
+ }
3040
+ function conflictMessage(filePath, conflict) {
3041
+ const who = conflict.changedByKind === "ai" ? "an AI session" : (
3042
+ // changedBy is a user id, not a display name — attribute, do not name.
3043
+ conflict.changedBy ? `another user (id ${conflict.changedBy})` : "someone else"
3044
+ );
3045
+ const when = conflict.changedAt ? ` at ${conflict.changedAt}` : "";
3046
+ const rev = conflict.currentRevision === void 0 ? "" : ` It is now at revision ${String(conflict.currentRevision)}.`;
3047
+ const version = conflict.currentVersion === void 0 ? "" : ` (It was at version ${String(conflict.currentVersion)} when your write was rejected.)`;
3048
+ return `${filePath} was NOT updated: ${who} changed it${when} since you read it.${rev} Their version was kept \u2014 nothing of theirs was overwritten. Re-read the file with get_spec_file (or read_spec_file), decide how your change should combine with theirs, then update again passing the content_version that re-read returns as expected_version.${version} Do not simply resend the same content \u2014 you would undo their work.`;
3049
+ }
2819
3050
 
2820
3051
  // src/server/tools/upload_attachment.ts
2821
- import { createHash as createHash2 } from "crypto";
3052
+ import { createHash as createHash3 } from "crypto";
2822
3053
  import { lstat, readFile as readFile2 } from "fs/promises";
2823
3054
  import { basename, extname, isAbsolute as isAbsolute2 } from "path";
2824
3055
  import { z as z16 } from "zod";
@@ -2994,7 +3225,7 @@ var uploadAttachmentTool = {
2994
3225
  }
2995
3226
  const fileName = args.file_name ?? basename(args.file_path);
2996
3227
  const mimeType = args.mime_type ?? inferMimeFromName(fileName);
2997
- const checksum = `sha256:${createHash2("sha256").update(buf).digest("hex")}`;
3228
+ const checksum = `sha256:${createHash3("sha256").update(buf).digest("hex")}`;
2998
3229
  const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
2999
3230
  const existing = await ctx.client.listAttachments(args.project_id);
3000
3231
  const nameMatches = existing.filter((a) => a.name === fileName);
@@ -3415,7 +3646,7 @@ function ripgrepIgnoreGlobs() {
3415
3646
  }
3416
3647
 
3417
3648
  // src/reverse/pack-codebase.ts
3418
- import { createHash as createHash3 } from "crypto";
3649
+ import { createHash as createHash4 } from "crypto";
3419
3650
  import { mkdtemp, readFile as readFile3, rm } from "fs/promises";
3420
3651
  import { tmpdir } from "os";
3421
3652
  import { join } from "path";
@@ -3542,7 +3773,7 @@ function computeOutputId(inputs) {
3542
3773
  i: inputs.includePatterns ?? "",
3543
3774
  x: inputs.ignorePatterns ?? ""
3544
3775
  });
3545
- const digest = createHash3("sha256").update(payload).digest("hex").slice(0, 12);
3776
+ const digest = createHash4("sha256").update(payload).digest("hex").slice(0, 12);
3546
3777
  return `pack-${digest}`;
3547
3778
  }
3548
3779
  function readPage(cache, outputId, page) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.1.5-next.62",
3
+ "version": "0.1.5-next.63",
4
4
  "description": "MySpec MCP server — exposes MySpec platform projects, files and attachments to MCP-aware clients via OAuth-authenticated access tokens.",
5
5
  "type": "module",
6
6
  "repository": {