@myspec/mcp-server 1.1.1-next.157 → 1.1.1-next.159

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 +277 -41
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -316,15 +316,88 @@ function createFileCredentialsStore(paths = {}) {
316
316
  },
317
317
  async clear() {
318
318
  await unlinkIfExists(oauthCredsPath);
319
- }
319
+ },
320
+ withLock: (fn) => withFileLock(`${oauthCredsPath}.lock`, fn, paths.lockTimings),
321
+ saveSettings: (creds) => writeSettingsFile(settingsPath, creds)
320
322
  };
321
323
  }
324
+ function saveDiscoveredUrls(store, creds) {
325
+ return store.saveSettings ? store.saveSettings(creds) : store.save(creds);
326
+ }
327
+ var DEFAULT_LOCK_TIMINGS = { staleMs: 3e4, waitMs: 2e4, pollMs: 50 };
328
+ async function withFileLock(lockPath, fn, timings = DEFAULT_LOCK_TIMINGS) {
329
+ const owner = await acquireFileLock(lockPath, timings);
330
+ try {
331
+ return await fn();
332
+ } finally {
333
+ if (owner !== null) {
334
+ await releaseFileLock(lockPath, owner);
335
+ }
336
+ }
337
+ }
338
+ async function acquireFileLock(lockPath, timings) {
339
+ const owner = randomUUID();
340
+ const deadline = Date.now() + timings.waitMs;
341
+ try {
342
+ await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 448 });
343
+ } catch {
344
+ return null;
345
+ }
346
+ for (; ; ) {
347
+ try {
348
+ await fs.writeFile(lockPath, owner, { flag: "wx", mode: 384 });
349
+ return owner;
350
+ } catch (err) {
351
+ if (!isFsExists(err)) {
352
+ return null;
353
+ }
354
+ }
355
+ if (Date.now() >= deadline) {
356
+ return null;
357
+ }
358
+ const held = await readLock(lockPath);
359
+ if (held === null) {
360
+ continue;
361
+ }
362
+ if (held.ageMs > timings.staleMs) {
363
+ const again = await readLock(lockPath);
364
+ if (again?.owner !== held.owner) {
365
+ continue;
366
+ }
367
+ try {
368
+ await fs.unlink(lockPath);
369
+ } catch (err) {
370
+ if (!isFsNotFound(err)) {
371
+ return null;
372
+ }
373
+ }
374
+ continue;
375
+ }
376
+ await new Promise((resolve3) => setTimeout(resolve3, timings.pollMs));
377
+ }
378
+ }
379
+ async function readLock(lockPath) {
380
+ try {
381
+ const [stat2, owner] = await Promise.all([fs.stat(lockPath), fs.readFile(lockPath, "utf-8")]);
382
+ return { owner, ageMs: Date.now() - stat2.mtimeMs };
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+ async function releaseFileLock(lockPath, owner) {
388
+ const current = await fs.readFile(lockPath, "utf-8").catch(() => null);
389
+ if (current === owner) {
390
+ await unlinkIfExists(lockPath).catch(() => void 0);
391
+ }
392
+ }
322
393
  function keepCredentialsOnFailure(store) {
323
394
  return {
324
395
  load: () => store.load(),
325
396
  save: (creds) => store.save(creds),
326
397
  clear: () => Promise.resolve(),
327
- path: () => store.path()
398
+ path: () => store.path(),
399
+ ...store.withLock ? { withLock: store.withLock } : {},
400
+ ...store.saveSettings ? { saveSettings: store.saveSettings } : {}
328
401
  };
329
402
  }
330
403
  async function writeSettingsFile(target, creds) {
@@ -596,15 +669,30 @@ function requireNumber(obj, key) {
596
669
  function isFsNotFound(err) {
597
670
  return typeof err === "object" && err !== null && err.code === "ENOENT";
598
671
  }
672
+ function isFsExists(err) {
673
+ return typeof err === "object" && err !== null && err.code === "EEXIST";
674
+ }
599
675
 
600
676
  // src/auth/token-manager.ts
601
677
  var EXPIRY_SKEW_MS = 3e4;
678
+ var REFRESH_TIMEOUT_MS = 8e3;
602
679
  var TokenManager = class {
603
680
  store;
604
681
  fetchImpl;
605
682
  now;
606
683
  cached = null;
607
684
  refreshInFlight = null;
685
+ /**
686
+ * Whether the refresh in flight is guaranteed to mint. An unforced one may
687
+ * settle for a token another process saved, which a forced caller must not
688
+ * be handed — see refresh().
689
+ */
690
+ refreshInFlightForced = false;
691
+ /**
692
+ * The access token this instance last minted, so a forced caller that
693
+ * waited on an unforced refresh can tell whether it got a minted token.
694
+ */
695
+ lastMintedAccessToken = null;
608
696
  /**
609
697
  * The in-flight disk read, shared by every concurrent caller.
610
698
  *
@@ -652,18 +740,25 @@ var TokenManager = class {
652
740
  }
653
741
  async getValidAccessToken() {
654
742
  const creds = await this.ensureLoaded();
655
- if (creds.expiresAt > this.now() + EXPIRY_SKEW_MS) {
743
+ if (this.isFresh(creds)) {
656
744
  return creds.accessToken;
657
745
  }
658
- const refreshed = await this.refresh(creds);
746
+ const refreshed = await this.refresh(creds, { forced: false });
659
747
  return refreshed.accessToken;
660
748
  }
661
749
  async getCredentials() {
662
750
  return this.ensureLoaded();
663
751
  }
752
+ /**
753
+ * Mints a new access token even if the current one has not expired — for
754
+ * callers whose token was just refused, or that need claims stamped after a
755
+ * server-side change (login re-minting after an organization switch). So,
756
+ * unlike getValidAccessToken, it never settles for a token another process
757
+ * already saved.
758
+ */
664
759
  async forceRefresh() {
665
760
  const creds = await this.ensureLoaded();
666
- const refreshed = await this.refresh(creds);
761
+ const refreshed = await this.refresh(creds, { forced: true });
667
762
  return refreshed.accessToken;
668
763
  }
669
764
  async clear() {
@@ -689,16 +784,27 @@ var TokenManager = class {
689
784
  }
690
785
  return loaded;
691
786
  }
692
- async refresh(creds) {
787
+ isFresh(creds) {
788
+ return creds.expiresAt > this.now() + EXPIRY_SKEW_MS;
789
+ }
790
+ async refresh(creds, opts) {
693
791
  if (this.refreshInFlight) {
694
- return this.refreshInFlight;
792
+ if (this.refreshInFlightForced || !opts.forced) {
793
+ return this.refreshInFlight;
794
+ }
795
+ const settled = await this.refreshInFlight;
796
+ if (settled.accessToken === this.lastMintedAccessToken) {
797
+ return settled;
798
+ }
799
+ return this.refresh(this.cached ?? creds, opts);
695
800
  }
696
- this.refreshInFlight = this.doRefresh(creds).finally(() => {
801
+ this.refreshInFlightForced = opts.forced;
802
+ this.refreshInFlight = this.doRefresh(creds, opts).finally(() => {
697
803
  this.refreshInFlight = null;
698
804
  });
699
805
  return this.refreshInFlight;
700
806
  }
701
- async doRefresh(creds) {
807
+ async doRefresh(creds, opts) {
702
808
  if (creds.apiToken) {
703
809
  if (this.apiTokenRejection) {
704
810
  throw this.apiTokenRejection;
@@ -710,18 +816,100 @@ var TokenManager = class {
710
816
  "No credential is configured. Either set MYSPEC_API_TOKEN to a long-lived API token (or put one in the `apiToken` field of your credentials file) \u2014 create one in the MySpec webapp under the avatar menu, API tokens \u2014 or run `npx @myspec/mcp-server login` to sign in interactively."
711
817
  );
712
818
  }
819
+ return this.refreshSession(creds, opts);
820
+ }
821
+ /**
822
+ * Refreshes a login session that other processes may be refreshing too.
823
+ *
824
+ * Refresh tokens are single-use, and user-auth honours the previous one for
825
+ * only 60 seconds after a rotation. Every MCP client spawns its own `serve`,
826
+ * and `reverse` and `auth status` run beside them, all on the same
827
+ * ~/.myspec. This instance caches the credentials once, so when another
828
+ * process rotated the token, the cached one is already retired: presenting
829
+ * it gets a 401, and treating that as "signed out" used to delete the file —
830
+ * along with the valid token the other process had just saved.
831
+ *
832
+ * So the whole read → refresh → save runs under the store's lock, and the
833
+ * store is re-read first: a session rotated elsewhere is adopted (see
834
+ * newerSession), used as it is while its access token is fresh, otherwise
835
+ * refreshed with its token. While every process refreshing this file holds
836
+ * the lock, none can rotate in between. The lock is best-effort, though — a
837
+ * store without one, a lock not acquired in time or taken over as stale, an
838
+ * older CLI version — so a refusal is checked the same way, since a rotation
839
+ * can land while our request is in flight, and gets one retry with the newer
840
+ * token. Only then is the store cleared — and only if it still holds a token
841
+ * that was refused (see clearUnlessReplaced).
842
+ */
843
+ refreshSession(cachedCreds, opts) {
844
+ const run = () => this.refreshSessionLocked(cachedCreds, opts);
845
+ return this.store.withLock ? this.store.withLock(run) : run();
846
+ }
847
+ async refreshSessionLocked(cachedCreds, opts) {
848
+ const refused = /* @__PURE__ */ new Set();
849
+ let creds = cachedCreds;
850
+ let onDisk = await this.readStore();
851
+ const newer = newerSession(onDisk, creds);
852
+ if (newer) {
853
+ this.cached = newer;
854
+ if (!opts.forced && this.isFresh(newer)) {
855
+ return newer;
856
+ }
857
+ creds = newer;
858
+ }
713
859
  try {
714
- return await this.performRefresh(creds);
860
+ return await this.performRefresh(creds, onDisk?.apiToken);
715
861
  } catch (err) {
716
- await this.invalidateOnAuthFailure(err);
717
- throw err;
862
+ if (!(err instanceof NeedsLoginError)) {
863
+ throw err;
864
+ }
865
+ refused.add(creds.refreshToken ?? "");
866
+ onDisk = await this.readStore();
867
+ const raced = newerSession(onDisk, creds);
868
+ if (!raced) {
869
+ await this.clearUnlessReplaced(onDisk, refused);
870
+ throw err;
871
+ }
872
+ this.cached = raced;
873
+ if (!opts.forced && this.isFresh(raced)) {
874
+ return raced;
875
+ }
876
+ try {
877
+ return await this.performRefresh(raced, onDisk?.apiToken);
878
+ } catch (retryErr) {
879
+ if (retryErr instanceof NeedsLoginError) {
880
+ refused.add(raced.refreshToken ?? "");
881
+ await this.clearUnlessReplaced(await this.readStore(), refused);
882
+ }
883
+ throw retryErr;
884
+ }
718
885
  }
719
886
  }
720
- async invalidateOnAuthFailure(err) {
721
- if (err instanceof NeedsLoginError) {
722
- this.cached = null;
723
- await this.store.clear();
887
+ /**
888
+ * A fresh read of the store; null when it is empty or cannot be read, so a
889
+ * refresh that would work is not failed by it.
890
+ */
891
+ readStore() {
892
+ return this.store.load().catch(() => null);
893
+ }
894
+ /**
895
+ * Signs out after a refusal: drops the cache and clears the store — unless
896
+ * the store no longer holds a refused token. An API-token record is shown
897
+ * exactly once and is never ours to delete, and a session we have not
898
+ * presented belongs to another process. A store that could not be read is
899
+ * left alone too: it may hold either (a hand edit with a typo, a bad file
900
+ * mode), and its own error is what the user needs to see. Network errors,
901
+ * 5xx and other transient faults never reach here: they must not delete the
902
+ * stored refresh token.
903
+ */
904
+ async clearUnlessReplaced(onDisk, refused) {
905
+ this.cached = null;
906
+ if (!onDisk || onDisk.apiToken !== void 0) {
907
+ return;
724
908
  }
909
+ if (onDisk.refreshToken !== void 0 && !refused.has(onDisk.refreshToken)) {
910
+ return;
911
+ }
912
+ await this.store.clear();
725
913
  }
726
914
  /**
727
915
  * Trades the long-lived API token for a short-lived access token.
@@ -774,15 +962,26 @@ var TokenManager = class {
774
962
  expiresAt: this.now() + payload.expiresIn * 1e3
775
963
  };
776
964
  this.cached = next;
777
- await this.store.save(next);
965
+ this.lastMintedAccessToken = next.accessToken;
966
+ const onDisk = await this.readStore();
967
+ if (onDisk?.apiToken === apiToken) {
968
+ await this.store.save({ ...onDisk, accessToken: next.accessToken, expiresAt: next.expiresAt });
969
+ }
778
970
  return next;
779
971
  }
780
- async performRefresh(creds) {
972
+ /**
973
+ * @param keepApiToken An API token the store holds beside this session —
974
+ * added by hand while this process ran. Written back with the rotated pair,
975
+ * since save() rewrites the whole file and a PAT is shown exactly once; the
976
+ * cache stays on the session, so this process does not switch identity.
977
+ */
978
+ async performRefresh(creds, keepApiToken) {
781
979
  const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
782
980
  const response = await this.fetchImpl(url, {
783
981
  method: "POST",
784
982
  headers: { "Content-Type": "application/json" },
785
- body: JSON.stringify({ refreshToken: creds.refreshToken })
983
+ body: JSON.stringify({ refreshToken: creds.refreshToken }),
984
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS)
786
985
  });
787
986
  if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
788
987
  throw new RefreshRejectedError(
@@ -793,7 +992,8 @@ var TokenManager = class {
793
992
  const body = await response.text().catch(() => "");
794
993
  throw new HttpStatusError(response.status, body, `Token refresh failed: HTTP ${String(response.status)}`);
795
994
  }
796
- const payload = await response.json().catch(() => null);
995
+ const text = await response.text();
996
+ const payload = parseJson(text);
797
997
  if (!payload || typeof payload.accessToken !== "string" || typeof payload.refreshToken !== "string" || typeof payload.expiresIn !== "number") {
798
998
  throw new NeedsLoginError(
799
999
  "Token refresh response was malformed. Run `npx @myspec/mcp-server login` to sign in again."
@@ -806,10 +1006,24 @@ var TokenManager = class {
806
1006
  expiresAt: this.now() + payload.expiresIn * 1e3
807
1007
  };
808
1008
  this.cached = next;
809
- await this.store.save(next);
1009
+ this.lastMintedAccessToken = next.accessToken;
1010
+ await this.store.save(keepApiToken === void 0 ? next : { ...next, apiToken: keepApiToken });
810
1011
  return next;
811
1012
  }
812
1013
  };
1014
+ function parseJson(text) {
1015
+ try {
1016
+ return JSON.parse(text);
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ }
1021
+ function newerSession(onDisk, creds) {
1022
+ if (onDisk?.refreshToken === void 0 || onDisk.refreshToken === creds.refreshToken || onDisk.userAuthUrl !== creds.userAuthUrl || onDisk.expiresAt <= creds.expiresAt) {
1023
+ return null;
1024
+ }
1025
+ return { ...onDisk, apiToken: void 0 };
1026
+ }
813
1027
  var RefreshRejectedError = class extends NeedsLoginError {
814
1028
  };
815
1029
  var ApiTokenRejectedError = class extends ConfigError {
@@ -1136,9 +1350,13 @@ async function runLogin(options, deps = {}) {
1136
1350
  webappUrl: config.webappUrl
1137
1351
  });
1138
1352
  const enriched = await discoverAfterLogin(credentials, config.webappUrl, deps.discover, log);
1139
- const prior = await store.load().catch(() => null);
1140
- const preserved = prior?.apiToken === void 0 ? enriched : { ...enriched, apiToken: prior.apiToken };
1141
- await store.save(preserved);
1353
+ const persist = async () => {
1354
+ const prior = await store.load().catch(() => null);
1355
+ const merged = prior?.apiToken === void 0 ? enriched : { ...enriched, apiToken: prior.apiToken };
1356
+ await store.save(merged);
1357
+ return merged;
1358
+ };
1359
+ const preserved = await (store.withLock ? store.withLock(persist) : persist());
1142
1360
  log(`Signed in as ${enriched.user?.email ?? "unknown"}.`);
1143
1361
  log(`Credentials saved to ${store.path()}`);
1144
1362
  await ensureActiveOrganization({
@@ -1221,9 +1439,25 @@ async function ensureActiveOrganization(args) {
1221
1439
  log(`Active organization: ${mintedOrg}`);
1222
1440
  }
1223
1441
  function defaultRefreshTokens(store) {
1224
- const tokenManager = new TokenManager({ store: keepCredentialsOnFailure(store) });
1442
+ const tokenManager = new TokenManager({ store: sessionOnly(store) });
1225
1443
  return tokenManager.forceRefresh();
1226
1444
  }
1445
+ function sessionOnly(store) {
1446
+ return {
1447
+ load: async () => {
1448
+ const creds = await store.load();
1449
+ return creds === null ? null : { ...creds, apiToken: void 0 };
1450
+ },
1451
+ save: async (creds) => {
1452
+ const current = await store.load();
1453
+ const apiToken = creds.apiToken ?? current?.apiToken;
1454
+ await store.save(apiToken === void 0 ? creds : { ...creds, apiToken });
1455
+ },
1456
+ clear: () => Promise.resolve(),
1457
+ path: () => store.path(),
1458
+ ...store.withLock ? { withLock: store.withLock } : {}
1459
+ };
1460
+ }
1227
1461
  function sameSlug(a, b) {
1228
1462
  return a.toLowerCase() === b.toLowerCase();
1229
1463
  }
@@ -1368,7 +1602,7 @@ async function resolveAuthStatus(deps) {
1368
1602
  return { ...status, organization: { slug, name: match.name } };
1369
1603
  }
1370
1604
  function describeTokenFailure(err, userAuthUrl) {
1371
- if (err instanceof TypeError) {
1605
+ if (err instanceof TypeError || err instanceof Error && err.name === "TimeoutError") {
1372
1606
  return new ConfigError(
1373
1607
  `Could not reach ${userAuthUrl} to check the sign-in: ${errorMessage2(err)}`
1374
1608
  );
@@ -2658,11 +2892,7 @@ var HttpSpecSessionClient = class {
2658
2892
  if (opts?.query) params.set("q", opts.query);
2659
2893
  if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
2660
2894
  if (opts?.offset !== void 0) params.set("offset", String(opts.offset));
2661
- const response = await this.httpClient.get(
2662
- `/project/v1/projects/${projectId}/sessions?${params.toString()}`,
2663
- jwtToken,
2664
- {}
2665
- );
2895
+ const response = await this.httpClient.get(`/project/v1/projects/${projectId}/sessions?${params.toString()}`, jwtToken, {});
2666
2896
  return {
2667
2897
  sessions: response.sessions.map(transformSpecSessionResponse),
2668
2898
  total: response.total,
@@ -2670,13 +2900,14 @@ var HttpSpecSessionClient = class {
2670
2900
  offset: response.offset
2671
2901
  };
2672
2902
  }
2673
- async updateSpecSession(sessionId, updates, jwtToken) {
2903
+ async updateSpecSession(sessionId, updates, jwtToken, options) {
2674
2904
  const body = {};
2675
2905
  if (updates.status !== void 0) body.status = updates.status;
2676
2906
  if (updates.messageCount !== void 0) body.message_count = updates.messageCount;
2677
2907
  if (updates.tokenCount !== void 0) body.token_count = updates.tokenCount;
2678
2908
  if (updates.context !== void 0) body.context = updates.context;
2679
2909
  if (updates.chatHistory !== void 0) body.chat_history = updates.chatHistory;
2910
+ if (options?.originClientId) body.origin_client_id = options.originClientId;
2680
2911
  const raw = await this.httpClient.put(
2681
2912
  `/project/v1/sessions/${sessionId}`,
2682
2913
  body,
@@ -2685,8 +2916,10 @@ var HttpSpecSessionClient = class {
2685
2916
  );
2686
2917
  return transformSpecSessionResponse(raw);
2687
2918
  }
2688
- async appendSessionMessage(sessionId, message, jwtToken) {
2689
- const response = await this.httpClient.post(`/project/v1/sessions/${sessionId}/messages`, { message }, jwtToken, {});
2919
+ async appendSessionMessage(sessionId, message, jwtToken, options) {
2920
+ const body = { message };
2921
+ if (options?.originClientId) body.origin_client_id = options.originClientId;
2922
+ const response = await this.httpClient.post(`/project/v1/sessions/${sessionId}/messages`, body, jwtToken, {});
2690
2923
  return { messageCount: response.message_count };
2691
2924
  }
2692
2925
  async archiveSpecSession(sessionId, jwtToken) {
@@ -8504,10 +8737,13 @@ async function resolveReverseAgentUrl(sources) {
8504
8737
  );
8505
8738
  }
8506
8739
  if (stored && sources.persist) {
8507
- await sources.persist(applyDiscoveredUrls(stored, config.webappUrl, discovered)).catch((err) => {
8508
- const message = err instanceof Error ? err.message : String(err);
8509
- warn(`myspec-mcp reverse: could not save discovered URLs (${message})`);
8510
- });
8740
+ const latest = await sources.loadStored().catch(() => null);
8741
+ if (latest) {
8742
+ await sources.persist(applyDiscoveredUrls(latest, config.webappUrl, discovered)).catch((err) => {
8743
+ const message = err instanceof Error ? err.message : String(err);
8744
+ warn(`myspec-mcp reverse: could not save discovered URLs (${message})`);
8745
+ });
8746
+ }
8511
8747
  }
8512
8748
  return { agentUrl: discovered.aiAgentMcpReverseUrl, discoveredVia: endpoint };
8513
8749
  } catch (err) {
@@ -8615,7 +8851,7 @@ async function runReverseCommand(flags) {
8615
8851
  };
8616
8852
  forceRefresh = () => tokenManager.forceRefresh();
8617
8853
  loadStored = () => store.load();
8618
- persist = (creds) => store.save(creds);
8854
+ persist = (creds) => saveDiscoveredUrls(store, creds);
8619
8855
  }
8620
8856
  const resolved = await resolveReverseAgentUrl({
8621
8857
  flagAgentUrl: flagString(flags, "agent-url"),
@@ -8668,9 +8904,9 @@ function createPlatformUrlResolver(deps) {
8668
8904
  `Could not discover endpoints from ${configEndpoint(deps.webappUrl)} (response is missing platformUrl). Run \`npx @myspec/mcp-server login\` and retry.`
8669
8905
  );
8670
8906
  }
8671
- const creds = await deps.tokenManager.getCredentials().catch(() => null);
8907
+ const creds = await deps.store.load().catch(() => null);
8672
8908
  if (creds) {
8673
- await deps.store.save(applyDiscoveredUrls(creds, deps.webappUrl, discovered)).catch((err) => {
8909
+ await saveDiscoveredUrls(deps.store, applyDiscoveredUrls(creds, deps.webappUrl, discovered)).catch((err) => {
8674
8910
  const message = err instanceof Error ? err.message : String(err);
8675
8911
  warn(`myspec-mcp: could not save discovered URLs (${message})`);
8676
8912
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "1.1.1-next.157",
3
+ "version": "1.1.1-next.159",
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": {