@myspec/mcp-server 0.1.0 → 0.1.1-next.16
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 +19 -8
- package/dist/index.js +270 -139
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,18 @@ 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
|
+
The only URL option is `--user-auth-url <url>` (default `https://auth.myspec.dev`),
|
|
30
|
+
accepted by every command. The webapp URL is derived from it (`auth.` → `app.`,
|
|
31
|
+
`dev-auth.` → `dev-app.`, `localhost:8787` → `localhost:5173`), and the remaining
|
|
32
|
+
endpoints — the platform API base URL and the ai-agent WebSocket URL — are
|
|
33
|
+
discovered after login from the webapp's authenticated `GET /api/v1/config`,
|
|
34
|
+
then saved to `~/.myspec/settings.json` for subsequent runs. Example against the
|
|
35
|
+
dev environment:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx -y @myspec/mcp-server login --user-auth-url https://dev-auth.myspec.dev
|
|
39
|
+
```
|
|
40
|
+
|
|
29
41
|
### `reverse`
|
|
30
42
|
|
|
31
43
|
`reverse` connects out to the ai-agent service over WebSocket and exposes a set
|
|
@@ -33,30 +45,31 @@ of `local_fs` tools scoped to a single local directory, so a cloud agent can
|
|
|
33
45
|
read files from your machine.
|
|
34
46
|
|
|
35
47
|
```bash
|
|
36
|
-
npx @myspec/mcp-server
|
|
48
|
+
npx @myspec/mcp-server reverse --root /path/to/project
|
|
37
49
|
```
|
|
38
50
|
|
|
39
51
|
The ai-agent WebSocket URL is **discovered at startup** from the webapp's
|
|
40
52
|
authenticated `GET /api/v1/config` endpoint using your saved credentials, so
|
|
41
53
|
the agent domain is never hardcoded in the CLI and can change server-side.
|
|
42
|
-
|
|
54
|
+
A successful discovery refreshes the URLs saved in `~/.myspec/settings.json`;
|
|
55
|
+
if discovery fails, `reverse` falls back to the agent URL saved by a previous
|
|
56
|
+
successful discovery, and otherwise exits with an actionable error instead of
|
|
43
57
|
guessing — pass `--agent-url` or set `MYSPEC_AI_AGENT_WS_URL` to skip
|
|
44
58
|
discovery (e.g. against a local ai-agent):
|
|
45
59
|
|
|
46
60
|
```bash
|
|
47
|
-
npx @myspec/mcp-server
|
|
61
|
+
npx @myspec/mcp-server reverse --root . --agent-url ws://localhost:3001/mcp/reverse
|
|
48
62
|
```
|
|
49
63
|
|
|
50
64
|
| Flag / env | Purpose |
|
|
51
65
|
|---|---|
|
|
52
66
|
| `--root <dir>` | Directory to grant read access to (default: current working directory). All tool paths resolve relative to this root; nothing outside it is reachable. |
|
|
53
67
|
| `--agent-url <url>` / `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL override; skips webapp discovery entirely. |
|
|
54
|
-
| `--webapp-url <url>` | Webapp base URL used for discovery (default: the URL stored at login, else derived from the auth host). |
|
|
55
68
|
| `--access-token <jwt>` / `MYSPEC_ACCESS_TOKEN` | Use a static access token for local testing instead of the saved credentials. Otherwise `reverse` uses the refresh token from `npx @myspec/mcp-server login` and auto-refreshes. |
|
|
56
69
|
|
|
57
70
|
Local state is stored under `~/.myspec/` (alongside the on-disk cache root), split into two files:
|
|
58
71
|
|
|
59
|
-
- `settings.json` — non-secret config: the auth-server
|
|
72
|
+
- `settings.json` — non-secret config: the auth-server URL you logged in with, plus the derived webapp URL and the discovered platform / ai-agent URLs (refreshed on every successful discovery).
|
|
60
73
|
- `oauth_creds.json` — the OAuth tokens (access/refresh/expiry) and your user identity, written mode `0600`.
|
|
61
74
|
|
|
62
75
|
`logout` removes `oauth_creds.json`; `settings.json` is left in place.
|
|
@@ -65,11 +78,9 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
|
|
|
65
78
|
|
|
66
79
|
| Variable | Purpose |
|
|
67
80
|
|---|---|
|
|
68
|
-
| `MYSPEC_USER_AUTH_URL` | user-auth base URL (default `https://auth.myspec.dev`) |
|
|
69
|
-
| `MYSPEC_PLATFORM_URL` | platform API base URL (default `https://platform.myspec.dev`) |
|
|
81
|
+
| `MYSPEC_USER_AUTH_URL` | user-auth base URL (default `https://auth.myspec.dev`). The webapp URL is derived from it; the platform / ai-agent URLs are discovered via the webapp. |
|
|
70
82
|
| `MYSPEC_REFRESH_TOKEN` | Skip file-based credentials; the server mints a fresh access token on first use via this refresh token. The supported way to wire the MCP server up without running `npx @myspec/mcp-server login`. |
|
|
71
83
|
| `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
|
|
72
|
-
| `MYSPEC_WEBAPP_URL` | Webapp base URL used by `login` (the `/auth/cli` page) and by `reverse` discovery (default: the URL stored at login, else derived from `MYSPEC_USER_AUTH_URL`) |
|
|
73
84
|
| `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
|
|
74
85
|
|
|
75
86
|
## Tools
|
package/dist/index.js
CHANGED
|
@@ -89,7 +89,6 @@ async function loopbackLogin(opts) {
|
|
|
89
89
|
refreshToken: exchanged.refreshToken,
|
|
90
90
|
expiresAt: now() + exchanged.expiresIn * 1e3,
|
|
91
91
|
userAuthUrl: opts.userAuthUrl,
|
|
92
|
-
platformUrl: opts.platformUrl,
|
|
93
92
|
webappUrl: opts.webappUrl,
|
|
94
93
|
user: exchanged.user
|
|
95
94
|
};
|
|
@@ -266,7 +265,6 @@ async function pasteLogin(opts) {
|
|
|
266
265
|
refreshToken: exchanged.refreshToken,
|
|
267
266
|
expiresAt: now() + exchanged.expiresIn * 1e3,
|
|
268
267
|
userAuthUrl: opts.userAuthUrl,
|
|
269
|
-
platformUrl: opts.platformUrl,
|
|
270
268
|
webappUrl: opts.webappUrl,
|
|
271
269
|
user: exchanged.user
|
|
272
270
|
};
|
|
@@ -316,6 +314,7 @@ function createFileCredentialsStore(paths = {}) {
|
|
|
316
314
|
userAuthUrl: settings.userAuthUrl,
|
|
317
315
|
platformUrl: settings.platformUrl,
|
|
318
316
|
webappUrl: settings.webappUrl,
|
|
317
|
+
aiAgentMcpReverseUrl: settings.aiAgentMcpReverseUrl,
|
|
319
318
|
user: oauth.user
|
|
320
319
|
};
|
|
321
320
|
},
|
|
@@ -325,7 +324,8 @@ function createFileCredentialsStore(paths = {}) {
|
|
|
325
324
|
{
|
|
326
325
|
userAuthUrl: creds.userAuthUrl,
|
|
327
326
|
platformUrl: creds.platformUrl,
|
|
328
|
-
webappUrl: creds.webappUrl
|
|
327
|
+
webappUrl: creds.webappUrl,
|
|
328
|
+
aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl
|
|
329
329
|
},
|
|
330
330
|
{ secret: false }
|
|
331
331
|
);
|
|
@@ -470,7 +470,8 @@ function parseSettings(value) {
|
|
|
470
470
|
const userAuthUrl = requireString(v, "userAuthUrl");
|
|
471
471
|
const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
|
|
472
472
|
const webappUrl = typeof v.webappUrl === "string" ? v.webappUrl : void 0;
|
|
473
|
-
|
|
473
|
+
const aiAgentMcpReverseUrl = typeof v.aiAgentMcpReverseUrl === "string" ? v.aiAgentMcpReverseUrl : void 0;
|
|
474
|
+
return { userAuthUrl, platformUrl, webappUrl, aiAgentMcpReverseUrl };
|
|
474
475
|
}
|
|
475
476
|
function requireString(obj, key) {
|
|
476
477
|
const value = obj[key];
|
|
@@ -494,14 +495,11 @@ function isFsNotFound(err) {
|
|
|
494
495
|
function resolveConfig(sources = {}) {
|
|
495
496
|
const env = sources.env ?? process.env;
|
|
496
497
|
const userAuthUrl = sources.cliUserAuthUrl ?? env.MYSPEC_USER_AUTH_URL ?? sources.storedUserAuthUrl ?? "https://auth.myspec.dev";
|
|
497
|
-
const
|
|
498
|
-
const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? sources.storedWebappUrl ?? deriveWebappFromAuth(userAuthUrl);
|
|
498
|
+
const webappUrl = deriveWebappFromAuth(userAuthUrl);
|
|
499
499
|
validateUrl(userAuthUrl, "user-auth URL");
|
|
500
|
-
validateUrl(platformUrl, "platform URL");
|
|
501
500
|
validateUrl(webappUrl, "webapp URL");
|
|
502
501
|
return {
|
|
503
502
|
userAuthUrl: stripTrailingSlash(userAuthUrl),
|
|
504
|
-
platformUrl: stripTrailingSlash(platformUrl),
|
|
505
503
|
webappUrl: stripTrailingSlash(webappUrl)
|
|
506
504
|
};
|
|
507
505
|
}
|
|
@@ -540,32 +538,136 @@ function stripTrailingSlash(value) {
|
|
|
540
538
|
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
541
539
|
}
|
|
542
540
|
|
|
541
|
+
// src/discovery.ts
|
|
542
|
+
var DEFAULT_ERROR_HINT = "Run `npx @myspec/mcp-server login` and retry.";
|
|
543
|
+
async function discoverConfig(opts) {
|
|
544
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
545
|
+
const endpoint = configEndpoint(opts.webappUrl);
|
|
546
|
+
const hint = opts.errorHint ?? DEFAULT_ERROR_HINT;
|
|
547
|
+
let response;
|
|
548
|
+
try {
|
|
549
|
+
const token = await opts.getAccessToken();
|
|
550
|
+
response = await fetchConfig(endpoint, token, fetchImpl);
|
|
551
|
+
if (response.status === 401 && opts.forceRefresh) {
|
|
552
|
+
const refreshed = await opts.forceRefresh();
|
|
553
|
+
response = await fetchConfig(endpoint, refreshed, fetchImpl);
|
|
554
|
+
}
|
|
555
|
+
} catch (err) {
|
|
556
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
557
|
+
throw discoveryError(endpoint, message, hint);
|
|
558
|
+
}
|
|
559
|
+
if (!response.ok) {
|
|
560
|
+
throw discoveryError(endpoint, `HTTP ${String(response.status)}`, hint);
|
|
561
|
+
}
|
|
562
|
+
let body;
|
|
563
|
+
try {
|
|
564
|
+
body = await response.json();
|
|
565
|
+
} catch {
|
|
566
|
+
throw discoveryError(endpoint, "response is not valid JSON", hint);
|
|
567
|
+
}
|
|
568
|
+
const platformUrl = optionalString(body.platformUrl);
|
|
569
|
+
if (platformUrl !== void 0) {
|
|
570
|
+
assertHttpUrl(platformUrl, endpoint, hint);
|
|
571
|
+
}
|
|
572
|
+
const aiAgentMcpReverseUrl = optionalString(body.aiAgentMcpReverseUrl);
|
|
573
|
+
if (aiAgentMcpReverseUrl !== void 0) {
|
|
574
|
+
assertWebSocketUrl(aiAgentMcpReverseUrl, endpoint, hint);
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
platformUrl: platformUrl ? stripTrailingSlash2(platformUrl) : void 0,
|
|
578
|
+
aiAgentMcpReverseUrl
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function configEndpoint(webappUrl) {
|
|
582
|
+
return `${webappUrl}/api/v1/config`;
|
|
583
|
+
}
|
|
584
|
+
function applyDiscoveredUrls(creds, webappUrl, discovered) {
|
|
585
|
+
return {
|
|
586
|
+
...creds,
|
|
587
|
+
webappUrl,
|
|
588
|
+
platformUrl: discovered.platformUrl ?? creds.platformUrl,
|
|
589
|
+
aiAgentMcpReverseUrl: discovered.aiAgentMcpReverseUrl ?? creds.aiAgentMcpReverseUrl
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function fetchConfig(endpoint, token, fetchImpl) {
|
|
593
|
+
return fetchImpl(endpoint, {
|
|
594
|
+
method: "GET",
|
|
595
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
function optionalString(value) {
|
|
599
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
600
|
+
}
|
|
601
|
+
function assertHttpUrl(value, endpoint, hint) {
|
|
602
|
+
const parsed = parseUrl(value, endpoint, hint);
|
|
603
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
604
|
+
throw discoveryError(
|
|
605
|
+
endpoint,
|
|
606
|
+
`returned a non-HTTP platformUrl (${value}); expected http:// or https://`,
|
|
607
|
+
hint
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function assertWebSocketUrl(value, endpoint, hint) {
|
|
612
|
+
const parsed = parseUrl(value, endpoint, hint);
|
|
613
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
614
|
+
throw discoveryError(
|
|
615
|
+
endpoint,
|
|
616
|
+
`returned a non-WebSocket URL (${value}); expected ws:// or wss://`,
|
|
617
|
+
hint
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function parseUrl(value, endpoint, hint) {
|
|
622
|
+
try {
|
|
623
|
+
return new URL(value);
|
|
624
|
+
} catch {
|
|
625
|
+
throw discoveryError(endpoint, `returned an invalid URL: ${value}`, hint);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function stripTrailingSlash2(value) {
|
|
629
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
630
|
+
}
|
|
631
|
+
function discoveryError(endpoint, reason, hint) {
|
|
632
|
+
return new ConfigError(`Could not discover endpoints from ${endpoint} (${reason}). ${hint}`);
|
|
633
|
+
}
|
|
634
|
+
|
|
543
635
|
// src/cli/login.ts
|
|
544
|
-
async function runLogin(options) {
|
|
545
|
-
const config = resolveConfig({
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
636
|
+
async function runLogin(options, deps = {}) {
|
|
637
|
+
const config = resolveConfig({ cliUserAuthUrl: options.userAuthUrl });
|
|
638
|
+
const store = deps.store ?? createFileCredentialsStore();
|
|
639
|
+
const log = deps.log ?? ((message) => {
|
|
640
|
+
process.stderr.write(message + "\n");
|
|
549
641
|
});
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
userAuthUrl: config.userAuthUrl,
|
|
553
|
-
platformUrl: config.platformUrl,
|
|
554
|
-
webappUrl: config.webappUrl
|
|
555
|
-
}) : await loopbackLogin({
|
|
556
|
-
userAuthUrl: config.userAuthUrl,
|
|
557
|
-
platformUrl: config.platformUrl,
|
|
558
|
-
webappUrl: config.webappUrl,
|
|
642
|
+
const doLogin = deps.doLogin ?? ((cfg) => options.paste ? pasteLogin(cfg) : loopbackLogin({
|
|
643
|
+
...cfg,
|
|
559
644
|
openBrowser: async (url) => {
|
|
560
645
|
const mod = await import("open");
|
|
561
646
|
await mod.default(url);
|
|
562
647
|
}
|
|
648
|
+
}));
|
|
649
|
+
const credentials = await doLogin({
|
|
650
|
+
userAuthUrl: config.userAuthUrl,
|
|
651
|
+
webappUrl: config.webappUrl
|
|
563
652
|
});
|
|
564
|
-
await
|
|
565
|
-
|
|
566
|
-
`);
|
|
567
|
-
|
|
568
|
-
|
|
653
|
+
const enriched = await discoverAfterLogin(credentials, config.webappUrl, deps.discover, log);
|
|
654
|
+
await store.save(enriched);
|
|
655
|
+
log(`Signed in as ${enriched.user.email}.`);
|
|
656
|
+
log(`Credentials saved to ${store.path()}`);
|
|
657
|
+
}
|
|
658
|
+
async function discoverAfterLogin(credentials, webappUrl, discover = discoverConfig, log = () => void 0) {
|
|
659
|
+
try {
|
|
660
|
+
const discovered = await discover({
|
|
661
|
+
webappUrl,
|
|
662
|
+
getAccessToken: () => Promise.resolve(credentials.accessToken),
|
|
663
|
+
errorHint: "The endpoints will be discovered on first use."
|
|
664
|
+
});
|
|
665
|
+
return applyDiscoveredUrls(credentials, webappUrl, discovered);
|
|
666
|
+
} catch (err) {
|
|
667
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
668
|
+
log(`Warning: ${message}`);
|
|
669
|
+
return credentials;
|
|
670
|
+
}
|
|
569
671
|
}
|
|
570
672
|
|
|
571
673
|
// src/cli/logout.ts
|
|
@@ -660,6 +762,7 @@ var TokenManager = class {
|
|
|
660
762
|
userAuthUrl: this.envFallback.userAuthUrl,
|
|
661
763
|
platformUrl: creds.platformUrl,
|
|
662
764
|
webappUrl: creds.webappUrl,
|
|
765
|
+
aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl,
|
|
663
766
|
user: creds.user
|
|
664
767
|
};
|
|
665
768
|
try {
|
|
@@ -1587,38 +1690,37 @@ var silentLogger = {
|
|
|
1587
1690
|
};
|
|
1588
1691
|
var PlatformClient = class {
|
|
1589
1692
|
tokenManager;
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
file;
|
|
1593
|
-
attachment;
|
|
1594
|
-
baseUrl;
|
|
1693
|
+
resolveBaseUrl;
|
|
1694
|
+
clientsPromise = null;
|
|
1595
1695
|
constructor(deps) {
|
|
1596
1696
|
this.tokenManager = deps.tokenManager;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1697
|
+
if (deps.baseUrl !== void 0) {
|
|
1698
|
+
const fixed = deps.baseUrl.replace(/\/$/, "");
|
|
1699
|
+
this.resolveBaseUrl = () => Promise.resolve(fixed);
|
|
1700
|
+
} else if (deps.resolveBaseUrl) {
|
|
1701
|
+
this.resolveBaseUrl = deps.resolveBaseUrl;
|
|
1702
|
+
} else {
|
|
1703
|
+
throw new Error("PlatformClient requires either baseUrl or resolveBaseUrl");
|
|
1704
|
+
}
|
|
1603
1705
|
}
|
|
1604
1706
|
async listProjects(opts = {}) {
|
|
1605
|
-
return this.withTokenRetry((jwt) =>
|
|
1707
|
+
return this.withTokenRetry((jwt, c) => c.project.listProjects(jwt, opts));
|
|
1606
1708
|
}
|
|
1607
1709
|
async getProject(projectId) {
|
|
1608
|
-
return this.withTokenRetry((jwt) =>
|
|
1710
|
+
return this.withTokenRetry((jwt, c) => c.project.getProject(projectId, jwt));
|
|
1609
1711
|
}
|
|
1610
1712
|
async listProjectSessions(projectId, opts = {}) {
|
|
1611
1713
|
const merged = {
|
|
1612
1714
|
archiveFilter: opts.archiveFilter ?? "active",
|
|
1613
1715
|
...opts
|
|
1614
1716
|
};
|
|
1615
|
-
return this.withTokenRetry((jwt) =>
|
|
1717
|
+
return this.withTokenRetry((jwt, c) => c.session.listSpecSessions(projectId, jwt, merged));
|
|
1616
1718
|
}
|
|
1617
1719
|
async listFiles(projectId, opts = {}) {
|
|
1618
|
-
return this.withTokenRetry((jwt) =>
|
|
1720
|
+
return this.withTokenRetry((jwt, c) => c.file.listFiles(projectId, jwt, opts));
|
|
1619
1721
|
}
|
|
1620
1722
|
async getFileMetadata(fileId) {
|
|
1621
|
-
return this.withTokenRetry((jwt) =>
|
|
1723
|
+
return this.withTokenRetry((jwt, c) => c.file.getFile(fileId, jwt));
|
|
1622
1724
|
}
|
|
1623
1725
|
async getAccessTokenWithExpiry() {
|
|
1624
1726
|
const accessToken = await this.tokenManager.getValidAccessToken();
|
|
@@ -1626,29 +1728,29 @@ var PlatformClient = class {
|
|
|
1626
1728
|
return { accessToken, expiresAt: creds.expiresAt };
|
|
1627
1729
|
}
|
|
1628
1730
|
async getFileDownloadUrl(fileId) {
|
|
1629
|
-
return this.withTokenRetry((jwt) =>
|
|
1731
|
+
return this.withTokenRetry((jwt, c) => c.file.getDownloadUrl(fileId, jwt));
|
|
1630
1732
|
}
|
|
1631
1733
|
async getRevisionDownloadUrl(fileId, revisionNumber) {
|
|
1632
1734
|
return this.withTokenRetry(
|
|
1633
|
-
(jwt) =>
|
|
1735
|
+
(jwt, c) => c.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
|
|
1634
1736
|
);
|
|
1635
1737
|
}
|
|
1636
1738
|
async getAttachmentDownloadUrl(attachmentId) {
|
|
1637
|
-
return this.withTokenRetry((jwt) =>
|
|
1739
|
+
return this.withTokenRetry((jwt, c) => c.attachment.getDownloadUrl(attachmentId, jwt));
|
|
1638
1740
|
}
|
|
1639
1741
|
async downloadFile(fileId, revision) {
|
|
1640
1742
|
if (revision !== void 0) {
|
|
1641
1743
|
const bytes2 = await this.withTokenRetry(
|
|
1642
|
-
(jwt) =>
|
|
1744
|
+
(jwt, c) => c.file.downloadRevision(fileId, revision, jwt)
|
|
1643
1745
|
);
|
|
1644
1746
|
return { bytes: bytes2, revisionNumber: revision };
|
|
1645
1747
|
}
|
|
1646
|
-
const bytes = await this.withTokenRetry((jwt) =>
|
|
1748
|
+
const bytes = await this.withTokenRetry((jwt, c) => c.file.downloadContent(fileId, jwt));
|
|
1647
1749
|
const meta = await this.getFileMetadata(fileId);
|
|
1648
1750
|
return { bytes, revisionNumber: meta.revisionCount };
|
|
1649
1751
|
}
|
|
1650
1752
|
async listAttachments(projectId) {
|
|
1651
|
-
return this.withTokenRetry((jwt) =>
|
|
1753
|
+
return this.withTokenRetry((jwt, c) => c.attachment.listByProject(projectId, jwt));
|
|
1652
1754
|
}
|
|
1653
1755
|
/**
|
|
1654
1756
|
* Fetch a single attachment's metadata by id. Returns null on a 404 so callers
|
|
@@ -1659,7 +1761,7 @@ var PlatformClient = class {
|
|
|
1659
1761
|
*/
|
|
1660
1762
|
async getAttachmentMetadata(attachmentId) {
|
|
1661
1763
|
try {
|
|
1662
|
-
return await this.withTokenRetry((jwt) =>
|
|
1764
|
+
return await this.withTokenRetry((jwt, c) => c.attachment.getById(attachmentId, jwt));
|
|
1663
1765
|
} catch (err) {
|
|
1664
1766
|
if (statusOf(err) === 404) {
|
|
1665
1767
|
return null;
|
|
@@ -1668,7 +1770,7 @@ var PlatformClient = class {
|
|
|
1668
1770
|
}
|
|
1669
1771
|
}
|
|
1670
1772
|
async downloadAttachment(attachmentId) {
|
|
1671
|
-
return this.withTokenRetry((jwt) =>
|
|
1773
|
+
return this.withTokenRetry((jwt, c) => c.attachment.downloadContent(attachmentId, jwt));
|
|
1672
1774
|
}
|
|
1673
1775
|
/**
|
|
1674
1776
|
* Soft-deletes an attachment by id. The server hides it from subsequent
|
|
@@ -1677,7 +1779,7 @@ var PlatformClient = class {
|
|
|
1677
1779
|
* is set and an attachment with the same filename already exists.
|
|
1678
1780
|
*/
|
|
1679
1781
|
async deleteAttachment(attachmentId) {
|
|
1680
|
-
await this.withTokenRetry((jwt) =>
|
|
1782
|
+
await this.withTokenRetry((jwt, c) => c.attachment.deleteById(attachmentId, jwt));
|
|
1681
1783
|
}
|
|
1682
1784
|
/**
|
|
1683
1785
|
* Two-phase attachment upload. Reserves an attachment id (Phase 1), then
|
|
@@ -1687,7 +1789,7 @@ var PlatformClient = class {
|
|
|
1687
1789
|
*/
|
|
1688
1790
|
async uploadAttachment(args) {
|
|
1689
1791
|
const initiated = await this.withTokenRetry(
|
|
1690
|
-
(jwt) =>
|
|
1792
|
+
(jwt, c) => c.attachment.initiateUpload(
|
|
1691
1793
|
{
|
|
1692
1794
|
projectId: args.projectId,
|
|
1693
1795
|
fileName: args.fileName,
|
|
@@ -1699,21 +1801,44 @@ var PlatformClient = class {
|
|
|
1699
1801
|
)
|
|
1700
1802
|
);
|
|
1701
1803
|
const uploaded = await this.withTokenRetry(
|
|
1702
|
-
(jwt) =>
|
|
1804
|
+
(jwt, c) => c.attachment.uploadContent(initiated.attachmentId, args.content, jwt)
|
|
1703
1805
|
);
|
|
1704
1806
|
return {
|
|
1705
1807
|
attachmentId: uploaded.attachmentId,
|
|
1706
1808
|
fileUri: uploaded.fileUri
|
|
1707
1809
|
};
|
|
1708
1810
|
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Builds the HTTP clients on first use, resolving the base URL lazily. A
|
|
1813
|
+
* successful build is cached for the process lifetime; a failed resolution
|
|
1814
|
+
* (e.g. discovery before login) is dropped so the next call retries.
|
|
1815
|
+
*/
|
|
1816
|
+
clients() {
|
|
1817
|
+
this.clientsPromise ??= this.buildClients().catch((err) => {
|
|
1818
|
+
this.clientsPromise = null;
|
|
1819
|
+
throw err;
|
|
1820
|
+
});
|
|
1821
|
+
return this.clientsPromise;
|
|
1822
|
+
}
|
|
1823
|
+
async buildClients() {
|
|
1824
|
+
const baseUrl = (await this.resolveBaseUrl()).replace(/\/$/, "");
|
|
1825
|
+
const httpClient = new BaseHttpClient({ baseUrl, logger: silentLogger });
|
|
1826
|
+
return {
|
|
1827
|
+
project: new HttpProjectClient(httpClient),
|
|
1828
|
+
session: new HttpSpecSessionClient(httpClient),
|
|
1829
|
+
file: new HttpFileClient(httpClient),
|
|
1830
|
+
attachment: new HttpAttachmentClient(httpClient)
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1709
1833
|
async withTokenRetry(call) {
|
|
1834
|
+
const clients = await this.clients();
|
|
1710
1835
|
const jwt = await this.tokenManager.getValidAccessToken();
|
|
1711
1836
|
try {
|
|
1712
|
-
return await call(jwt);
|
|
1837
|
+
return await call(jwt, clients);
|
|
1713
1838
|
} catch (err) {
|
|
1714
1839
|
if (statusOf(err) === 401) {
|
|
1715
1840
|
const refreshed = await this.tokenManager.forceRefresh();
|
|
1716
|
-
return await call(refreshed);
|
|
1841
|
+
return await call(refreshed, clients);
|
|
1717
1842
|
}
|
|
1718
1843
|
throw err;
|
|
1719
1844
|
}
|
|
@@ -3468,64 +3593,6 @@ function sleep2(ms) {
|
|
|
3468
3593
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3469
3594
|
}
|
|
3470
3595
|
|
|
3471
|
-
// src/reverse/discover.ts
|
|
3472
|
-
async function discoverAgentUrl(opts) {
|
|
3473
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
3474
|
-
const endpoint = `${opts.webappUrl}/api/v1/config`;
|
|
3475
|
-
let response;
|
|
3476
|
-
try {
|
|
3477
|
-
const token = await opts.getAccessToken();
|
|
3478
|
-
response = await fetchConfig(endpoint, token, fetchImpl);
|
|
3479
|
-
if (response.status === 401 && opts.forceRefresh) {
|
|
3480
|
-
const refreshed = await opts.forceRefresh();
|
|
3481
|
-
response = await fetchConfig(endpoint, refreshed, fetchImpl);
|
|
3482
|
-
}
|
|
3483
|
-
} catch (err) {
|
|
3484
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3485
|
-
throw discoveryError(endpoint, message);
|
|
3486
|
-
}
|
|
3487
|
-
if (!response.ok) {
|
|
3488
|
-
throw discoveryError(endpoint, `HTTP ${String(response.status)}`);
|
|
3489
|
-
}
|
|
3490
|
-
let body;
|
|
3491
|
-
try {
|
|
3492
|
-
body = await response.json();
|
|
3493
|
-
} catch {
|
|
3494
|
-
throw discoveryError(endpoint, "response is not valid JSON");
|
|
3495
|
-
}
|
|
3496
|
-
const agentUrl = body.aiAgentMcpReverseUrl;
|
|
3497
|
-
if (typeof agentUrl !== "string" || agentUrl.length === 0) {
|
|
3498
|
-
throw discoveryError(endpoint, "response is missing aiAgentMcpReverseUrl");
|
|
3499
|
-
}
|
|
3500
|
-
assertWebSocketUrl(agentUrl, endpoint);
|
|
3501
|
-
return agentUrl;
|
|
3502
|
-
}
|
|
3503
|
-
function fetchConfig(endpoint, token, fetchImpl) {
|
|
3504
|
-
return fetchImpl(endpoint, {
|
|
3505
|
-
method: "GET",
|
|
3506
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
3507
|
-
});
|
|
3508
|
-
}
|
|
3509
|
-
function assertWebSocketUrl(value, endpoint) {
|
|
3510
|
-
let parsed;
|
|
3511
|
-
try {
|
|
3512
|
-
parsed = new URL(value);
|
|
3513
|
-
} catch {
|
|
3514
|
-
throw discoveryError(endpoint, `returned an invalid URL: ${value}`);
|
|
3515
|
-
}
|
|
3516
|
-
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
3517
|
-
throw discoveryError(
|
|
3518
|
-
endpoint,
|
|
3519
|
-
`returned a non-WebSocket URL (${value}); expected ws:// or wss://`
|
|
3520
|
-
);
|
|
3521
|
-
}
|
|
3522
|
-
}
|
|
3523
|
-
function discoveryError(endpoint, reason) {
|
|
3524
|
-
return new ConfigError(
|
|
3525
|
-
`Could not discover the ai-agent URL from ${endpoint} (${reason}). Pass --agent-url <url>, set MYSPEC_AI_AGENT_WS_URL, or run \`npx @myspec/mcp-server login\` and retry.`
|
|
3526
|
-
);
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
3596
|
// src/index.ts
|
|
3530
3597
|
function parseArgs(argv) {
|
|
3531
3598
|
const [first, ...rest] = argv;
|
|
@@ -3602,16 +3669,15 @@ function printHelp() {
|
|
|
3602
3669
|
" --version Print version",
|
|
3603
3670
|
" --help Print this help",
|
|
3604
3671
|
"",
|
|
3605
|
-
"
|
|
3606
|
-
" --user-auth-url <url> user-auth base URL (overrides env /
|
|
3607
|
-
"
|
|
3608
|
-
"
|
|
3609
|
-
"
|
|
3672
|
+
"Flags:",
|
|
3673
|
+
" --user-auth-url <url> user-auth base URL (overrides env / saved settings;",
|
|
3674
|
+
" default https://auth.myspec.dev). The webapp URL is",
|
|
3675
|
+
" derived from it, and the remaining endpoints",
|
|
3676
|
+
" (platform API, ai-agent WebSocket) are discovered",
|
|
3677
|
+
" via the webapp and saved to ~/.myspec/settings.json.",
|
|
3610
3678
|
"",
|
|
3611
3679
|
"Environment:",
|
|
3612
3680
|
" MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
|
|
3613
|
-
" MYSPEC_PLATFORM_URL platform API base URL (default https://platform.myspec.dev)",
|
|
3614
|
-
" MYSPEC_WEBAPP_URL webapp base URL (default derived from user-auth host)",
|
|
3615
3681
|
" MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
|
|
3616
3682
|
" MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
|
|
3617
3683
|
" fresh access token on first use via this refresh",
|
|
@@ -3646,9 +3712,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
3646
3712
|
case "login":
|
|
3647
3713
|
await runLogin({
|
|
3648
3714
|
paste: flagBool(flags, "paste"),
|
|
3649
|
-
userAuthUrl: flagString(flags, "user-auth-url")
|
|
3650
|
-
platformUrl: flagString(flags, "platform-url"),
|
|
3651
|
-
webappUrl: flagString(flags, "webapp-url")
|
|
3715
|
+
userAuthUrl: flagString(flags, "user-auth-url")
|
|
3652
3716
|
});
|
|
3653
3717
|
return;
|
|
3654
3718
|
case "logout":
|
|
@@ -3663,21 +3727,43 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
3663
3727
|
return;
|
|
3664
3728
|
}
|
|
3665
3729
|
}
|
|
3730
|
+
var REVERSE_DISCOVERY_HINT = "Pass --agent-url <url>, set MYSPEC_AI_AGENT_WS_URL, or run `npx @myspec/mcp-server login` and retry.";
|
|
3666
3731
|
async function resolveReverseAgentUrl(sources) {
|
|
3667
3732
|
const env = sources.env ?? process.env;
|
|
3733
|
+
const warn = sources.warn ?? ((message) => {
|
|
3734
|
+
process.stderr.write(message + "\n");
|
|
3735
|
+
});
|
|
3668
3736
|
const explicitAgentUrl = sources.flagAgentUrl ?? env.MYSPEC_AI_AGENT_WS_URL;
|
|
3669
3737
|
if (explicitAgentUrl) {
|
|
3670
3738
|
return { agentUrl: explicitAgentUrl };
|
|
3671
3739
|
}
|
|
3672
3740
|
const stored = await sources.loadStored().catch(() => null);
|
|
3673
|
-
const config = resolveConfig({
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3741
|
+
const config = resolveConfig({ env, storedUserAuthUrl: stored?.userAuthUrl });
|
|
3742
|
+
const endpoint = configEndpoint(config.webappUrl);
|
|
3743
|
+
try {
|
|
3744
|
+
const discovered = await sources.discover(config.webappUrl);
|
|
3745
|
+
if (!discovered.aiAgentMcpReverseUrl) {
|
|
3746
|
+
throw new ConfigError(
|
|
3747
|
+
`Could not discover endpoints from ${endpoint} (response is missing aiAgentMcpReverseUrl). ${REVERSE_DISCOVERY_HINT}`
|
|
3748
|
+
);
|
|
3749
|
+
}
|
|
3750
|
+
if (stored && sources.persist) {
|
|
3751
|
+
await sources.persist(applyDiscoveredUrls(stored, config.webappUrl, discovered)).catch((err) => {
|
|
3752
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3753
|
+
warn(`myspec-mcp reverse: could not save discovered URLs (${message})`);
|
|
3754
|
+
});
|
|
3755
|
+
}
|
|
3756
|
+
return { agentUrl: discovered.aiAgentMcpReverseUrl, discoveredVia: endpoint };
|
|
3757
|
+
} catch (err) {
|
|
3758
|
+
if (stored?.aiAgentMcpReverseUrl) {
|
|
3759
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3760
|
+
warn(
|
|
3761
|
+
`myspec-mcp reverse: discovery failed (${message}); using the agent URL saved in settings.json`
|
|
3762
|
+
);
|
|
3763
|
+
return { agentUrl: stored.aiAgentMcpReverseUrl };
|
|
3764
|
+
}
|
|
3765
|
+
throw err;
|
|
3766
|
+
}
|
|
3681
3767
|
}
|
|
3682
3768
|
async function runReverseCommand(flags) {
|
|
3683
3769
|
const root = flagString(flags, "root") ?? process.cwd();
|
|
@@ -3686,12 +3772,14 @@ async function runReverseCommand(flags) {
|
|
|
3686
3772
|
let onAuthFailed;
|
|
3687
3773
|
let forceRefresh;
|
|
3688
3774
|
let loadStored;
|
|
3775
|
+
let persist;
|
|
3689
3776
|
if (inlineToken) {
|
|
3690
3777
|
const staticToken = inlineToken;
|
|
3691
3778
|
getAccessToken = () => Promise.resolve(staticToken);
|
|
3692
3779
|
onAuthFailed = void 0;
|
|
3693
3780
|
forceRefresh = void 0;
|
|
3694
3781
|
loadStored = () => Promise.resolve(null);
|
|
3782
|
+
persist = void 0;
|
|
3695
3783
|
} else {
|
|
3696
3784
|
const envConfig = readEnvRefreshConfig();
|
|
3697
3785
|
const store = createCompositeCredentialsStore({
|
|
@@ -3705,13 +3793,19 @@ async function runReverseCommand(flags) {
|
|
|
3705
3793
|
};
|
|
3706
3794
|
forceRefresh = () => tokenManager.forceRefresh();
|
|
3707
3795
|
loadStored = () => store.load();
|
|
3796
|
+
persist = (creds) => store.save(creds);
|
|
3708
3797
|
}
|
|
3709
3798
|
const resolved = await resolveReverseAgentUrl({
|
|
3710
3799
|
flagAgentUrl: flagString(flags, "agent-url"),
|
|
3711
|
-
cliWebappUrl: flagString(flags, "webapp-url"),
|
|
3712
3800
|
loadStored,
|
|
3801
|
+
persist,
|
|
3713
3802
|
discover: (webappUrl) => {
|
|
3714
|
-
return
|
|
3803
|
+
return discoverConfig({
|
|
3804
|
+
webappUrl,
|
|
3805
|
+
getAccessToken,
|
|
3806
|
+
forceRefresh,
|
|
3807
|
+
errorHint: REVERSE_DISCOVERY_HINT
|
|
3808
|
+
});
|
|
3715
3809
|
}
|
|
3716
3810
|
});
|
|
3717
3811
|
if (resolved.discoveredVia) {
|
|
@@ -3728,6 +3822,36 @@ async function runReverseCommand(flags) {
|
|
|
3728
3822
|
version: readVersion()
|
|
3729
3823
|
});
|
|
3730
3824
|
}
|
|
3825
|
+
function createPlatformUrlResolver(deps) {
|
|
3826
|
+
const discover = deps.discover ?? discoverConfig;
|
|
3827
|
+
const warn = deps.warn ?? ((message) => {
|
|
3828
|
+
process.stderr.write(message + "\n");
|
|
3829
|
+
});
|
|
3830
|
+
return async () => {
|
|
3831
|
+
const stored = await deps.store.load().catch(() => null);
|
|
3832
|
+
if (stored?.platformUrl && stored.userAuthUrl === deps.userAuthUrl) {
|
|
3833
|
+
return stored.platformUrl;
|
|
3834
|
+
}
|
|
3835
|
+
const discovered = await discover({
|
|
3836
|
+
webappUrl: deps.webappUrl,
|
|
3837
|
+
getAccessToken: () => deps.tokenManager.getValidAccessToken(),
|
|
3838
|
+
forceRefresh: () => deps.tokenManager.forceRefresh()
|
|
3839
|
+
});
|
|
3840
|
+
if (!discovered.platformUrl) {
|
|
3841
|
+
throw new ConfigError(
|
|
3842
|
+
`Could not discover endpoints from ${configEndpoint(deps.webappUrl)} (response is missing platformUrl). Run \`npx @myspec/mcp-server login\` and retry.`
|
|
3843
|
+
);
|
|
3844
|
+
}
|
|
3845
|
+
const creds = await deps.tokenManager.getCredentials().catch(() => null);
|
|
3846
|
+
if (creds) {
|
|
3847
|
+
await deps.store.save(applyDiscoveredUrls(creds, deps.webappUrl, discovered)).catch((err) => {
|
|
3848
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3849
|
+
warn(`myspec-mcp: could not save discovered URLs (${message})`);
|
|
3850
|
+
});
|
|
3851
|
+
}
|
|
3852
|
+
return discovered.platformUrl;
|
|
3853
|
+
};
|
|
3854
|
+
}
|
|
3731
3855
|
async function runServe(flags) {
|
|
3732
3856
|
const envConfig = readEnvRefreshConfig();
|
|
3733
3857
|
const store = createCompositeCredentialsStore({
|
|
@@ -3742,16 +3866,21 @@ async function runServe(flags) {
|
|
|
3742
3866
|
}
|
|
3743
3867
|
const config = resolveConfig({
|
|
3744
3868
|
cliUserAuthUrl: flagString(flags, "user-auth-url"),
|
|
3745
|
-
|
|
3746
|
-
storedUserAuthUrl: initial?.userAuthUrl,
|
|
3747
|
-
storedPlatformUrl: initial?.platformUrl,
|
|
3748
|
-
storedWebappUrl: initial?.webappUrl
|
|
3869
|
+
storedUserAuthUrl: initial?.userAuthUrl
|
|
3749
3870
|
});
|
|
3750
3871
|
const tokenManager = new TokenManager({
|
|
3751
3872
|
store,
|
|
3752
3873
|
envFallback: envConfig
|
|
3753
3874
|
});
|
|
3754
|
-
const client = new PlatformClient({
|
|
3875
|
+
const client = new PlatformClient({
|
|
3876
|
+
resolveBaseUrl: createPlatformUrlResolver({
|
|
3877
|
+
store,
|
|
3878
|
+
tokenManager,
|
|
3879
|
+
userAuthUrl: config.userAuthUrl,
|
|
3880
|
+
webappUrl: config.webappUrl
|
|
3881
|
+
}),
|
|
3882
|
+
tokenManager
|
|
3883
|
+
});
|
|
3755
3884
|
await startStdioServer({ client, version: readVersion() });
|
|
3756
3885
|
}
|
|
3757
3886
|
function isCliEntry() {
|
|
@@ -3782,6 +3911,8 @@ if (isCliEntry()) {
|
|
|
3782
3911
|
});
|
|
3783
3912
|
}
|
|
3784
3913
|
export {
|
|
3914
|
+
REVERSE_DISCOVERY_HINT,
|
|
3915
|
+
createPlatformUrlResolver,
|
|
3785
3916
|
main,
|
|
3786
3917
|
resolveReverseAgentUrl,
|
|
3787
3918
|
runServe
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myspec/mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1-next.16",
|
|
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": {
|