@myspec/mcp-server 1.1.1-next.156 → 1.1.1-next.158

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 +49 -4
  2. package/dist/index.js +529 -84
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,13 +19,19 @@ Run with `npx @myspec/mcp-server [command]` (or `myspec-mcp [command]` when inst
19
19
  ```text
20
20
  (default) Start MCP server over stdio
21
21
  serve Start MCP server over stdio
22
- login Sign in via browser loopback OAuth
23
- login --paste Same as login, but never auto-opens a browser
24
- login --org <slug> Sign in and pin that organization
25
- logout Revoke refresh token and clear local credentials
22
+ auth login Sign in via browser loopback OAuth
23
+ auth login --paste Same as auth login, but never auto-opens a browser
24
+ auth login --org <slug> Sign in and pin that organization
25
+ auth logout Revoke refresh token and clear local credentials
26
+ auth status Show the sign-in: account email and active organization
27
+ login, logout Aliases for `auth login` and `auth logout`
26
28
  reverse --root <dir> Connect to ai-agent and expose local_fs tools
27
29
  ```
28
30
 
31
+ `login` and `logout` keep working exactly as before; they run `auth login` and
32
+ `auth logout`. Add `--help` after any auth command to print the usage instead
33
+ of running it.
34
+
29
35
  ### Signing in from another device (`--paste`)
30
36
 
31
37
  `login` opens a browser on this machine and catches the callback on a local
@@ -239,6 +245,45 @@ dev environment:
239
245
  npx -y @myspec/mcp-server login --user-auth-url https://dev-auth.myspec.dev
240
246
  ```
241
247
 
248
+ ### Checking your sign-in (`auth status`)
249
+
250
+ ```bash
251
+ npx -y @myspec/mcp-server auth status
252
+ ```
253
+
254
+ ```text
255
+ Signed in to https://auth.myspec.dev
256
+ Account: jane@example.com
257
+ Organization: Acme Inc (acme)
258
+ Credential: login session (/home/jane/.myspec/oauth_creds.json)
259
+ ```
260
+
261
+ It reports the credential `serve` would use, in the same precedence order
262
+ (`MYSPEC_API_TOKEN` > file `apiToken` > the `login` session), and honours
263
+ `--user-auth-url` the same way. The first line names the auth server, which is
264
+ how you tell a dev sign-in from a production one.
265
+
266
+ - **What is checked online.** The email and organization come from the saved
267
+ access token while it is still valid (they last 15 minutes); once it has
268
+ expired, `auth status` renews it first, like any other command. For a login
269
+ session it then looks the organization name up from user-auth, which also
270
+ confirms the session: one that was ended on the server is reported as signed
271
+ out. `MYSPEC_API_TOKEN` is exchanged on every run. A file `apiToken` whose
272
+ saved access token is still valid is not checked with the server, so a token
273
+ revoked in the webapp can still show as signed in for up to 15 minutes.
274
+ - **API tokens show the organization slug only**, e.g.
275
+ `acme (API tokens do not include the organization name)`, plus the token's
276
+ access mode (`read-only` or `read-write`). user-auth has no endpoint that
277
+ returns the name for an API token.
278
+ - **No active organization** prints `Organization: none` and how to pin one.
279
+ - **If the name lookup fails** (the network, or a server error), the slug is
280
+ shown with the reason and the command still succeeds.
281
+ - **Exit codes:** `0` when signed in; `2` when not signed in, when the
282
+ credential is rejected, or when an expired token could not be renewed — so
283
+ `auth status || auth login` works in a script. A problem with the
284
+ credentials file itself (wrong file mode, bad JSON) fails the same way
285
+ `serve` does. A failure never deletes the saved credentials.
286
+
242
287
  ### `reverse`
243
288
 
244
289
  `reverse` connects out to the ai-agent service over WebSocket and exposes a set
package/dist/index.js CHANGED
@@ -316,7 +316,88 @@ function createFileCredentialsStore(paths = {}) {
316
316
  },
317
317
  async clear() {
318
318
  await unlinkIfExists(oauthCredsPath);
319
+ },
320
+ withLock: (fn) => withFileLock(`${oauthCredsPath}.lock`, fn, paths.lockTimings),
321
+ saveSettings: (creds) => writeSettingsFile(settingsPath, creds)
322
+ };
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;
319
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
+ }
393
+ function keepCredentialsOnFailure(store) {
394
+ return {
395
+ load: () => store.load(),
396
+ save: (creds) => store.save(creds),
397
+ clear: () => Promise.resolve(),
398
+ path: () => store.path(),
399
+ ...store.withLock ? { withLock: store.withLock } : {},
400
+ ...store.saveSettings ? { saveSettings: store.saveSettings } : {}
320
401
  };
321
402
  }
322
403
  async function writeSettingsFile(target, creds) {
@@ -588,15 +669,30 @@ function requireNumber(obj, key) {
588
669
  function isFsNotFound(err) {
589
670
  return typeof err === "object" && err !== null && err.code === "ENOENT";
590
671
  }
672
+ function isFsExists(err) {
673
+ return typeof err === "object" && err !== null && err.code === "EEXIST";
674
+ }
591
675
 
592
676
  // src/auth/token-manager.ts
593
677
  var EXPIRY_SKEW_MS = 3e4;
678
+ var REFRESH_TIMEOUT_MS = 8e3;
594
679
  var TokenManager = class {
595
680
  store;
596
681
  fetchImpl;
597
682
  now;
598
683
  cached = null;
599
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;
600
696
  /**
601
697
  * The in-flight disk read, shared by every concurrent caller.
602
698
  *
@@ -644,18 +740,25 @@ var TokenManager = class {
644
740
  }
645
741
  async getValidAccessToken() {
646
742
  const creds = await this.ensureLoaded();
647
- if (creds.expiresAt > this.now() + EXPIRY_SKEW_MS) {
743
+ if (this.isFresh(creds)) {
648
744
  return creds.accessToken;
649
745
  }
650
- const refreshed = await this.refresh(creds);
746
+ const refreshed = await this.refresh(creds, { forced: false });
651
747
  return refreshed.accessToken;
652
748
  }
653
749
  async getCredentials() {
654
750
  return this.ensureLoaded();
655
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
+ */
656
759
  async forceRefresh() {
657
760
  const creds = await this.ensureLoaded();
658
- const refreshed = await this.refresh(creds);
761
+ const refreshed = await this.refresh(creds, { forced: true });
659
762
  return refreshed.accessToken;
660
763
  }
661
764
  async clear() {
@@ -681,16 +784,27 @@ var TokenManager = class {
681
784
  }
682
785
  return loaded;
683
786
  }
684
- async refresh(creds) {
787
+ isFresh(creds) {
788
+ return creds.expiresAt > this.now() + EXPIRY_SKEW_MS;
789
+ }
790
+ async refresh(creds, opts) {
685
791
  if (this.refreshInFlight) {
686
- 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);
687
800
  }
688
- this.refreshInFlight = this.doRefresh(creds).finally(() => {
801
+ this.refreshInFlightForced = opts.forced;
802
+ this.refreshInFlight = this.doRefresh(creds, opts).finally(() => {
689
803
  this.refreshInFlight = null;
690
804
  });
691
805
  return this.refreshInFlight;
692
806
  }
693
- async doRefresh(creds) {
807
+ async doRefresh(creds, opts) {
694
808
  if (creds.apiToken) {
695
809
  if (this.apiTokenRejection) {
696
810
  throw this.apiTokenRejection;
@@ -702,18 +816,100 @@ var TokenManager = class {
702
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."
703
817
  );
704
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
+ }
705
859
  try {
706
- return await this.performRefresh(creds);
860
+ return await this.performRefresh(creds, onDisk?.apiToken);
707
861
  } catch (err) {
708
- await this.invalidateOnAuthFailure(err);
709
- 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
+ }
710
885
  }
711
886
  }
712
- async invalidateOnAuthFailure(err) {
713
- if (err instanceof NeedsLoginError) {
714
- this.cached = null;
715
- 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;
908
+ }
909
+ if (onDisk.refreshToken !== void 0 && !refused.has(onDisk.refreshToken)) {
910
+ return;
716
911
  }
912
+ await this.store.clear();
717
913
  }
718
914
  /**
719
915
  * Trades the long-lived API token for a short-lived access token.
@@ -766,15 +962,26 @@ var TokenManager = class {
766
962
  expiresAt: this.now() + payload.expiresIn * 1e3
767
963
  };
768
964
  this.cached = next;
769
- 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
+ }
770
970
  return next;
771
971
  }
772
- 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) {
773
979
  const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
774
980
  const response = await this.fetchImpl(url, {
775
981
  method: "POST",
776
982
  headers: { "Content-Type": "application/json" },
777
- body: JSON.stringify({ refreshToken: creds.refreshToken })
983
+ body: JSON.stringify({ refreshToken: creds.refreshToken }),
984
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS)
778
985
  });
779
986
  if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
780
987
  throw new RefreshRejectedError(
@@ -785,7 +992,8 @@ var TokenManager = class {
785
992
  const body = await response.text().catch(() => "");
786
993
  throw new HttpStatusError(response.status, body, `Token refresh failed: HTTP ${String(response.status)}`);
787
994
  }
788
- const payload = await response.json().catch(() => null);
995
+ const text = await response.text();
996
+ const payload = parseJson(text);
789
997
  if (!payload || typeof payload.accessToken !== "string" || typeof payload.refreshToken !== "string" || typeof payload.expiresIn !== "number") {
790
998
  throw new NeedsLoginError(
791
999
  "Token refresh response was malformed. Run `npx @myspec/mcp-server login` to sign in again."
@@ -798,10 +1006,24 @@ var TokenManager = class {
798
1006
  expiresAt: this.now() + payload.expiresIn * 1e3
799
1007
  };
800
1008
  this.cached = next;
801
- await this.store.save(next);
1009
+ this.lastMintedAccessToken = next.accessToken;
1010
+ await this.store.save(keepApiToken === void 0 ? next : { ...next, apiToken: keepApiToken });
802
1011
  return next;
803
1012
  }
804
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
+ }
805
1027
  var RefreshRejectedError = class extends NeedsLoginError {
806
1028
  };
807
1029
  var ApiTokenRejectedError = class extends ConfigError {
@@ -809,19 +1031,36 @@ var ApiTokenRejectedError = class extends ConfigError {
809
1031
 
810
1032
  // src/auth/organization.ts
811
1033
  import readline2 from "readline";
812
- function readOrgClaim(accessToken) {
1034
+
1035
+ // src/auth/token-claims.ts
1036
+ function readTokenClaims(accessToken) {
813
1037
  const payload = accessToken.split(".")[1];
814
1038
  if (payload === void 0) {
815
- return void 0;
1039
+ return {};
816
1040
  }
1041
+ let claims;
817
1042
  try {
818
- const json = Buffer.from(payload, "base64url").toString("utf-8");
819
- const claims = JSON.parse(json);
820
- const org = claims.org;
821
- return typeof org === "string" && org.length > 0 ? org : void 0;
1043
+ claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
822
1044
  } catch {
823
- return void 0;
1045
+ return {};
824
1046
  }
1047
+ if (typeof claims !== "object" || claims === null) {
1048
+ return {};
1049
+ }
1050
+ const record = claims;
1051
+ return {
1052
+ email: nonEmptyString(record.email),
1053
+ org: nonEmptyString(record.org),
1054
+ access: nonEmptyString(record.access)
1055
+ };
1056
+ }
1057
+ function nonEmptyString(value) {
1058
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1059
+ }
1060
+
1061
+ // src/auth/organization.ts
1062
+ function readOrgClaim(accessToken) {
1063
+ return readTokenClaims(accessToken).org;
825
1064
  }
826
1065
  var NO_ACTIVE_ORG_HINT = "Your CLI session has no active organization, so the access token is missing the `org` claim that the platform API and ai-agent require. Run `npx @myspec/mcp-server login --org <slug>` to pin one.";
827
1066
  async function listOrganizations(opts) {
@@ -1111,9 +1350,13 @@ async function runLogin(options, deps = {}) {
1111
1350
  webappUrl: config.webappUrl
1112
1351
  });
1113
1352
  const enriched = await discoverAfterLogin(credentials, config.webappUrl, deps.discover, log);
1114
- const prior = await store.load().catch(() => null);
1115
- const preserved = prior?.apiToken === void 0 ? enriched : { ...enriched, apiToken: prior.apiToken };
1116
- 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());
1117
1360
  log(`Signed in as ${enriched.user?.email ?? "unknown"}.`);
1118
1361
  log(`Credentials saved to ${store.path()}`);
1119
1362
  await ensureActiveOrganization({
@@ -1196,15 +1439,23 @@ async function ensureActiveOrganization(args) {
1196
1439
  log(`Active organization: ${mintedOrg}`);
1197
1440
  }
1198
1441
  function defaultRefreshTokens(store) {
1199
- const tokenManager = new TokenManager({ store: keepCredentialsOnFailure(store) });
1442
+ const tokenManager = new TokenManager({ store: sessionOnly(store) });
1200
1443
  return tokenManager.forceRefresh();
1201
1444
  }
1202
- function keepCredentialsOnFailure(store) {
1445
+ function sessionOnly(store) {
1203
1446
  return {
1204
- load: () => store.load(),
1205
- save: (creds) => store.save(creds),
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
+ },
1206
1456
  clear: () => Promise.resolve(),
1207
- path: () => store.path()
1457
+ path: () => store.path(),
1458
+ ...store.withLock ? { withLock: store.withLock } : {}
1208
1459
  };
1209
1460
  }
1210
1461
  function sameSlug(a, b) {
@@ -1289,6 +1540,153 @@ async function runLogout(env = process.env) {
1289
1540
  }
1290
1541
  }
1291
1542
 
1543
+ // src/cli/auth-status.ts
1544
+ var NOT_SIGNED_IN = "Not signed in. Run `npx @myspec/mcp-server auth login` to sign in, or set MYSPEC_API_TOKEN to a MySpec API token.";
1545
+ var SESSION_REVOKED = "Your saved sign-in is no longer valid: the session was ended on the server. Run `npx @myspec/mcp-server auth login` to sign in again.";
1546
+ async function resolveAuthStatus(deps) {
1547
+ const stored = await deps.store.load();
1548
+ if (!stored) {
1549
+ throw new NeedsLoginError(NOT_SIGNED_IN);
1550
+ }
1551
+ let accessToken;
1552
+ try {
1553
+ accessToken = await deps.getAccessToken();
1554
+ } catch (err) {
1555
+ throw describeTokenFailure(err, stored.userAuthUrl);
1556
+ }
1557
+ const claims = readTokenClaims(accessToken);
1558
+ const kind = credentialKind(stored, deps.envApiTokenActive);
1559
+ const status = {
1560
+ userAuthUrl: stored.userAuthUrl,
1561
+ credential: { kind, location: deps.store.path() },
1562
+ // The saved user is the fallback only: an API-token record has none.
1563
+ email: claims.email ?? stored.user?.email,
1564
+ access: claims.access
1565
+ };
1566
+ const slug = claims.org;
1567
+ if (slug === void 0) {
1568
+ return status;
1569
+ }
1570
+ if (kind !== "login-session") {
1571
+ return {
1572
+ ...status,
1573
+ organization: { slug },
1574
+ orgNameNote: "API tokens do not include the organization name"
1575
+ };
1576
+ }
1577
+ let organizations;
1578
+ try {
1579
+ organizations = await deps.listOrgs({
1580
+ userAuthUrl: stored.userAuthUrl,
1581
+ accessToken,
1582
+ webappUrl: stored.webappUrl ?? deps.webappUrl
1583
+ });
1584
+ } catch (err) {
1585
+ if (err instanceof HttpStatusError && err.status === 401) {
1586
+ throw new NeedsLoginError(SESSION_REVOKED);
1587
+ }
1588
+ return {
1589
+ ...status,
1590
+ organization: { slug },
1591
+ orgNameNote: `could not look up its name: ${errorMessage2(err)}`
1592
+ };
1593
+ }
1594
+ const match = organizations.find((org) => org.slug.toLowerCase() === slug.toLowerCase());
1595
+ if (!match) {
1596
+ return {
1597
+ ...status,
1598
+ organization: { slug },
1599
+ orgNameNote: "not among your organizations; it may have been deleted, or you were removed"
1600
+ };
1601
+ }
1602
+ return { ...status, organization: { slug, name: match.name } };
1603
+ }
1604
+ function describeTokenFailure(err, userAuthUrl) {
1605
+ if (err instanceof TypeError || err instanceof Error && err.name === "TimeoutError") {
1606
+ return new ConfigError(
1607
+ `Could not reach ${userAuthUrl} to check the sign-in: ${errorMessage2(err)}`
1608
+ );
1609
+ }
1610
+ if (err instanceof HttpStatusError) {
1611
+ return new ConfigError(
1612
+ `Could not check the sign-in against ${userAuthUrl}: ${errorMessage2(err)}`
1613
+ );
1614
+ }
1615
+ return err;
1616
+ }
1617
+ function credentialKind(stored, envApiTokenActive) {
1618
+ if (stored.apiToken === void 0) {
1619
+ return "login-session";
1620
+ }
1621
+ return envApiTokenActive ? "env-api-token" : "file-api-token";
1622
+ }
1623
+ function formatAuthStatus(status) {
1624
+ const lines = [
1625
+ `Signed in to ${status.userAuthUrl}`,
1626
+ ` Account: ${status.email ?? "unknown"}`,
1627
+ ` Organization: ${formatOrganization(status)}`,
1628
+ ` Credential: ${formatCredential(status)}`
1629
+ ];
1630
+ if (status.organization === void 0) {
1631
+ lines.push("", `Warning: ${NO_ACTIVE_ORG_HINT}`);
1632
+ }
1633
+ return lines;
1634
+ }
1635
+ function formatOrganization(status) {
1636
+ const org = status.organization;
1637
+ if (org === void 0) {
1638
+ return "none";
1639
+ }
1640
+ if (org.name !== void 0) {
1641
+ return `${org.name} (${org.slug})`;
1642
+ }
1643
+ return status.orgNameNote === void 0 ? org.slug : `${org.slug} (${status.orgNameNote})`;
1644
+ }
1645
+ function formatCredential(status) {
1646
+ const access = status.access === void 0 ? "" : ` (${formatAccess(status.access)})`;
1647
+ switch (status.credential.kind) {
1648
+ case "env-api-token":
1649
+ return `API token from MYSPEC_API_TOKEN${access}`;
1650
+ case "file-api-token":
1651
+ return `API token in ${status.credential.location}${access}`;
1652
+ case "login-session":
1653
+ return `login session (${status.credential.location})`;
1654
+ }
1655
+ }
1656
+ function formatAccess(access) {
1657
+ if (access === "read") {
1658
+ return "read-only";
1659
+ }
1660
+ if (access === "write") {
1661
+ return "read-write";
1662
+ }
1663
+ return access;
1664
+ }
1665
+ function errorMessage2(err) {
1666
+ return redactSensitive(err instanceof Error ? err.message : String(err));
1667
+ }
1668
+ async function runAuthStatus(options, deps = {}) {
1669
+ const log = deps.log ?? ((message) => {
1670
+ process.stderr.write(message + "\n");
1671
+ });
1672
+ const tokenManager = new TokenManager({
1673
+ store: keepCredentialsOnFailure(options.store),
1674
+ fetchImpl: deps.fetchImpl
1675
+ });
1676
+ const listOrgs = deps.listOrgs ?? ((opts) => listOrganizations({ ...opts, fetchImpl: deps.fetchImpl }));
1677
+ const status = await resolveAuthStatus({
1678
+ store: options.store,
1679
+ getAccessToken: () => tokenManager.getValidAccessToken(),
1680
+ listOrgs,
1681
+ webappUrl: options.webappUrl,
1682
+ envApiTokenActive: options.envApiTokenActive
1683
+ });
1684
+ for (const line of formatAuthStatus(status)) {
1685
+ log(line);
1686
+ }
1687
+ return status;
1688
+ }
1689
+
1292
1690
  // src/platform/client.ts
1293
1691
  import { createHash } from "crypto";
1294
1692
 
@@ -7328,7 +7726,7 @@ async function handleList(args, opts) {
7328
7726
  try {
7329
7727
  await walk(realPath, "");
7330
7728
  } catch (err) {
7331
- return structuredError("list_dir_failed", errorMessage2(err));
7729
+ return structuredError("list_dir_failed", errorMessage3(err));
7332
7730
  }
7333
7731
  return structuredOk({ entries, truncated });
7334
7732
  }
@@ -7349,7 +7747,7 @@ async function handleRead(args, opts) {
7349
7747
  try {
7350
7748
  stat2 = await fs4.stat(realPath);
7351
7749
  } catch (err) {
7352
- return structuredError("stat_failed", errorMessage2(err));
7750
+ return structuredError("stat_failed", errorMessage3(err));
7353
7751
  }
7354
7752
  if (!stat2.isFile()) {
7355
7753
  return structuredError("not_a_file", `Not a regular file: ${args.path}`);
@@ -7365,7 +7763,7 @@ async function handleRead(args, opts) {
7365
7763
  try {
7366
7764
  raw = await fs4.readFile(realPath);
7367
7765
  } catch (err) {
7368
- return structuredError("read_failed", errorMessage2(err));
7766
+ return structuredError("read_failed", errorMessage3(err));
7369
7767
  }
7370
7768
  const probe = raw.subarray(0, Math.min(READ_FILE_BINARY_PROBE_BYTES, raw.length));
7371
7769
  if (probe.includes(0)) {
@@ -7572,7 +7970,7 @@ async function nodeGrep(searchRoot, realRoot, pattern, opts) {
7572
7970
  try {
7573
7971
  regex = opts.isRegex ? new RegExp(pattern, opts.caseSensitive ? "" : "i") : new RegExp(escapeRegExp(pattern), opts.caseSensitive ? "" : "i");
7574
7972
  } catch (err) {
7575
- throw new InvalidRegexError(errorMessage2(err));
7973
+ throw new InvalidRegexError(errorMessage3(err));
7576
7974
  }
7577
7975
  const matches = [];
7578
7976
  let truncated = false;
@@ -7646,7 +8044,7 @@ async function nodeGrep(searchRoot, realRoot, pattern, opts) {
7646
8044
  function escapeRegExp(s) {
7647
8045
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7648
8046
  }
7649
- function errorMessage2(err) {
8047
+ function errorMessage3(err) {
7650
8048
  return err instanceof Error ? err.message : String(err);
7651
8049
  }
7652
8050
  function mapPathError(err) {
@@ -7659,7 +8057,7 @@ function mapPathError(err) {
7659
8057
  if (err.code === "ENOENT") {
7660
8058
  return structuredError("not_found", "Path does not exist");
7661
8059
  }
7662
- return structuredError("resolve_failed", errorMessage2(err));
8060
+ return structuredError("resolve_failed", errorMessage3(err));
7663
8061
  }
7664
8062
  function mapPackError(err) {
7665
8063
  if (err instanceof PackCacheMissError) {
@@ -7674,7 +8072,7 @@ function mapPackError(err) {
7674
8072
  if (err instanceof AbsolutePathError || err instanceof PathOutsideRootError) {
7675
8073
  return mapPathError(err);
7676
8074
  }
7677
- return structuredError("pack_failed", errorMessage2(err));
8075
+ return structuredError("pack_failed", errorMessage3(err));
7678
8076
  }
7679
8077
  function registerLocalFsTools(server, opts) {
7680
8078
  const packCache = new PackCache();
@@ -7753,7 +8151,7 @@ function registerLocalFsTools(server, opts) {
7753
8151
  `);
