@myspec/mcp-server 0.4.0-next.91 → 0.4.0-next.93

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/dist/index.js +87 -7
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -255,6 +255,7 @@ async function exchangeCode(userAuthUrl, code, fetchImpl) {
255
255
  }
256
256
 
257
257
  // src/auth/credentials-store.ts
258
+ import { randomUUID } from "crypto";
258
259
  import { promises as fs } from "fs";
259
260
  import os from "os";
260
261
  import path from "path";
@@ -365,9 +366,16 @@ async function writeJsonFile(target, data, opts) {
365
366
  if (process.platform !== "win32") {
366
367
  await fs.chmod(dir, 448);
367
368
  }
368
- await fs.writeFile(target, JSON.stringify(data, null, 2), { mode: 384 });
369
- if (opts.secret && process.platform !== "win32") {
370
- await fs.chmod(target, 384);
369
+ const tmp = `${target}.${randomUUID()}.tmp`;
370
+ try {
371
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode: 384 });
372
+ if (opts.secret && process.platform !== "win32") {
373
+ await fs.chmod(tmp, 384);
374
+ }
375
+ await fs.rename(tmp, target);
376
+ } catch (err) {
377
+ await unlinkIfExists(tmp);
378
+ throw err;
371
379
  }
372
380
  }
373
381
  async function unlinkIfExists(target) {
@@ -584,6 +592,37 @@ var TokenManager = class {
584
592
  now;
585
593
  cached = null;
586
594
  refreshInFlight = null;
595
+ /**
596
+ * The in-flight disk read, shared by every concurrent caller.
597
+ *
598
+ * `refresh` below has always deduped; this did not, so N concurrent platform
599
+ * calls each issued their own `store.load()` before any of them populated
600
+ * `cached`. That is N redundant reads on every cold call, and it is what made
601
+ * a torn read reachable at all: one caller could be refreshing (and so
602
+ * rewriting the credential files) while the others were still reading them.
603
+ *
604
+ * The atomic write in credentials-store.ts is the real guarantee. This is the
605
+ * other half — it removes the redundant reads rather than merely surviving
606
+ * them.
607
+ */
608
+ loadInFlight = null;
609
+ /**
610
+ * Bumped by clear(), so a read that started before it cannot publish its
611
+ * result to `cached` afterwards.
612
+ *
613
+ * Scope, precisely: the caller already awaiting that read still receives the
614
+ * pre-clear credentials as its own return value and proceeds with them —
615
+ * nothing can retract a value from a call already in flight. What the guard
616
+ * buys is that the value stops there instead of being cached, so no
617
+ * SUBSEQUENT caller is handed credentials the user asked us to forget.
618
+ *
619
+ * Dropping `loadInFlight` alone would not achieve even that: the awaiting
620
+ * caller resumes after the clear and would assign `cached` itself.
621
+ * Pre-existing — the old code assigned `cached` straight after an un-deduped
622
+ * `await` — but the fix belongs with the dedupe rather than left as a claim
623
+ * the comment cannot back up.
624
+ */
625
+ loadGeneration = 0;
587
626
  /**
588
627
  * Set once the exchange endpoint rejects the API token outright.
589
628
  *
@@ -616,17 +655,25 @@ var TokenManager = class {
616
655
  }
617
656
  async clear() {
618
657
  this.cached = null;
658
+ this.loadInFlight = null;
659
+ this.loadGeneration += 1;
619
660
  await this.store.clear();
620
661
  }
621
662
  async ensureLoaded() {
622
663
  if (this.cached) {
623
664
  return this.cached;
624
665
  }
625
- const loaded = await this.store.load();
666
+ const generation = this.loadGeneration;
667
+ this.loadInFlight ??= this.store.load().finally(() => {
668
+ this.loadInFlight = null;
669
+ });
670
+ const loaded = await this.loadInFlight;
626
671
  if (!loaded) {
627
672
  throw new NeedsLoginError();
628
673
  }
629
- this.cached = loaded;
674
+ if (generation === this.loadGeneration) {
675
+ this.cached = loaded;
676
+ }
630
677
  return loaded;
631
678
  }
632
679
  async refresh(creds) {
@@ -1333,6 +1380,26 @@ var PlatformPreconditionRequiredError = class extends PlatformHttpError {
1333
1380
  this.name = "PlatformPreconditionRequiredError";
1334
1381
  }
1335
1382
  };
1383
+ var QUOTA_EXCEEDED_CODE = "quota_exceeded";
1384
+ var PlatformQuotaError = class extends PlatformHttpError {
1385
+ /** The platform's machine-readable code, e.g. `quota_exceeded`. */
1386
+ code;
1387
+ constructor(message, status, details, code) {
1388
+ super(message, status, details);
1389
+ this.name = "PlatformQuotaError";
1390
+ this.code = code;
1391
+ }
1392
+ };
1393
+ function isPlatformQuotaError(err) {
1394
+ if (statusOf(err) !== 429) {
1395
+ return false;
1396
+ }
1397
+ if (typeof err !== "object" || err === null) {
1398
+ return false;
1399
+ }
1400
+ const code = err.code;
1401
+ return typeof code === "string" && code.toLowerCase() === QUOTA_EXCEEDED_CODE;
1402
+ }
1336
1403
  function isPlatformHttpError(err) {
1337
1404
  return typeof err === "object" && err !== null && err[PLATFORM_ERROR_BRAND] === true;
1338
1405
  }
@@ -1409,13 +1476,16 @@ function toConflictDetails(details) {
1409
1476
  changedAt: stringField(details, "last_modified_at")
1410
1477
  };
1411
1478
  }
