@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.
Files changed (3) hide show
  1. package/README.md +19 -8
  2. package/dist/index.js +270 -139
  3. 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@next reverse --root /path/to/project
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
- If discovery fails, `reverse` exits with an actionable error instead of
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@next reverse --root . --agent-url ws://localhost:3001/mcp/reverse
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, platform, and webapp URLs you logged in with.
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
- return { userAuthUrl, platformUrl, webappUrl };
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 platformUrl = sources.cliPlatformUrl ?? env.MYSPEC_PLATFORM_URL ?? sources.storedPlatformUrl ?? "https://platform.myspec.dev";
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
- cliUserAuthUrl: options.userAuthUrl,
547
- cliPlatformUrl: options.platformUrl,
548
- cliWebappUrl: options.webappUrl
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 store = createFileCredentialsStore();
551
- const credentials = options.paste ? await pasteLogin({
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 store.save(credentials);
565
- process.stderr.write(`Signed in as ${credentials.user.email}.
566
- `);
567
- process.stderr.write(`Credentials saved to ${store.path()}
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
- project;
1591
- session;
1592
- file;
1593
- attachment;
1594
- baseUrl;
1693
+ resolveBaseUrl;
1694
+ clientsPromise = null;
1595
1695
  constructor(deps) {
1596
1696
  this.tokenManager = deps.tokenManager;
1597
- this.baseUrl = deps.baseUrl.replace(/\/$/, "");
1598
- const httpClient = new BaseHttpClient({ baseUrl: this.baseUrl, logger: silentLogger });
1599
- this.project = new HttpProjectClient(httpClient);
1600
- this.session = new HttpSpecSessionClient(httpClient);
1601
- this.file = new HttpFileClient(httpClient);
1602
- this.attachment = new HttpAttachmentClient(httpClient);
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) => this.project.listProjects(jwt, opts));
1707
+ return this.withTokenRetry((jwt, c) => c.project.listProjects(jwt, opts));
1606
1708
  }
1607
1709
  async getProject(projectId) {
1608
- return this.withTokenRetry((jwt) => this.project.getProject(projectId, 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) => this.session.listSpecSessions(projectId, jwt, merged));
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) => this.file.listFiles(projectId, jwt, opts));
1720
+ return this.withTokenRetry((jwt, c) => c.file.listFiles(projectId, jwt, opts));
1619
1721
  }
1620
1722
  async getFileMetadata(fileId) {
1621
- return this.withTokenRetry((jwt) => this.file.getFile(fileId, 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) => this.file.getDownloadUrl(fileId, 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) => this.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
1735
+ (jwt, c) => c.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
1634
1736
  );
1635
1737
  }
1636
1738
  async getAttachmentDownloadUrl(attachmentId) {
1637
- return this.withTokenRetry((jwt) => this.attachment.getDownloadUrl(attachmentId, 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) => this.file.downloadRevision(fileId, revision, 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) => this.file.downloadContent(fileId, 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) => this.attachment.listByProject(projectId, 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) => this.attachment.getById(attachmentId, 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) => this.attachment.downloadContent(attachmentId, 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) => this.attachment.deleteById(attachmentId, 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) => this.attachment.initiateUpload(
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) => this.attachment.uploadContent(initiated.attachmentId, args.content, 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
- "Login / reverse flags:",
3606
- " --user-auth-url <url> user-auth base URL (overrides env / defaults)",
3607
- " --platform-url <url> platform API base URL",
3608
- " --webapp-url <url> webapp base URL (login: hosts the /auth/cli page;",
3609
- " reverse: hosts the /api/v1/config discovery endpoint)",
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
- env,
3675
- cliWebappUrl: sources.cliWebappUrl,
3676
- storedUserAuthUrl: stored?.userAuthUrl,
3677
- storedWebappUrl: stored?.webappUrl
3678
- });
3679
- const agentUrl = await sources.discover(config.webappUrl);
3680
- return { agentUrl, discoveredVia: `${config.webappUrl}/api/v1/config` };
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 discoverAgentUrl({ webappUrl, getAccessToken, forceRefresh });
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
- cliPlatformUrl: flagString(flags, "platform-url"),
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({ baseUrl: config.platformUrl, tokenManager });
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.0",
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": {