7754
8152
  return result;
7755
8153
  } catch (err) {
7756
- const msg = errorMessage2(err);
8154
+ const msg = errorMessage3(err);
7757
8155
  const elapsedMs = Math.round(performance.now() - started);
7758
8156
  process.stdout.write(`[tool] ${name} -> threw ${String(elapsedMs)}ms ${msg}
7759
8157
  `);
@@ -8114,20 +8512,49 @@ function createReverseMcpPresenceProbe(deps) {
8114
8512
  }
8115
8513
 
8116
8514
  // src/index.ts
8515
+ var AUTH_COMMANDS = ["login", "logout", "status"];
8516
+ function isAuthCommand(value) {
8517
+ return AUTH_COMMANDS.includes(value);
8518
+ }
8117
8519
  function parseArgs(argv) {
8118
8520
  const [first, ...rest] = argv;
8119
- let command = "serve";
8120
- let flagArgs = argv;
8121
- if (first === "login" || first === "logout" || first === "serve" || first === "reverse" || first === "help" || first === "version") {
8122
- command = first;
8123
- flagArgs = rest;
8124
- } else if (first === "--help" || first === "-h") {
8125
- command = "help";
8126
- flagArgs = rest;
8127
- } else if (first === "--version" || first === "-v") {
8128
- command = "version";
8129
- flagArgs = rest;
8521
+ if (first === "auth") {
8522
+ const [sub, ...subRest] = rest;
8523
+ if (sub === void 0 || sub === "help" || sub === "--help" || sub === "-h") {
8524
+ return { command: "help", flags: /* @__PURE__ */ new Map() };
8525
+ }
8526
+ if (!isAuthCommand(sub)) {
8527
+ throw new ConfigError(
8528
+ `Unknown auth command: ${sub}. Expected one of: ${AUTH_COMMANDS.join(", ")}.`
8529
+ );
8530
+ }
8531
+ return authArgs(sub, subRest);
8532
+ }
8533
+ if (first === "login" || first === "logout") {
8534
+ return authArgs(first, rest);
8535
+ }
8536
+ if (first === "serve" || first === "reverse" || first === "help" || first === "version") {
8537
+ return { command: first, flags: parseFlags(rest) };
8538
+ }
8539
+ if (first === "--help" || first === "-h") {
8540
+ return { command: "help", flags: parseFlags(rest) };
8541
+ }
8542
+ if (first === "--version" || first === "-v") {
8543
+ return { command: "version", flags: parseFlags(rest) };
8544
+ }
8545
+ return { command: "serve", flags: parseFlags(argv) };
8546
+ }
8547
+ function authArgs(authCommand, args) {
8548
+ if (args.includes("-h")) {
8549
+ return { command: "help", flags: /* @__PURE__ */ new Map() };
8550
+ }
8551
+ const flags = parseFlags(args);
8552
+ if (flags.has("help")) {
8553
+ return { command: "help", flags };
8130
8554
  }
8555
+ return { command: "auth", authCommand, flags };
8556
+ }
8557
+ function parseFlags(flagArgs) {
8131
8558
  const flags = /* @__PURE__ */ new Map();
8132
8559
  const unknownPositional = [];
8133
8560
  for (let i = 0; i < flagArgs.length; i += 1) {
@@ -8157,7 +8584,7 @@ function parseArgs(argv) {
8157
8584
  `Unknown positional arguments: ${unknownPositional.join(", ")}. Run \`npx @myspec/mcp-server --help\` for usage.`
8158
8585
  );
8159
8586
  }
8160
- return { command, flags };
8587
+ return flags;
8161
8588
  }
8162
8589
  function flagString(flags, key) {
8163
8590
  const value = flags.get(key);
@@ -8176,16 +8603,21 @@ function printHelp() {
8176
8603
  "Commands:",
8177
8604
  " (default) Start MCP server over stdio",
8178
8605
  " serve Start MCP server over stdio",
8179
- " login Sign in via browser (chooser page on the webapp)",
8180
- " login --paste Same as login, but never auto-opens a browser. Open",
8181
- " the printed URL yourself; if the page cannot reach this",
8182
- " CLI it shows a `<code>.<state>` token to paste back.",
8183
- " login --org <slug> Sign in and pin that organization. Tokens only work",
8606
+ " auth login Sign in via browser (chooser page on the webapp)",
8607
+ " auth login --paste Same as auth login, but never auto-opens a browser.",
8608
+ " Open the printed URL yourself; if the page cannot reach",
8609
+ " this CLI it shows a `<code>.<state>` token to paste back.",
8610
+ " auth login --org <slug> Sign in and pin that organization. Tokens only work",
8184
8611
  " when an organization is active; the CLI asks when you",
8185
8612
  " belong to several and this flag is not given.",
8186
- " logout Sign out and clear the session credentials. A",
8613
+ " auth logout Sign out and clear the session credentials. A",
8187
8614
  " configured apiToken is KEPT, so an unattended",
8188
8615
  " install keeps working.",
8616
+ " auth status Show whether you are signed in, the account email and",
8617
+ " the active organization. Renews an expired access",
8618
+ " token first, as any command does. Exits 2 when not",
8619
+ " signed in.",
8620
+ " login, logout Aliases for `auth login` and `auth logout`.",
8189
8621
  " reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
8190
8622
  " The agent WebSocket URL is discovered from the webapp",
8191
8623
  " (GET /api/v1/config) using your saved credentials;",
@@ -8238,14 +8670,28 @@ function assertOrgFlagHasValue(flags) {
8238
8670
  }
8239
8671
  }
8240
8672
  async function main(argv = process.argv.slice(2)) {
8241
- const { command, flags } = parseArgs(argv);
8242
- switch (command) {
8673
+ const parsed = parseArgs(argv);
8674
+ const { flags } = parsed;
8675
+ switch (parsed.command) {
8243
8676
  case "help":
8244
8677
  printHelp();
8245
8678
  return;
8246
8679
  case "version":
8247
8680
  process.stderr.write(readVersion() + "\n");
8248
8681
  return;
8682
+ case "auth":
8683
+ await runAuthCommand(parsed.authCommand, flags);
8684
+ return;
8685
+ case "reverse":
8686
+ await runReverseCommand(flags);
8687
+ return;
8688
+ case "serve":
8689
+ await runServe(flags);
8690
+ return;
8691
+ }
8692
+ }
8693
+ async function runAuthCommand(authCommand, flags) {
8694
+ switch (authCommand) {
8249
8695
  case "login":
8250
8696
  assertOrgFlagHasValue(flags);
8251
8697
  await runLogin({
@@ -8257,13 +8703,14 @@ async function main(argv = process.argv.slice(2)) {
8257
8703
  case "logout":
8258
8704
  await runLogout();
8259
8705
  return;
8260
- case "reverse":
8261
- await runReverseCommand(flags);
8262
- return;
8263
- case "serve":
8264
- default:
8265
- await runServe(flags);
8706
+ case "status": {
8707
+ assertNoEnvAccessToken();
8708
+ const { store, config, envApiTokenActive } = await buildCredentialsStore({
8709
+ cliUserAuthUrl: flagString(flags, "user-auth-url")
8710
+ });
8711
+ await runAuthStatus({ store, webappUrl: config.webappUrl, envApiTokenActive });
8266
8712
  return;
8713
+ }
8267
8714
  }
8268
8715
  }
8269
8716
  var REVERSE_DISCOVERY_HINT = "Pass --agent-url <url>, set MYSPEC_AI_AGENT_WS_URL, or run `npx @myspec/mcp-server login` and retry.";
@@ -8291,10 +8738,13 @@ async function resolveReverseAgentUrl(sources) {
8291
8738
  );
8292
8739
  }
8293
8740
  if (stored && sources.persist) {
8294
- await sources.persist(applyDiscoveredUrls(stored, config.webappUrl, discovered)).catch((err) => {
8295
- const message = err instanceof Error ? err.message : String(err);
8296
- warn(`myspec-mcp reverse: could not save discovered URLs (${message})`);
8297
- });
8741
+ const latest = await sources.loadStored().catch(() => null);
8742
+ if (latest) {
8743
+ await sources.persist(applyDiscoveredUrls(latest, config.webappUrl, discovered)).catch((err) => {
8744
+ const message = err instanceof Error ? err.message : String(err);
8745
+ warn(`myspec-mcp reverse: could not save discovered URLs (${message})`);
8746
+ });
8747
+ }
8298
8748
  }
8299
8749
  return { agentUrl: discovered.aiAgentMcpReverseUrl, discoveredVia: endpoint };
8300
8750
  } catch (err) {
@@ -8353,17 +8803,12 @@ async function buildCredentialsStore(opts = {}) {
8353
8803
  // ConfigError exit — and one that never names the file it came from.
8354
8804
  storedUserAuthUrl: stored?.userAuthUrl ?? usableAuthUrl(fromSettings, settingsPath, warn)
8355
8805
  });
8806
+ const envConfig = readEnvApiToken(config.userAuthUrl, env, warn);
8356
8807
  return {
8357
- store: createEnvApiTokenStore({
8358
- fileStore,
8359
- // `warn` rather than the default sink, so the "cannot be sent as a
8360
- // header" warning lands wherever the caller's other warnings do.
8361
- envConfig: readEnvApiToken(config.userAuthUrl, env, warn),
8362
- settingsPath,
8363
- warn
8364
- }),
8808
+ store: createEnvApiTokenStore({ fileStore, envConfig, settingsPath, warn }),
8365
8809
  config,
8366
- usingEnvApiToken
8810
+ usingEnvApiToken,
8811
+ envApiTokenActive: envConfig !== null
8367
8812
  };
8368
8813
  }
8369
8814
  function warnIfStaticTokenShadowsApiToken(inlineToken, env = process.env, warn = (message) => {
@@ -8407,7 +8852,7 @@ async function runReverseCommand(flags) {
8407
8852
  };
8408
8853
  forceRefresh = () => tokenManager.forceRefresh();
8409
8854
  loadStored = () => store.load();
8410
- persist = (creds) => store.save(creds);
8855
+ persist = (creds) => saveDiscoveredUrls(store, creds);
8411
8856
  }
8412
8857
  const resolved = await resolveReverseAgentUrl({
8413
8858
  flagAgentUrl: flagString(flags, "agent-url"),
@@ -8460,9 +8905,9 @@ function createPlatformUrlResolver(deps) {
8460
8905
  `Could not discover endpoints from ${configEndpoint(deps.webappUrl)} (response is missing platformUrl). Run \`npx @myspec/mcp-server login\` and retry.`
8461
8906
  );
8462
8907
  }
8463
- const creds = await deps.tokenManager.getCredentials().catch(() => null);
8908
+ const creds = await deps.store.load().catch(() => null);
8464
8909
  if (creds) {
8465
- await deps.store.save(applyDiscoveredUrls(creds, deps.webappUrl, discovered)).catch((err) => {
8910
+ await saveDiscoveredUrls(deps.store, applyDiscoveredUrls(creds, deps.webappUrl, discovered)).catch((err) => {
8466
8911
  const message = err instanceof Error ? err.message : String(err);
8467
8912
  warn(`myspec-mcp: could not save discovered URLs (${message})`);
8468
8913
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "1.1.1-next.156",
3
+ "version": "1.1.1-next.158",
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": {