@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.
- package/README.md +49 -4
- package/dist/index.js +529 -84
- 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
|
|
23
|
-
login --paste
|
|
24
|
-
login --org <slug>
|
|
25
|
-
logout
|
|
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 (
|
|
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
|
-
|
|
787
|
+
isFresh(creds) {
|
|
788
|
+
return creds.expiresAt > this.now() + EXPIRY_SKEW_MS;
|
|
789
|
+
}
|
|
790
|
+
async refresh(creds, opts) {
|
|
685
791
|
if (this.refreshInFlight) {
|
|
686
|
-
|
|
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.
|
|
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
|
-
|
|
709
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1039
|
+
return {};
|
|
816
1040
|
}
|
|
1041
|
+
let claims;
|
|
817
1042
|
try {
|
|
818
|
-
|
|
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
|
|
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
|
|
1115
|
-
|
|
1116
|
-
|
|
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:
|
|
1442
|
+
const tokenManager = new TokenManager({ store: sessionOnly(store) });
|
|
1200
1443
|
return tokenManager.forceRefresh();
|
|
1201
1444
|
}
|
|
1202
|
-
function
|
|
1445
|
+
function sessionOnly(store) {
|
|
1203
1446
|
return {
|
|
1204
|
-
load: () =>
|
|
1205
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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(
|
|
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
|
|
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",
|
|
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",
|
|
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 =
|
|
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
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
8126
|
-
|
|
8127
|
-
|
|
8128
|
-
|
|
8129
|
-
|
|
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
|
|
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
|
|
8180
|
-
" login --paste
|
|
8181
|
-
" the printed URL yourself; if the page cannot reach
|
|
8182
|
-
" CLI it shows a `<code>.<state>` token to paste back.",
|
|
8183
|
-
" login --org <slug>
|
|
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
|
|
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
|
|
8242
|
-
|
|
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 "
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
await
|
|
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.
|
|
8295
|
-
|
|
8296
|
-
|
|
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
|
|
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.
|
|
8908
|
+
const creds = await deps.store.load().catch(() => null);
|
|
8464
8909
|
if (creds) {
|
|
8465
|
-
await deps.store
|
|
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.
|
|
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": {
|