@myspec/mcp-server 0.1.5-next.75 → 0.1.6-next.77
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 +26 -1
- package/dist/index.js +449 -140
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,11 +21,36 @@ Run with `npx @myspec/mcp-server [command]` (or `myspec-mcp [command]` when inst
|
|
|
21
21
|
serve Start MCP server over stdio
|
|
22
22
|
login Sign in via browser loopback OAuth
|
|
23
23
|
login --paste Sign in by pasting a one-time code
|
|
24
|
-
login --
|
|
24
|
+
login --org <slug> Sign in and pin that organization
|
|
25
25
|
logout Revoke refresh token and clear local credentials
|
|
26
26
|
reverse --root <dir> Connect to ai-agent and expose local_fs tools
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Active organization
|
|
30
|
+
|
|
31
|
+
Every MySpec access token carries the slug of one **active organization**, and
|
|
32
|
+
both the platform API and the ai-agent require it — a token without it is
|
|
33
|
+
rejected everywhere (`missing required claims (sub, org)` from the platform,
|
|
34
|
+
`MISSING_ORG_CLAIM` from the ai-agent).
|
|
35
|
+
|
|
36
|
+
`login` pins one for you: automatically when you belong to a single
|
|
37
|
+
organization, and by asking when you belong to several. Pass `--org <slug>` to
|
|
38
|
+
skip the question — required in a non-interactive shell, and the way to switch
|
|
39
|
+
the CLI to a different organization later:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx -y @myspec/mcp-server login --org my-team
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The chosen slug is printed at the end of a successful sign-in
|
|
46
|
+
(`Active organization: my-team`).
|
|
47
|
+
|
|
48
|
+
**Exit codes differ by mode.** Without `--org`, a problem in the organization
|
|
49
|
+
step is a warning and the sign-in still succeeds — the credential is valid and
|
|
50
|
+
you can pin an org later. With `--org`, it is fatal and the CLI exits non-zero,
|
|
51
|
+
because `login --org x && reverse` must not fall through to whichever
|
|
52
|
+
organization happened to already be pinned.
|
|
53
|
+
|
|
29
54
|
The only URL option is `--user-auth-url <url>` (default `https://auth.myspec.dev`),
|
|
30
55
|
accepted by every command. The webapp URL is derived from it (`auth.` → `app.`,
|
|
31
56
|
`dev-auth.` → `dev-app.`, `localhost:8787` → `localhost:5173`), and the remaining
|
package/dist/index.js
CHANGED
|
@@ -491,6 +491,264 @@ function isFsNotFound(err) {
|
|
|
491
491
|
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
// src/auth/token-manager.ts
|
|
495
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
496
|
+
var TokenManager = class {
|
|
497
|
+
store;
|
|
498
|
+
fetchImpl;
|
|
499
|
+
now;
|
|
500
|
+
envFallback;
|
|
501
|
+
cached = null;
|
|
502
|
+
refreshInFlight = null;
|
|
503
|
+
constructor(deps) {
|
|
504
|
+
this.store = deps.store;
|
|
505
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
506
|
+
this.now = deps.now ?? Date.now;
|
|
507
|
+
this.envFallback = deps.envFallback ?? null;
|
|
508
|
+
}
|
|
509
|
+
async getValidAccessToken() {
|
|
510
|
+
const creds = await this.ensureLoaded();
|
|
511
|
+
if (creds.expiresAt > this.now() + EXPIRY_SKEW_MS) {
|
|
512
|
+
return creds.accessToken;
|
|
513
|
+
}
|
|
514
|
+
const refreshed = await this.refresh(creds);
|
|
515
|
+
return refreshed.accessToken;
|
|
516
|
+
}
|
|
517
|
+
async getCredentials() {
|
|
518
|
+
return this.ensureLoaded();
|
|
519
|
+
}
|
|
520
|
+
async forceRefresh() {
|
|
521
|
+
const creds = await this.ensureLoaded();
|
|
522
|
+
const refreshed = await this.refresh(creds);
|
|
523
|
+
return refreshed.accessToken;
|
|
524
|
+
}
|
|
525
|
+
async clear() {
|
|
526
|
+
this.cached = null;
|
|
527
|
+
await this.store.clear();
|
|
528
|
+
}
|
|
529
|
+
async ensureLoaded() {
|
|
530
|
+
if (this.cached) {
|
|
531
|
+
return this.cached;
|
|
532
|
+
}
|
|
533
|
+
const loaded = await this.store.load();
|
|
534
|
+
if (!loaded) {
|
|
535
|
+
throw new NeedsLoginError();
|
|
536
|
+
}
|
|
537
|
+
this.cached = loaded;
|
|
538
|
+
return loaded;
|
|
539
|
+
}
|
|
540
|
+
async refresh(creds) {
|
|
541
|
+
if (this.refreshInFlight) {
|
|
542
|
+
return this.refreshInFlight;
|
|
543
|
+
}
|
|
544
|
+
this.refreshInFlight = this.doRefresh(creds).finally(() => {
|
|
545
|
+
this.refreshInFlight = null;
|
|
546
|
+
});
|
|
547
|
+
return this.refreshInFlight;
|
|
548
|
+
}
|
|
549
|
+
async doRefresh(creds) {
|
|
550
|
+
try {
|
|
551
|
+
return await this.performRefresh(creds);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
if (err instanceof RefreshRejectedError && this.envFallback && this.envFallback.refreshToken !== creds.refreshToken) {
|
|
554
|
+
const bootstrap = {
|
|
555
|
+
accessToken: "",
|
|
556
|
+
refreshToken: this.envFallback.refreshToken,
|
|
557
|
+
expiresAt: 0,
|
|
558
|
+
userAuthUrl: this.envFallback.userAuthUrl,
|
|
559
|
+
platformUrl: creds.platformUrl,
|
|
560
|
+
webappUrl: creds.webappUrl,
|
|
561
|
+
aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl,
|
|
562
|
+
user: creds.user
|
|
563
|
+
};
|
|
564
|
+
try {
|
|
565
|
+
return await this.performRefresh(bootstrap);
|
|
566
|
+
} catch (retryErr) {
|
|
567
|
+
await this.invalidateOnAuthFailure(retryErr);
|
|
568
|
+
throw retryErr;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
await this.invalidateOnAuthFailure(err);
|
|
572
|
+
throw err;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async invalidateOnAuthFailure(err) {
|
|
576
|
+
if (err instanceof NeedsLoginError) {
|
|
577
|
+
this.cached = null;
|
|
578
|
+
await this.store.clear();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async performRefresh(creds) {
|
|
582
|
+
const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
|
|
583
|
+
const response = await this.fetchImpl(url, {
|
|
584
|
+
method: "POST",
|
|
585
|
+
headers: { "Content-Type": "application/json" },
|
|
586
|
+
body: JSON.stringify({ refreshToken: creds.refreshToken })
|
|
587
|
+
});
|
|
588
|
+
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
|
|
589
|
+
throw new RefreshRejectedError(
|
|
590
|
+
"Refresh token rejected. Run `npx @myspec/mcp-server login` to sign in again."
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
if (!response.ok) {
|
|
594
|
+
const body = await response.text().catch(() => "");
|
|
595
|
+
throw new HttpStatusError(response.status, body, `Token refresh failed: HTTP ${String(response.status)}`);
|
|
596
|
+
}
|
|
597
|
+
const payload = await response.json().catch(() => null);
|
|
598
|
+
if (!payload || typeof payload.accessToken !== "string" || typeof payload.refreshToken !== "string" || typeof payload.expiresIn !== "number") {
|
|
599
|
+
throw new NeedsLoginError(
|
|
600
|
+
"Token refresh response was malformed. Run `npx @myspec/mcp-server login` to sign in again."
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const next = {
|
|
604
|
+
...creds,
|
|
605
|
+
accessToken: payload.accessToken,
|
|
606
|
+
refreshToken: payload.refreshToken,
|
|
607
|
+
expiresAt: this.now() + payload.expiresIn * 1e3
|
|
608
|
+
};
|
|
609
|
+
this.cached = next;
|
|
610
|
+
await this.store.save(next);
|
|
611
|
+
return next;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
var RefreshRejectedError = class extends NeedsLoginError {
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// src/auth/organization.ts
|
|
618
|
+
import readline3 from "readline";
|
|
619
|
+
function readOrgClaim(accessToken) {
|
|
620
|
+
const payload = accessToken.split(".")[1];
|
|
621
|
+
if (payload === void 0) {
|
|
622
|
+
return void 0;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const json = Buffer.from(payload, "base64url").toString("utf-8");
|
|
626
|
+
const claims = JSON.parse(json);
|
|
627
|
+
const org = claims.org;
|
|
628
|
+
return typeof org === "string" && org.length > 0 ? org : void 0;
|
|
629
|
+
} catch {
|
|
630
|
+
return void 0;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
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.";
|
|
634
|
+
async function listOrganizations(opts) {
|
|
635
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
636
|
+
const response = await fetchImpl(`${opts.userAuthUrl}/api/auth/organization/list`, {
|
|
637
|
+
method: "GET",
|
|
638
|
+
headers: { Authorization: `Bearer ${opts.accessToken}` }
|
|
639
|
+
});
|
|
640
|
+
if (!response.ok) {
|
|
641
|
+
const body2 = await response.text().catch(() => "");
|
|
642
|
+
throw new HttpStatusError(
|
|
643
|
+
response.status,
|
|
644
|
+
body2,
|
|
645
|
+
`Could not list your organizations: HTTP ${String(response.status)}`
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const body = await response.json().catch(() => null);
|
|
649
|
+
if (!Array.isArray(body)) {
|
|
650
|
+
throw new Error("Could not list your organizations: unexpected response shape.");
|
|
651
|
+
}
|
|
652
|
+
return body.flatMap((raw) => {
|
|
653
|
+
const org = raw;
|
|
654
|
+
if (typeof org.id !== "string" || typeof org.slug !== "string") {
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
return [
|
|
658
|
+
{
|
|
659
|
+
id: org.id,
|
|
660
|
+
slug: org.slug,
|
|
661
|
+
name: typeof org.name === "string" ? org.name : org.slug
|
|
662
|
+
}
|
|
663
|
+
];
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
async function setActiveOrganization(opts) {
|
|
667
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
668
|
+
const response = await fetchImpl(`${opts.userAuthUrl}/api/auth/organization/set-active`, {
|
|
669
|
+
method: "POST",
|
|
670
|
+
headers: {
|
|
671
|
+
Authorization: `Bearer ${opts.accessToken}`,
|
|
672
|
+
"Content-Type": "application/json",
|
|
673
|
+
Origin: opts.webappUrl
|
|
674
|
+
},
|
|
675
|
+
body: JSON.stringify({ organizationId: opts.organizationId })
|
|
676
|
+
});
|
|
677
|
+
if (!response.ok) {
|
|
678
|
+
const body = await response.text().catch(() => "");
|
|
679
|
+
const reason = response.status === 403 ? "that organization may have been deleted, or you are no longer a member" : `HTTP ${String(response.status)}`;
|
|
680
|
+
throw new HttpStatusError(
|
|
681
|
+
response.status,
|
|
682
|
+
body,
|
|
683
|
+
`Could not switch to the selected organization: ${reason}.`
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async function chooseOrganization(organizations, requestedSlug, prompt) {
|
|
688
|
+
if (organizations.length === 0) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
if (requestedSlug !== void 0) {
|
|
692
|
+
const wanted = requestedSlug.toLowerCase();
|
|
693
|
+
const match = organizations.find((org) => org.slug.toLowerCase() === wanted);
|
|
694
|
+
if (!match) {
|
|
695
|
+
const slugs = organizations.map((org) => org.slug).join(", ");
|
|
696
|
+
throw new Error(
|
|
697
|
+
`You are not a member of an organization with slug "${requestedSlug}". Available: ${slugs}.`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
return match;
|
|
701
|
+
}
|
|
702
|
+
const only = organizations[0];
|
|
703
|
+
if (organizations.length === 1 && only) {
|
|
704
|
+
return only;
|
|
705
|
+
}
|
|
706
|
+
return prompt(organizations);
|
|
707
|
+
}
|
|
708
|
+
async function promptForOrganization(organizations, deps = {}) {
|
|
709
|
+
const input = deps.input ?? process.stdin;
|
|
710
|
+
const output = deps.output ?? process.stderr;
|
|
711
|
+
if (input.isTTY !== true) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
output.write("You belong to several organizations. Which one should the CLI use?\n");
|
|
715
|
+
organizations.forEach((org, index) => {
|
|
716
|
+
output.write(` ${String(index + 1)}) ${org.name} (${org.slug})
|
|
717
|
+
`);
|
|
718
|
+
});
|
|
719
|
+
const rl = readline3.createInterface({ input, output });
|
|
720
|
+
try {
|
|
721
|
+
for (; ; ) {
|
|
722
|
+
const answer = await new Promise((resolve3) => {
|
|
723
|
+
const onClose = () => {
|
|
724
|
+
resolve3(null);
|
|
725
|
+
};
|
|
726
|
+
rl.once("close", onClose);
|
|
727
|
+
rl.question(`Enter a number [1-${String(organizations.length)}]: `, (value) => {
|
|
728
|
+
rl.removeListener("close", onClose);
|
|
729
|
+
resolve3(value);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
if (answer === null) {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const trimmed = answer.trim();
|
|
736
|
+
const picked = /^\d+$/.test(trimmed) ? organizations[Number.parseInt(trimmed, 10) - 1] : void 0;
|
|
737
|
+
if (picked) {
|
|
738
|
+
return picked;
|
|
739
|
+
}
|
|
740
|
+
output.write("Not a valid choice.\n");
|
|
741
|
+
}
|
|
742
|
+
} finally {
|
|
743
|
+
rl.close();
|
|
744
|
+
input.pause();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function nonInteractiveOrgHint(organizations) {
|
|
748
|
+
const slugs = organizations.map((org) => org.slug).join(", ");
|
|
749
|
+
return `You belong to several organizations and stdin is not interactive, so the CLI cannot ask which one to use. Re-run with \`npx @myspec/mcp-server login --org <slug>\` (available: ${slugs}).`;
|
|
750
|
+
}
|
|
751
|
+
|
|
494
752
|
// src/config.ts
|
|
495
753
|
function resolveConfig(sources = {}) {
|
|
496
754
|
const env = sources.env ?? process.env;
|
|
@@ -654,6 +912,108 @@ async function runLogin(options, deps = {}) {
|
|
|
654
912
|
await store.save(enriched);
|
|
655
913
|
log(`Signed in as ${enriched.user.email}.`);
|
|
656
914
|
log(`Credentials saved to ${store.path()}`);
|
|
915
|
+
await ensureActiveOrganization({
|
|
916
|
+
credentials: enriched,
|
|
917
|
+
webappUrl: config.webappUrl,
|
|
918
|
+
requestedSlug: options.org,
|
|
919
|
+
store,
|
|
920
|
+
log,
|
|
921
|
+
deps
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
async function ensureActiveOrganization(args) {
|
|
925
|
+
const { credentials, webappUrl, requestedSlug, store, log, deps } = args;
|
|
926
|
+
const currentOrg = readOrgClaim(credentials.accessToken);
|
|
927
|
+
const alreadyPinned = currentOrg !== void 0 && (requestedSlug === void 0 || sameSlug(requestedSlug, currentOrg));
|
|
928
|
+
if (alreadyPinned) {
|
|
929
|
+
log(`Active organization: ${currentOrg}`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const failed = (message) => {
|
|
933
|
+
if (requestedSlug !== void 0) {
|
|
934
|
+
throw new ConfigError(message);
|
|
935
|
+
}
|
|
936
|
+
log(`Warning: ${message}`);
|
|
937
|
+
};
|
|
938
|
+
const listOrgs = deps.listOrgs ?? listOrganizations;
|
|
939
|
+
const setActiveOrg = deps.setActiveOrg ?? setActiveOrganization;
|
|
940
|
+
const prompt = deps.prompt ?? promptForOrganization;
|
|
941
|
+
const refreshTokens = deps.refreshTokens ?? defaultRefreshTokens;
|
|
942
|
+
let organizations;
|
|
943
|
+
try {
|
|
944
|
+
organizations = await listOrgs({
|
|
945
|
+
userAuthUrl: credentials.userAuthUrl,
|
|
946
|
+
accessToken: credentials.accessToken,
|
|
947
|
+
webappUrl
|
|
948
|
+
});
|
|
949
|
+
} catch (err) {
|
|
950
|
+
failed(`${errorMessage(err)} ${orgFallbackHint(currentOrg)}`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (organizations.length === 0) {
|
|
954
|
+
failed(
|
|
955
|
+
`You do not belong to any organization yet, so the CLI cannot obtain a usable access token. Create a workspace at ${webappUrl}/onboarding, then run \`npx @myspec/mcp-server login\` again.`
|
|
956
|
+
);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
let target;
|
|
960
|
+
try {
|
|
961
|
+
target = await chooseOrganization(organizations, requestedSlug, (orgs) => {
|
|
962
|
+
return prompt(orgs);
|
|
963
|
+
});
|
|
964
|
+
} catch (err) {
|
|
965
|
+
failed(errorMessage(err));
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (!target) {
|
|
969
|
+
failed(nonInteractiveOrgHint(organizations));
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
let refreshedToken;
|
|
973
|
+
try {
|
|
974
|
+
await setActiveOrg({
|
|
975
|
+
userAuthUrl: credentials.userAuthUrl,
|
|
976
|
+
accessToken: credentials.accessToken,
|
|
977
|
+
webappUrl,
|
|
978
|
+
organizationId: target.id
|
|
979
|
+
});
|
|
980
|
+
refreshedToken = await refreshTokens(store);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
failed(`${errorMessage(err)} ${orgFallbackHint(currentOrg)}`);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
const mintedOrg = readOrgClaim(refreshedToken);
|
|
986
|
+
if (mintedOrg === void 0 || !sameSlug(mintedOrg, target.slug)) {
|
|
987
|
+
failed(
|
|
988
|
+
`Switched to "${target.slug}", but the new access token reports ${mintedOrg === void 0 ? "no organization" : `"${mintedOrg}"`}. Try again, or pick another with \`npx @myspec/mcp-server login --org <slug>\`.`
|
|
989
|
+
);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
log(`Active organization: ${mintedOrg}`);
|
|
993
|
+
}
|
|
994
|
+
function defaultRefreshTokens(store) {
|
|
995
|
+
const tokenManager = new TokenManager({ store: keepCredentialsOnFailure(store) });
|
|
996
|
+
return tokenManager.forceRefresh();
|
|
997
|
+
}
|
|
998
|
+
function keepCredentialsOnFailure(store) {
|
|
999
|
+
return {
|
|
1000
|
+
load: () => store.load(),
|
|
1001
|
+
save: (creds) => store.save(creds),
|
|
1002
|
+
clear: () => Promise.resolve(),
|
|
1003
|
+
path: () => store.path()
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function sameSlug(a, b) {
|
|
1007
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
1008
|
+
}
|
|
1009
|
+
function orgFallbackHint(currentOrg) {
|
|
1010
|
+
if (currentOrg !== void 0) {
|
|
1011
|
+
return `The CLI is still using the "${currentOrg}" organization.`;
|
|
1012
|
+
}
|
|
1013
|
+
return "The saved access token has no `org` claim, so `serve` and `reverse` will be rejected. Re-run `npx @myspec/mcp-server login --org <slug>`.";
|
|
1014
|
+
}
|
|
1015
|
+
function errorMessage(err) {
|
|
1016
|
+
return err instanceof Error ? err.message : String(err);
|
|
657
1017
|
}
|
|
658
1018
|
async function discoverAfterLogin(credentials, webappUrl, discover = discoverConfig, log = () => void 0) {
|
|
659
1019
|
try {
|
|
@@ -695,129 +1055,6 @@ async function runLogout() {
|
|
|
695
1055
|
process.stderr.write("Signed out and cleared local credentials.\n");
|
|
696
1056
|
}
|
|
697
1057
|
|
|
698
|
-
// src/auth/token-manager.ts
|
|
699
|
-
var EXPIRY_SKEW_MS = 3e4;
|
|
700
|
-
var TokenManager = class {
|
|
701
|
-
store;
|
|
702
|
-
fetchImpl;
|
|
703
|
-
now;
|
|
704
|
-
envFallback;
|
|
705
|
-
cached = null;
|
|
706
|
-
refreshInFlight = null;
|
|
707
|
-
constructor(deps) {
|
|
708
|
-
this.store = deps.store;
|
|
709
|
-
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
710
|
-
this.now = deps.now ?? Date.now;
|
|
711
|
-
this.envFallback = deps.envFallback ?? null;
|
|
712
|
-
}
|
|
713
|
-
async getValidAccessToken() {
|
|
714
|
-
const creds = await this.ensureLoaded();
|
|
715
|
-
if (creds.expiresAt > this.now() + EXPIRY_SKEW_MS) {
|
|
716
|
-
return creds.accessToken;
|
|
717
|
-
}
|
|
718
|
-
const refreshed = await this.refresh(creds);
|
|
719
|
-
return refreshed.accessToken;
|
|
720
|
-
}
|
|
721
|
-
async getCredentials() {
|
|
722
|
-
return this.ensureLoaded();
|
|
723
|
-
}
|
|
724
|
-
async forceRefresh() {
|
|
725
|
-
const creds = await this.ensureLoaded();
|
|
726
|
-
const refreshed = await this.refresh(creds);
|
|
727
|
-
return refreshed.accessToken;
|
|
728
|
-
}
|
|
729
|
-
async clear() {
|
|
730
|
-
this.cached = null;
|
|
731
|
-
await this.store.clear();
|
|
732
|
-
}
|
|
733
|
-
async ensureLoaded() {
|
|
734
|
-
if (this.cached) {
|
|
735
|
-
return this.cached;
|
|
736
|
-
}
|
|
737
|
-
const loaded = await this.store.load();
|
|
738
|
-
if (!loaded) {
|
|
739
|
-
throw new NeedsLoginError();
|
|
740
|
-
}
|
|
741
|
-
this.cached = loaded;
|
|
742
|
-
return loaded;
|
|
743
|
-
}
|
|
744
|
-
async refresh(creds) {
|
|
745
|
-
if (this.refreshInFlight) {
|
|
746
|
-
return this.refreshInFlight;
|
|
747
|
-
}
|
|
748
|
-
this.refreshInFlight = this.doRefresh(creds).finally(() => {
|
|
749
|
-
this.refreshInFlight = null;
|
|
750
|
-
});
|
|
751
|
-
return this.refreshInFlight;
|
|
752
|
-
}
|
|
753
|
-
async doRefresh(creds) {
|
|
754
|
-
try {
|
|
755
|
-
return await this.performRefresh(creds);
|
|
756
|
-
} catch (err) {
|
|
757
|
-
if (err instanceof RefreshRejectedError && this.envFallback && this.envFallback.refreshToken !== creds.refreshToken) {
|
|
758
|
-
const bootstrap = {
|
|
759
|
-
accessToken: "",
|
|
760
|
-
refreshToken: this.envFallback.refreshToken,
|
|
761
|
-
expiresAt: 0,
|
|
762
|
-
userAuthUrl: this.envFallback.userAuthUrl,
|
|
763
|
-
platformUrl: creds.platformUrl,
|
|
764
|
-
webappUrl: creds.webappUrl,
|
|
765
|
-
aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl,
|
|
766
|
-
user: creds.user
|
|
767
|
-
};
|
|
768
|
-
try {
|
|
769
|
-
return await this.performRefresh(bootstrap);
|
|
770
|
-
} catch (retryErr) {
|
|
771
|
-
await this.invalidateOnAuthFailure(retryErr);
|
|
772
|
-
throw retryErr;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
await this.invalidateOnAuthFailure(err);
|
|
776
|
-
throw err;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
async invalidateOnAuthFailure(err) {
|
|
780
|
-
if (err instanceof NeedsLoginError) {
|
|
781
|
-
this.cached = null;
|
|
782
|
-
await this.store.clear();
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
async performRefresh(creds) {
|
|
786
|
-
const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
|
|
787
|
-
const response = await this.fetchImpl(url, {
|
|
788
|
-
method: "POST",
|
|
789
|
-
headers: { "Content-Type": "application/json" },
|
|
790
|
-
body: JSON.stringify({ refreshToken: creds.refreshToken })
|
|
791
|
-
});
|
|
792
|
-
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
|
|
793
|
-
throw new RefreshRejectedError(
|
|
794
|
-
"Refresh token rejected. Run `npx @myspec/mcp-server login` to sign in again."
|
|
795
|
-
);
|
|
796
|
-
}
|
|
797
|
-
if (!response.ok) {
|
|
798
|
-
const body = await response.text().catch(() => "");
|
|
799
|
-
throw new HttpStatusError(response.status, body, `Token refresh failed: HTTP ${String(response.status)}`);
|
|
800
|
-
}
|
|
801
|
-
const payload = await response.json().catch(() => null);
|
|
802
|
-
if (!payload || typeof payload.accessToken !== "string" || typeof payload.refreshToken !== "string" || typeof payload.expiresIn !== "number") {
|
|
803
|
-
throw new NeedsLoginError(
|
|
804
|
-
"Token refresh response was malformed. Run `npx @myspec/mcp-server login` to sign in again."
|
|
805
|
-
);
|
|
806
|
-
}
|
|
807
|
-
const next = {
|
|
808
|
-
...creds,
|
|
809
|
-
accessToken: payload.accessToken,
|
|
810
|
-
refreshToken: payload.refreshToken,
|
|
811
|
-
expiresAt: this.now() + payload.expiresIn * 1e3
|
|
812
|
-
};
|
|
813
|
-
this.cached = next;
|
|
814
|
-
await this.store.save(next);
|
|
815
|
-
return next;
|
|
816
|
-
}
|
|
817
|
-
};
|
|
818
|
-
var RefreshRejectedError = class extends NeedsLoginError {
|
|
819
|
-
};
|
|
820
|
-
|
|
821
1058
|
// src/platform/client.ts
|
|
822
1059
|
import { createHash } from "crypto";
|
|
823
1060
|
|
|
@@ -3977,7 +4214,7 @@ async function handleList(args, opts) {
|
|
|
3977
4214
|
try {
|
|
3978
4215
|
await walk(realPath, "");
|
|
3979
4216
|
} catch (err) {
|
|
3980
|
-
return structuredError("list_dir_failed",
|
|
4217
|
+
return structuredError("list_dir_failed", errorMessage2(err));
|
|
3981
4218
|
}
|
|
3982
4219
|
return structuredOk({ entries, truncated });
|
|
3983
4220
|
}
|
|
@@ -3998,7 +4235,7 @@ async function handleRead(args, opts) {
|
|
|
3998
4235
|
try {
|
|
3999
4236
|
stat2 = await fs4.stat(realPath);
|
|
4000
4237
|
} catch (err) {
|
|
4001
|
-
return structuredError("stat_failed",
|
|
4238
|
+
return structuredError("stat_failed", errorMessage2(err));
|
|
4002
4239
|
}
|
|
4003
4240
|
if (!stat2.isFile()) {
|
|
4004
4241
|
return structuredError("not_a_file", `Not a regular file: ${args.path}`);
|
|
@@ -4014,7 +4251,7 @@ async function handleRead(args, opts) {
|
|
|
4014
4251
|
try {
|
|
4015
4252
|
raw = await fs4.readFile(realPath);
|
|
4016
4253
|
} catch (err) {
|
|
4017
|
-
return structuredError("read_failed",
|
|
4254
|
+
return structuredError("read_failed", errorMessage2(err));
|
|
4018
4255
|
}
|
|
4019
4256
|
const probe = raw.subarray(0, Math.min(READ_FILE_BINARY_PROBE_BYTES, raw.length));
|
|
4020
4257
|
if (probe.includes(0)) {
|
|
@@ -4221,7 +4458,7 @@ async function nodeGrep(searchRoot, realRoot, pattern, opts) {
|
|
|
4221
4458
|
try {
|
|
4222
4459
|
regex = opts.isRegex ? new RegExp(pattern, opts.caseSensitive ? "" : "i") : new RegExp(escapeRegExp(pattern), opts.caseSensitive ? "" : "i");
|
|
4223
4460
|
} catch (err) {
|
|
4224
|
-
throw new InvalidRegexError(
|
|
4461
|
+
throw new InvalidRegexError(errorMessage2(err));
|
|
4225
4462
|
}
|
|
4226
4463
|
const matches = [];
|
|
4227
4464
|
let truncated = false;
|
|
@@ -4295,7 +4532,7 @@ async function nodeGrep(searchRoot, realRoot, pattern, opts) {
|
|
|
4295
4532
|
function escapeRegExp(s) {
|
|
4296
4533
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4297
4534
|
}
|
|
4298
|
-
function
|
|
4535
|
+
function errorMessage2(err) {
|
|
4299
4536
|
return err instanceof Error ? err.message : String(err);
|
|
4300
4537
|
}
|
|
4301
4538
|
function mapPathError(err) {
|
|
@@ -4308,7 +4545,7 @@ function mapPathError(err) {
|
|
|
4308
4545
|
if (err.code === "ENOENT") {
|
|
4309
4546
|
return structuredError("not_found", "Path does not exist");
|
|
4310
4547
|
}
|
|
4311
|
-
return structuredError("resolve_failed",
|
|
4548
|
+
return structuredError("resolve_failed", errorMessage2(err));
|
|
4312
4549
|
}
|
|
4313
4550
|
function mapPackError(err) {
|
|
4314
4551
|
if (err instanceof PackCacheMissError) {
|
|
@@ -4323,7 +4560,7 @@ function mapPackError(err) {
|
|
|
4323
4560
|
if (err instanceof AbsolutePathError || err instanceof PathOutsideRootError) {
|
|
4324
4561
|
return mapPathError(err);
|
|
4325
4562
|
}
|
|
4326
|
-
return structuredError("pack_failed",
|
|
4563
|
+
return structuredError("pack_failed", errorMessage2(err));
|
|
4327
4564
|
}
|
|
4328
4565
|
function registerLocalFsTools(server, opts) {
|
|
4329
4566
|
const packCache = new PackCache();
|
|
@@ -4402,7 +4639,7 @@ function registerLocalFsTools(server, opts) {
|
|
|
4402
4639
|
`);
|
|
4403
4640
|
return result;
|
|
4404
4641
|
} catch (err) {
|
|
4405
|
-
const msg =
|
|
4642
|
+
const msg = errorMessage2(err);
|
|
4406
4643
|
const elapsedMs = Math.round(performance.now() - started);
|
|
4407
4644
|
process.stdout.write(`[tool] ${name} -> threw ${String(elapsedMs)}ms ${msg}
|
|
4408
4645
|
`);
|
|
@@ -4481,7 +4718,8 @@ async function runReverse(opts) {
|
|
|
4481
4718
|
const realRoot = await canonicaliseRoot(opts.root);
|
|
4482
4719
|
const rootLabel = basename3(realRoot);
|
|
4483
4720
|
process.stderr.write(
|
|
4484
|
-
`myspec-mcp reverse: granting read access to ${realRoot}
|
|
4721
|
+
`myspec-mcp reverse: granting read access to ${realRoot}
|
|
4722
|
+
Press Ctrl-C to stop. Auto-reconnect enabled.
|
|
4485
4723
|
`
|
|
4486
4724
|
);
|
|
4487
4725
|
let backoffMs = RECONNECT_BACKOFF_INITIAL_MS;
|
|
@@ -4502,6 +4740,11 @@ Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again
|
|
|
4502
4740
|
);
|
|
4503
4741
|
return;
|
|
4504
4742
|
}
|
|
4743
|
+
if (readOrgClaim(accessToken) === void 0) {
|
|
4744
|
+
process.stderr.write(`myspec-mcp reverse: ${NO_ACTIVE_ORG_HINT}
|
|
4745
|
+
`);
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4505
4748
|
try {
|
|
4506
4749
|
outcome = await runOneConnection({
|
|
4507
4750
|
realRoot,
|
|
@@ -4512,9 +4755,16 @@ Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again
|
|
|
4512
4755
|
});
|
|
4513
4756
|
} catch (err) {
|
|
4514
4757
|
const message = err instanceof Error ? err.message : String(err);
|
|
4515
|
-
process.stderr.write(
|
|
4516
|
-
`)
|
|
4517
|
-
|
|
4758
|
+
process.stderr.write(
|
|
4759
|
+
`myspec-mcp reverse: connect attempt ${String(attempt)} failed: ${message}
|
|
4760
|
+
`
|
|
4761
|
+
);
|
|
4762
|
+
outcome = {
|
|
4763
|
+
terminal: false,
|
|
4764
|
+
reason: `connect_error: ${message}`,
|
|
4765
|
+
upMs: 0,
|
|
4766
|
+
code: isTokenRejection(err) ? 4001 : 0
|
|
4767
|
+
};
|
|
4518
4768
|
}
|
|
4519
4769
|
if (outcome.upMs >= HEALTHY_CONNECTION_THRESHOLD_MS) {
|
|
4520
4770
|
backoffMs = RECONNECT_BACKOFF_INITIAL_MS;
|
|
@@ -4523,7 +4773,7 @@ Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again
|
|
|
4523
4773
|
if (outcome.code === 4001) {
|
|
4524
4774
|
if (opts.onAuthFailed && !authRetried) {
|
|
4525
4775
|
process.stderr.write(
|
|
4526
|
-
"myspec-mcp reverse: server rejected token
|
|
4776
|
+
"myspec-mcp reverse: server rejected the token; force-refreshing and retrying once...\n"
|
|
4527
4777
|
);
|
|
4528
4778
|
try {
|
|
4529
4779
|
await opts.onAuthFailed();
|
|
@@ -4539,7 +4789,9 @@ Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again
|
|
|
4539
4789
|
}
|
|
4540
4790
|
}
|
|
4541
4791
|
process.stderr.write(
|
|
4542
|
-
|
|
4792
|
+
`myspec-mcp reverse: token rejected after refresh attempt; run \`npx @myspec/mcp-server login\` and retry.
|
|
4793
|
+
If you belong to more than one organization: ${NO_ACTIVE_ORG_HINT}
|
|
4794
|
+
`
|
|
4543
4795
|
);
|
|
4544
4796
|
return;
|
|
4545
4797
|
}
|
|
@@ -4550,6 +4802,9 @@ Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again
|
|
|
4550
4802
|
);
|
|
4551
4803
|
return;
|
|
4552
4804
|
}
|
|
4805
|
+
if (opts.maxAttempts !== void 0 && attempt >= opts.maxAttempts) {
|
|
4806
|
+
return;
|
|
4807
|
+
}
|
|
4553
4808
|
process.stderr.write(
|
|
4554
4809
|
`myspec-mcp reverse: reconnecting in ${String(Math.round(backoffMs / 1e3))}s...
|
|
4555
4810
|
`
|
|
@@ -4631,18 +4886,61 @@ async function runOneConnection(opts) {
|
|
|
4631
4886
|
});
|
|
4632
4887
|
});
|
|
4633
4888
|
}
|
|
4889
|
+
var HandshakeError = class extends Error {
|
|
4890
|
+
status;
|
|
4891
|
+
/** Response body, truncated. The agent explains the refusal here. */
|
|
4892
|
+
body;
|
|
4893
|
+
constructor(status, body) {
|
|
4894
|
+
const suffix = body ? ` \u2014 ${body}` : "";
|
|
4895
|
+
super(`WebSocket upgrade rejected: HTTP ${String(status)}${suffix}`);
|
|
4896
|
+
this.name = "HandshakeError";
|
|
4897
|
+
this.status = status;
|
|
4898
|
+
this.body = body;
|
|
4899
|
+
}
|
|
4900
|
+
};
|
|
4901
|
+
var HANDSHAKE_BODY_LIMIT = 512;
|
|
4902
|
+
var TOKEN_REJECTION_PATTERN = /INVALID_TOKEN|TOKEN_EXPIRED|INVALID_ISSUER|missing bearer/i;
|
|
4903
|
+
function isTokenRejection(err) {
|
|
4904
|
+
return err instanceof HandshakeError && err.status === 401 && TOKEN_REJECTION_PATTERN.test(err.body);
|
|
4905
|
+
}
|
|
4634
4906
|
function waitForOpen(ws) {
|
|
4635
4907
|
return new Promise((resolve3, reject) => {
|
|
4636
|
-
const
|
|
4908
|
+
const cleanup = () => {
|
|
4909
|
+
ws.removeListener("open", onOpen);
|
|
4637
4910
|
ws.removeListener("error", onError);
|
|
4911
|
+
ws.removeListener("unexpected-response", onUnexpectedResponse);
|
|
4912
|
+
};
|
|
4913
|
+
const onOpen = () => {
|
|
4914
|
+
cleanup();
|
|
4638
4915
|
resolve3();
|
|
4639
4916
|
};
|
|
4640
4917
|
const onError = (err) => {
|
|
4641
|
-
|
|
4918
|
+
cleanup();
|
|
4642
4919
|
reject(err);
|
|
4643
4920
|
};
|
|
4921
|
+
const onUnexpectedResponse = (request, response) => {
|
|
4922
|
+
cleanup();
|
|
4923
|
+
ws.on("error", () => void 0);
|
|
4924
|
+
const status = response.statusCode ?? 0;
|
|
4925
|
+
const socket = response.socket;
|
|
4926
|
+
let body = "";
|
|
4927
|
+
const settle = () => {
|
|
4928
|
+
request.destroy?.();
|
|
4929
|
+
socket.destroy();
|
|
4930
|
+
reject(new HandshakeError(status, body.trim()));
|
|
4931
|
+
};
|
|
4932
|
+
response.setEncoding("utf-8");
|
|
4933
|
+
response.on("data", (chunk) => {
|
|
4934
|
+
if (body.length < HANDSHAKE_BODY_LIMIT) {
|
|
4935
|
+
body = (body + chunk).slice(0, HANDSHAKE_BODY_LIMIT);
|
|
4936
|
+
}
|
|
4937
|
+
});
|
|
4938
|
+
response.once("end", settle);
|
|
4939
|
+
response.once("error", settle);
|
|
4940
|
+
};
|
|
4644
4941
|
ws.once("open", onOpen);
|
|
4645
4942
|
ws.once("error", onError);
|
|
4943
|
+
ws.once("unexpected-response", onUnexpectedResponse);
|
|
4646
4944
|
});
|
|
4647
4945
|
}
|
|
4648
4946
|
function sleep2(ms) {
|
|
@@ -4714,6 +5012,9 @@ function printHelp() {
|
|
|
4714
5012
|
" serve Start MCP server over stdio",
|
|
4715
5013
|
" login Sign in via browser (chooser page on the webapp)",
|
|
4716
5014
|
" login --paste Sign in by pasting a one-time code",
|
|
5015
|
+
" login --org <slug> Sign in and pin that organization. Tokens only work",
|
|
5016
|
+
" when an organization is active; the CLI asks when you",
|
|
5017
|
+
" belong to several and this flag is not given.",
|
|
4717
5018
|
" logout Revoke refresh token and clear local credentials",
|
|
4718
5019
|
" reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
|
|
4719
5020
|
" The agent WebSocket URL is discovered from the webapp",
|
|
@@ -4756,6 +5057,12 @@ function readVersion() {
|
|
|
4756
5057
|
return "unknown";
|
|
4757
5058
|
}
|
|
4758
5059
|
}
|
|
5060
|
+
function assertOrgFlagHasValue(flags) {
|
|
5061
|
+
const orgFlag = flags.get("org");
|
|
5062
|
+
if (orgFlag === true || orgFlag === "") {
|
|
5063
|
+
throw new ConfigError("`--org` needs an organization slug, e.g. `--org my-team`.");
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
4759
5066
|
async function main(argv = process.argv.slice(2)) {
|
|
4760
5067
|
const { command, flags } = parseArgs(argv);
|
|
4761
5068
|
switch (command) {
|
|
@@ -4766,9 +5073,11 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
4766
5073
|
process.stderr.write(readVersion() + "\n");
|
|
4767
5074
|
return;
|
|
4768
5075
|
case "login":
|
|
5076
|
+
assertOrgFlagHasValue(flags);
|
|
4769
5077
|
await runLogin({
|
|
4770
5078
|
paste: flagBool(flags, "paste"),
|
|
4771
|
-
userAuthUrl: flagString(flags, "user-auth-url")
|
|
5079
|
+
userAuthUrl: flagString(flags, "user-auth-url"),
|
|
5080
|
+
org: flagString(flags, "org")
|
|
4772
5081
|
});
|
|
4773
5082
|
return;
|
|
4774
5083
|
case "logout":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myspec/mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6-next.77",
|
|
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": {
|