@myspec/mcp-server 0.1.1-next.15 → 0.1.1-next.17
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 +274 -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 {
|
|
@@ -1540,6 +1643,10 @@ var HttpSpecSessionClient = class {
|
|
|
1540
1643
|
);
|
|
1541
1644
|
return transformSpecSessionResponse(raw);
|
|
1542
1645
|
}
|
|
1646
|
+
async appendSessionMessage(sessionId, message, jwtToken) {
|
|
1647
|
+
const response = await this.httpClient.post(`/project/v1/sessions/${sessionId}/messages`, { message }, jwtToken, {});
|
|
1648
|
+
return { messageCount: response.message_count };
|
|
1649
|
+
}
|
|
1543
1650
|
async archiveSpecSession(sessionId, jwtToken) {
|
|
1544
1651
|
const raw = await this.httpClient.post(
|
|
1545
1652
|
`/project/v1/sessions/${sessionId}/archive`,
|
|
@@ -1587,38 +1694,37 @@ var silentLogger = {
|
|
|
1587
1694
|
};
|
|
1588
1695
|
var PlatformClient = class {
|
|
1589
1696
|
tokenManager;
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
file;
|
|
1593
|
-
attachment;
|
|
1594
|
-
baseUrl;
|
|
1697
|
+
resolveBaseUrl;
|
|
1698
|
+
clientsPromise = null;
|
|
1595
1699
|
constructor(deps) {
|
|
1596
1700
|
this.tokenManager = deps.tokenManager;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1701
|
+
if (deps.baseUrl !== void 0) {
|
|
1702
|
+
const fixed = deps.baseUrl.replace(/\/$/, "");
|
|
1703
|
+
this.resolveBaseUrl = () => Promise.resolve(fixed);
|
|
1704
|
+
} else if (deps.resolveBaseUrl) {
|
|
1705
|
+
this.resolveBaseUrl = deps.resolveBaseUrl;
|
|
1706
|
+
} else {
|
|
1707
|
+
throw new Error("PlatformClient requires either baseUrl or resolveBaseUrl");
|
|
1708
|
+
}
|
|
1603
1709
|
}
|
|
1604
1710
|
async listProjects(opts = {}) {
|
|
1605
|
-
return this.withTokenRetry((jwt) =>
|
|
1711
|
+
return this.withTokenRetry((jwt, c) => c.project.listProjects(jwt, opts));
|
|
1606
1712
|
}
|
|
1607
1713
|
async getProject(projectId) {
|
|
1608
|
-
return this.withTokenRetry((jwt) =>
|
|
1714
|
+
return this.withTokenRetry((jwt, c) => c.project.getProject(projectId, jwt));
|
|
1609
1715
|
}
|
|
1610
1716
|
async listProjectSessions(projectId, opts = {}) {
|
|
1611
1717
|
const merged = {
|
|
1612
1718
|
archiveFilter: opts.archiveFilter ?? "active",
|
|
1613
1719
|
...opts
|
|
1614
1720
|
};
|
|
1615
|
-
return this.withTokenRetry((jwt) =>
|
|
1721
|
+
return this.withTokenRetry((jwt, c) => c.session.listSpecSessions(projectId, jwt, merged));
|
|
1616
1722
|
}
|
|
1617
1723
|
async listFiles(projectId, opts = {}) {
|
|
1618
|
-
return this.withTokenRetry((jwt) =>
|
|
1724
|
+
return this.withTokenRetry((jwt, c) => c.file.listFiles(projectId, jwt, opts));
|
|
1619
1725
|
}
|
|
1620
1726
|
async getFileMetadata(fileId) {
|
|
1621
|
-
return this.withTokenRetry((jwt) =>
|
|
1727
|
+
return this.withTokenRetry((jwt, c) => c.file.getFile(fileId, jwt));
|
|
1622
1728
|
}
|
|
1623
1729
|
async getAccessTokenWithExpiry() {
|
|
1624
1730
|
const accessToken = await this.tokenManager.getValidAccessToken();
|
|
@@ -1626,29 +1732,29 @@ var PlatformClient = class {
|
|
|
1626
1732
|
return { accessToken, expiresAt: creds.expiresAt };
|
|
1627
1733
|
}
|
|
1628
1734
|
async getFileDownloadUrl(fileId) {
|
|
1629
|
-
return this.withTokenRetry((jwt) =>
|
|
1735
|
+
return this.withTokenRetry((jwt, c) => c.file.getDownloadUrl(fileId, jwt));
|
|
1630
1736
|
}
|
|
1631
1737
|
async getRevisionDownloadUrl(fileId, revisionNumber) {
|
|
1632
1738
|
return this.withTokenRetry(
|
|
1633
|
-
(jwt) =>
|
|
1739
|
+
(jwt, c) => c.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
|
|
1634
1740
|
);
|
|
1635
1741
|
}
|
|
1636
1742
|
async getAttachmentDownloadUrl(attachmentId) {
|
|
1637
|
-
return this.withTokenRetry((jwt) =>
|
|
1743
|
+
return this.withTokenRetry((jwt, c) => c.attachment.getDownloadUrl(attachmentId, jwt));
|
|
1638
1744
|
}
|
|
1639
1745
|
async downloadFile(fileId, revision) {
|
|
1640
1746
|
if (revision !== void 0) {
|
|
1641
1747
|
const bytes2 = await this.withTokenRetry(
|
|
1642
|
-
(jwt) =>
|
|
1748
|
+
(jwt, c) => c.file.downloadRevision(fileId, revision, jwt)
|
|
1643
1749
|
);
|
|
1644
1750
|
return { bytes: bytes2, revisionNumber: revision };
|
|
1645
1751
|
}
|
|
1646
|
-
const bytes = await this.withTokenRetry((jwt) =>
|
|
1752
|
+
const bytes = await this.withTokenRetry((jwt, c) => c.file.downloadContent(fileId, jwt));
|
|
1647
1753
|
const meta = await this.getFileMetadata(fileId);
|
|
1648
1754
|
return { bytes, revisionNumber: meta.revisionCount };
|
|
1649
1755
|
}
|
|
1650
1756
|
async listAttachments(projectId) {
|
|
1651
|
-
return this.withTokenRetry((jwt) =>
|
|
1757
|
+
return this.withTokenRetry((jwt, c) => c.attachment.listByProject(projectId, jwt));
|
|
1652
1758
|
}
|
|
1653
1759
|
/**
|
|
1654
1760
|
* Fetch a single attachment's metadata by id. Returns null on a 404 so callers
|
|
@@ -1659,7 +1765,7 @@ var PlatformClient = class {
|
|
|
1659
1765
|
*/
|
|
1660
1766
|
async getAttachmentMetadata(attachmentId) {
|
|
1661
1767
|
try {
|
|
1662
|
-
return await this.withTokenRetry((jwt) =>
|
|
1768
|
+
return await this.withTokenRetry((jwt, c) => c.attachment.getById(attachmentId, jwt));
|
|
1663
1769
|
} catch (err) {
|
|
1664
1770
|
if (statusOf(err) === 404) {
|
|
1665
1771
|
return null;
|
|
@@ -1668,7 +1774,7 @@ var PlatformClient = class {
|
|
|
1668
1774
|
}
|
|
1669
1775
|
}
|
|
1670
1776
|
async downloadAttachment(attachmentId) {
|
|
1671
|
-
return this.withTokenRetry((jwt) =>
|
|
1777
|
+
return this.withTokenRetry((jwt, c) => c.attachment.downloadContent(attachmentId, jwt));
|
|
1672
1778
|
}
|
|
1673
1779
|
/**
|
|
1674
1780
|
* Soft-deletes an attachment by id. The server hides it from subsequent
|
|
@@ -1677,7 +1783,7 @@ var PlatformClient = class {
|
|
|
1677
1783
|
* is set and an attachment with the same filename already exists.
|
|
1678
1784
|
*/
|
|
1679
1785
|
async deleteAttachment(attachmentId) {
|
|
1680
|
-
await this.withTokenRetry((jwt) =>
|
|
1786
|
+
await this.withTokenRetry((jwt, c) => c.attachment.deleteById(attachmentId, jwt));
|
|
1681
1787
|
}
|
|
1682
1788
|
/**
|
|
1683
1789
|
* Two-phase attachment upload. Reserves an attachment id (Phase 1), then
|
|
@@ -1687,7 +1793,7 @@ var PlatformClient = class {
|
|
|
1687
1793
|
*/
|
|
1688
1794
|
async uploadAttachment(args) {
|
|
1689
1795
|
const initiated = await this.withTokenRetry(
|
|
1690
|
-
(jwt) =>
|
|
1796
|
+
(jwt, c) => c.attachment.initiateUpload(
|
|
1691
1797
|
{
|
|
1692
1798
|
projectId: args.projectId,
|
|
1693
1799
|
fileName: args.fileName,
|
|
@@ -1699,21 +1805,44 @@ var PlatformClient = class {
|
|
|
1699
1805
|
)
|
|
1700
1806
|
);
|
|
1701
1807
|
const uploaded = await this.withTokenRetry(
|
|
1702
|
-
(jwt) =>
|
|
1808
|
+
(jwt, c) => c.attachment.uploadContent(initiated.attachmentId, args.content, jwt)
|
|
1703
1809
|
);
|
|
1704
1810
|
return {
|
|
1705
1811
|
attachmentId: uploaded.attachmentId,
|
|
1706
1812
|
fileUri: uploaded.fileUri
|
|
1707
1813
|
};
|
|
1708
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Builds the HTTP clients on first use, resolving the base URL lazily. A
|
|
1817
|
+
* successful build is cached for the process lifetime; a failed resolution
|
|
1818
|
+
* (e.g. discovery before login) is dropped so the next call retries.
|
|
1819
|
+
*/
|
|
1820
|
+
clients() {
|
|
1821
|
+
this.clientsPromise ??= this.buildClients().catch((err) => {
|
|
1822
|
+
this.clientsPromise = null;
|
|
1823
|
+
throw err;
|
|
1824
|
+
});
|
|
1825
|
+
return this.clientsPromise;
|
|
1826
|
+
}
|
|
1827
|
+
async buildClients() {
|
|
1828
|
+
const baseUrl = (await this.resolveBaseUrl()).replace(/\/$/, "");
|
|
1829
|
+
const httpClient = new BaseHttpClient({ baseUrl, logger: silentLogger });
|
|
1830
|
+
return {
|
|
1831
|
+
project: new HttpProjectClient(httpClient),
|
|
1832
|
+
session: new HttpSpecSessionClient(httpClient),
|
|
1833
|
+
file: new HttpFileClient(httpClient),
|
|
1834
|
+
attachment: new HttpAttachmentClient(httpClient)
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1709
1837
|
async withTokenRetry(call) {
|
|
1838
|
+
const clients = await this.clients();
|
|
1710
1839
|
const jwt = await this.tokenManager.getValidAccessToken();
|
|
1711
1840
|
try {
|
|
1712
|
-
return await call(jwt);
|
|
1841
|
+
return await call(jwt, clients);
|
|
1713
1842
|
} catch (err) {
|
|
1714
1843
|
if (statusOf(err) === 401) {
|
|
1715
1844
|
const refreshed = await this.tokenManager.forceRefresh();
|
|
1716
|
-
return await call(refreshed);
|
|
1845
|
+
return await call(refreshed, clients);
|
|
1717
1846
|
}
|
|
1718
1847
|
throw err;
|
|
1719
1848
|
}
|
|
@@ -3468,64 +3597,6 @@ function sleep2(ms) {
|
|
|
3468
3597
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3469
3598
|
}
|
|
3470
3599
|
|
|
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
3600
|
// src/index.ts
|
|
3530
3601
|
function parseArgs(argv) {
|
|
3531
3602
|
const [first, ...rest] = argv;
|
|
@@ -3602,16 +3673,15 @@ function printHelp() {
|
|
|
3602
3673
|
" --version Print version",
|
|
3603
3674
|
" --help Print this help",
|
|
3604
3675
|
"",
|
|
3605
|
-
"
|
|
3606
|
-
" --user-auth-url <url> user-auth base URL (overrides env /
|
|
3607
|
-
"
|
|
3608
|
-
"
|
|
3609
|
-
"
|
|
3676
|
+
"Flags:",
|
|
3677
|
+
" --user-auth-url <url> user-auth base URL (overrides env / saved settings;",
|
|
3678
|
+
" default https://auth.myspec.dev). The webapp URL is",
|
|
3679
|
+
" derived from it, and the remaining endpoints",
|
|
3680
|
+
" (platform API, ai-agent WebSocket) are discovered",
|
|
3681
|
+
" via the webapp and saved to ~/.myspec/settings.json.",
|
|
3610
3682
|
"",
|
|
3611
3683
|
"Environment:",
|
|
3612
3684
|
" 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
3685
|
" MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
|
|
3616
3686
|
" MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
|
|
3617
3687
|
" fresh access token on first use via this refresh",
|
|
@@ -3646,9 +3716,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
3646
3716
|
case "login":
|
|
3647
3717
|
await runLogin({
|
|
3648
3718
|
paste: flagBool(flags, "paste"),
|
|
3649
|
-
userAuthUrl: flagString(flags, "user-auth-url")
|
|
3650
|
-
platformUrl: flagString(flags, "platform-url"),
|
|
3651
|
-
webappUrl: flagString(flags, "webapp-url")
|
|
3719
|
+
userAuthUrl: flagString(flags, "user-auth-url")
|
|
3652
3720
|
});
|
|
3653
3721
|
return;
|
|
3654
3722
|
case "logout":
|
|
@@ -3663,21 +3731,43 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
3663
3731
|
return;
|
|
3664
3732
|
}
|
|
3665
3733
|
}
|
|
3734
|
+
var REVERSE_DISCOVERY_HINT = "Pass --agent-url <url>, set MYSPEC_AI_AGENT_WS_URL, or run `npx @myspec/mcp-server login` and retry.";
|
|
3666
3735
|
async function resolveReverseAgentUrl(sources) {
|
|
3667
3736
|
const env = sources.env ?? process.env;
|
|
3737
|
+
const warn = sources.warn ?? ((message) => {
|
|
3738
|
+
process.stderr.write(message + "\n");
|
|
3739
|
+
});
|
|
3668
3740
|
const explicitAgentUrl = sources.flagAgentUrl ?? env.MYSPEC_AI_AGENT_WS_URL;
|
|
3669
3741
|
if (explicitAgentUrl) {
|
|
3670
3742
|
return { agentUrl: explicitAgentUrl };
|
|
3671
3743
|
}
|
|
3672
3744
|
const stored = await sources.loadStored().catch(() => null);
|
|
3673
|
-
const config = resolveConfig({
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3745
|
+
const config = resolveConfig({ env, storedUserAuthUrl: stored?.userAuthUrl });
|
|
3746
|
+
const endpoint = configEndpoint(config.webappUrl);
|
|
3747
|
+
try {
|
|
3748
|
+
const discovered = await sources.discover(config.webappUrl);
|
|
3749
|
+
if (!discovered.aiAgentMcpReverseUrl) {
|
|
3750
|
+
throw new ConfigError(
|
|
3751
|
+
`Could not discover endpoints from ${endpoint} (response is missing aiAgentMcpReverseUrl). ${REVERSE_DISCOVERY_HINT}`
|
|
3752
|
+
);
|
|
3753
|
+
}
|
|
3754
|
+
if (stored && sources.persist) {
|
|
3755
|
+
await sources.persist(applyDiscoveredUrls(stored, config.webappUrl, discovered)).catch((err) => {
|
|
3756
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3757
|
+
warn(`myspec-mcp reverse: could not save discovered URLs (${message})`);
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
return { agentUrl: discovered.aiAgentMcpReverseUrl, discoveredVia: endpoint };
|
|
3761
|
+
} catch (err) {
|
|
3762
|
+
if (stored?.aiAgentMcpReverseUrl) {
|
|
3763
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3764
|
+
warn(
|
|
3765
|
+
`myspec-mcp reverse: discovery failed (${message}); using the agent URL saved in settings.json`
|
|
3766
|
+
);
|
|
3767
|
+
return { agentUrl: stored.aiAgentMcpReverseUrl };
|
|
3768
|
+
}
|
|
3769
|
+
throw err;
|
|
3770
|
+
}
|
|
3681
3771
|
}
|
|
3682
3772
|
async function runReverseCommand(flags) {
|
|
3683
3773
|
const root = flagString(flags, "root") ?? process.cwd();
|
|
@@ -3686,12 +3776,14 @@ async function runReverseCommand(flags) {
|
|
|
3686
3776
|
let onAuthFailed;
|
|
3687
3777
|
let forceRefresh;
|
|
3688
3778
|
let loadStored;
|
|
3779
|
+
let persist;
|
|
3689
3780
|
if (inlineToken) {
|
|
3690
3781
|
const staticToken = inlineToken;
|
|
3691
3782
|
getAccessToken = () => Promise.resolve(staticToken);
|
|
3692
3783
|
onAuthFailed = void 0;
|
|
3693
3784
|
forceRefresh = void 0;
|
|
3694
3785
|
loadStored = () => Promise.resolve(null);
|
|
3786
|
+
persist = void 0;
|
|
3695
3787
|
} else {
|
|
3696
3788
|
const envConfig = readEnvRefreshConfig();
|
|
3697
3789
|
const store = createCompositeCredentialsStore({
|
|
@@ -3705,13 +3797,19 @@ async function runReverseCommand(flags) {
|
|
|
3705
3797
|
};
|
|
3706
3798
|
forceRefresh = () => tokenManager.forceRefresh();
|
|
3707
3799
|
loadStored = () => store.load();
|
|
3800
|
+
persist = (creds) => store.save(creds);
|
|
3708
3801
|
}
|
|
3709
3802
|
const resolved = await resolveReverseAgentUrl({
|
|
3710
3803
|
flagAgentUrl: flagString(flags, "agent-url"),
|
|
3711
|
-
cliWebappUrl: flagString(flags, "webapp-url"),
|
|
3712
3804
|
loadStored,
|
|
3805
|
+
persist,
|
|
3713
3806
|
discover: (webappUrl) => {
|
|
3714
|
-
return
|
|
3807
|
+
return discoverConfig({
|
|
3808
|
+
webappUrl,
|
|
3809
|
+
getAccessToken,
|
|
3810
|
+
forceRefresh,
|
|
3811
|
+
errorHint: REVERSE_DISCOVERY_HINT
|
|
3812
|
+
});
|
|
3715
3813
|
}
|
|
3716
3814
|
});
|
|
3717
3815
|
if (resolved.discoveredVia) {
|
|
@@ -3728,6 +3826,36 @@ async function runReverseCommand(flags) {
|
|
|
3728
3826
|
version: readVersion()
|
|
3729
3827
|
});
|
|
3730
3828
|
}
|
|
3829
|
+
function createPlatformUrlResolver(deps) {
|
|
3830
|
+
const discover = deps.discover ?? discoverConfig;
|
|
3831
|
+
const warn = deps.warn ?? ((message) => {
|
|
3832
|
+
process.stderr.write(message + "\n");
|
|
3833
|
+
});
|
|
3834
|
+
return async () => {
|
|
3835
|
+
const stored = await deps.store.load().catch(() => null);
|
|
3836
|
+
if (stored?.platformUrl && stored.userAuthUrl === deps.userAuthUrl) {
|
|
3837
|
+
return stored.platformUrl;
|
|
3838
|
+
}
|
|
3839
|
+
const discovered = await discover({
|
|
3840
|
+
webappUrl: deps.webappUrl,
|
|
3841
|
+
getAccessToken: () => deps.tokenManager.getValidAccessToken(),
|
|
3842
|
+
forceRefresh: () => deps.tokenManager.forceRefresh()
|
|
3843
|
+
});
|
|
3844
|
+
if (!discovered.platformUrl) {
|
|
3845
|
+
throw new ConfigError(
|
|
3846
|
+
`Could not discover endpoints from ${configEndpoint(deps.webappUrl)} (response is missing platformUrl). Run \`npx @myspec/mcp-server login\` and retry.`
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
const creds = await deps.tokenManager.getCredentials().catch(() => null);
|
|
3850
|
+
if (creds) {
|
|
3851
|
+
await deps.store.save(applyDiscoveredUrls(creds, deps.webappUrl, discovered)).catch((err) => {
|
|
3852
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3853
|
+
warn(`myspec-mcp: could not save discovered URLs (${message})`);
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
return discovered.platformUrl;
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3731
3859
|
async function runServe(flags) {
|
|
3732
3860
|
const envConfig = readEnvRefreshConfig();
|
|
3733
3861
|
const store = createCompositeCredentialsStore({
|
|
@@ -3742,16 +3870,21 @@ async function runServe(flags) {
|
|
|
3742
3870
|
}
|
|
3743
3871
|
const config = resolveConfig({
|
|
3744
3872
|
cliUserAuthUrl: flagString(flags, "user-auth-url"),
|
|
3745
|
-
|
|
3746
|
-
storedUserAuthUrl: initial?.userAuthUrl,
|
|
3747
|
-
storedPlatformUrl: initial?.platformUrl,
|
|
3748
|
-
storedWebappUrl: initial?.webappUrl
|
|
3873
|
+
storedUserAuthUrl: initial?.userAuthUrl
|
|
3749
3874
|
});
|
|
3750
3875
|
const tokenManager = new TokenManager({
|
|
3751
3876
|
store,
|
|
3752
3877
|
envFallback: envConfig
|
|
3753
3878
|
});
|
|
3754
|
-
const client = new PlatformClient({
|
|
3879
|
+
const client = new PlatformClient({
|
|
3880
|
+
resolveBaseUrl: createPlatformUrlResolver({
|
|
3881
|
+
store,
|
|
3882
|
+
tokenManager,
|
|
3883
|
+
userAuthUrl: config.userAuthUrl,
|
|
3884
|
+
webappUrl: config.webappUrl
|
|
3885
|
+
}),
|
|
3886
|
+
tokenManager
|
|
3887
|
+
});
|
|
3755
3888
|
await startStdioServer({ client, version: readVersion() });
|
|
3756
3889
|
}
|
|
3757
3890
|
function isCliEntry() {
|
|
@@ -3782,6 +3915,8 @@ if (isCliEntry()) {
|
|
|
3782
3915
|
});
|
|
3783
3916
|
}
|
|
3784
3917
|
export {
|
|
3918
|
+
REVERSE_DISCOVERY_HINT,
|
|
3919
|
+
createPlatformUrlResolver,
|
|
3785
3920
|
main,
|
|
3786
3921
|
resolveReverseAgentUrl,
|
|
3787
3922
|
runServe
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myspec/mcp-server",
|
|
3
|
-
"version": "0.1.1-next.
|
|
3
|
+
"version": "0.1.1-next.17",
|
|
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": {
|