@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.
Files changed (3) hide show
  1. package/README.md +19 -8
  2. package/dist/index.js +274 -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 {
@@ -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
- project;
1591
- session;
1592
- file;
1593
- attachment;
1594
- baseUrl;
1697
+ resolveBaseUrl;
1698
+ clientsPromise = null;
1595
1699
  constructor(deps) {
1596
1700
  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);
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) => this.project.listProjects(jwt, opts));
1711
+ return this.withTokenRetry((jwt, c) => c.project.listProjects(jwt, opts));
1606
1712
  }
1607
1713
  async getProject(projectId) {
1608
- return this.withTokenRetry((jwt) => this.project.getProject(projectId, 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) => this.session.listSpecSessions(projectId, jwt, merged));
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) => this.file.listFiles(projectId, jwt, opts));
1724
+ return this.withTokenRetry((jwt, c) => c.file.listFiles(projectId, jwt, opts));
1619
1725
  }
1620
1726
  async getFileMetadata(fileId) {
1621
- return this.withTokenRetry((jwt) => this.file.getFile(fileId, 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) => this.file.getDownloadUrl(fileId, 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) => this.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
1739
+ (jwt, c) => c.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
1634
1740
  );
1635
1741
  }
1636
1742
  async getAttachmentDownloadUrl(attachmentId) {
1637
- return this.withTokenRetry((jwt) => this.attachment.getDownloadUrl(attachmentId, 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) => this.file.downloadRevision(fileId, revision, 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) => this.file.downloadContent(fileId, 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) => this.attachment.listByProject(projectId, 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) => this.attachment.getById(attachmentId, 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) => this.attachment.downloadContent(attachmentId, 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) => this.attachment.deleteById(attachmentId, 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) => this.attachment.initiateUpload(
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) => this.attachment.uploadContent(initiated.attachmentId, args.content, 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
- "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)",
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
- 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` };
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 discoverAgentUrl({ webappUrl, getAccessToken, forceRefresh });
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
- cliPlatformUrl: flagString(flags, "platform-url"),
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({ baseUrl: config.platformUrl, tokenManager });
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.15",
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": {