1412
- function createPlatformError(message, status, details) {
1479
+ function createPlatformError(message, status, details, code) {
1413
1480
  if (status === 409) {
1414
1481
  return new PlatformConflictError(message, status, details, toConflictDetails(details));
1415
1482
  }
1416
1483
  if (status === 428) {
1417
1484
  return new PlatformPreconditionRequiredError(message, details);
1418
1485
  }
1486
+ if (status === 429 && typeof code === "string" && code.toLowerCase() === QUOTA_EXCEEDED_CODE) {
1487
+ return new PlatformQuotaError(message, status, details, code.toLowerCase());
1488
+ }
1419
1489
  return new PlatformHttpError(message, status, details);
1420
1490
  }
1421
1491
  function truncate(text) {
@@ -1426,17 +1496,19 @@ async function throwFromResponse(response, fallbackMessage) {
1426
1496
  const rawBody = await response.text().catch(() => "");
1427
1497
  let message = fallbackMessage;
1428
1498
  let details;
1499
+ let code;
1429
1500
  if (rawBody) {
1430
1501
  try {
1431
1502
  const parsed = JSON.parse(rawBody);
1432
1503
  details = parsed.details;
1504
+ code = typeof parsed.error === "string" ? parsed.error : void 0;
1433
1505
  const suffix = details ? ` [details: ${JSON.stringify(details)}]` : "";
1434
1506
  message = truncate(parsed.message ?? rawBody) + suffix;
1435
1507
  } catch {
1436
1508
  message = truncate(rawBody);
1437
1509
  }
1438
1510
  }
1439
- throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details);
1511
+ throw createPlatformError(`HTTP ${response.status}: ${message}`, response.status, details, code);
1440
1512
  }
1441
1513
 
1442
1514
  // ../../packages/shared/logger.ts
@@ -1644,6 +1716,10 @@ var BaseHttpClient = class {
1644
1716
  this.logger.debug("Retrying after abort/timeout", { error: error.message });
1645
1717
  return true;
1646
1718
  }
1719
+ if (isPlatformQuotaError(error)) {
1720
+ this.logger.debug("Not retrying quota rejection", { status: 429 });
1721
+ return false;
1722
+ }
1647
1723
  const httpError = error;
1648
1724
  if (httpError.status !== void 0) {
1649
1725
  const shouldRetry = RETRYABLE_STATUS_CODES.includes(httpError.status);
@@ -1700,6 +1776,9 @@ function transformSpecFileResponse(raw) {
1700
1776
  deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
1701
1777
  };
1702
1778
  }
1779
+ function toRevisionAuthorKind(raw) {
1780
+ return raw === "ai" ? "ai" : "human";
1781
+ }
1703
1782
  function transformFileRevisionResponse(raw) {
1704
1783
  return {
1705
1784
  id: raw.id,
@@ -1710,6 +1789,7 @@ function transformFileRevisionResponse(raw) {
1710
1789
  fileSizeBytes: raw.file_size_bytes,
1711
1790
  checksum: raw.checksum,
1712
1791
  changedBy: raw.changed_by,
1792
+ changedByKind: toRevisionAuthorKind(raw.changed_by_kind),
1713
1793
  createdAt: new Date(raw.created_at)
1714
1794
  };
1715
1795
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.4.0-next.91",
3
+ "version": "0.4.0-next.93",
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": {