@myspec/mcp-server 0.1.0-next.1 → 0.1.0-next.11

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 +38 -3
  2. package/dist/index.js +261 -79
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -23,19 +23,54 @@ login Sign in via browser loopback OAuth
23
23
  login --paste Sign in by pasting a one-time code
24
24
  login --provider <google|github|gitlab|linear|atlassian>
25
25
  logout Revoke refresh token and clear local credentials
26
+ reverse --root <dir> Connect to ai-agent and expose local_fs tools
26
27
  ```
27
28
 
28
- Credentials are persisted at `$XDG_CONFIG_HOME/myspec/mcp-server.json` (POSIX) or `%APPDATA%\myspec\mcp-server.json` (Windows), mode `0600`.
29
+ ### `reverse`
30
+
31
+ `reverse` connects out to the ai-agent service over WebSocket and exposes a set
32
+ of `local_fs` tools scoped to a single local directory, so a cloud agent can
33
+ read files from your machine.
34
+
35
+ ```bash
36
+ npx @myspec/mcp-server@next reverse --root /path/to/project
37
+ ```
38
+
39
+ The ai-agent WebSocket URL is **discovered at startup** from the webapp's
40
+ authenticated `GET /api/v1/config` endpoint using your saved credentials, so
41
+ 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
43
+ guessing — pass `--agent-url` or set `MYSPEC_AI_AGENT_WS_URL` to skip
44
+ discovery (e.g. against a local ai-agent):
45
+
46
+ ```bash
47
+ npx @myspec/mcp-server@next reverse --root . --agent-url ws://localhost:3001/mcp/reverse
48
+ ```
49
+
50
+ | Flag / env | Purpose |
51
+ |---|---|
52
+ | `--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
+ | `--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
+ | `--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
+
57
+ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), split into two files:
58
+
59
+ - `settings.json` — non-secret config: the auth-server, platform, and webapp URLs you logged in with.
60
+ - `oauth_creds.json` — the OAuth tokens (access/refresh/expiry) and your user identity, written mode `0600`.
61
+
62
+ `logout` removes `oauth_creds.json`; `settings.json` is left in place.
29
63
 
30
64
  ## Environment variables
31
65
 
32
66
  | Variable | Purpose |
33
67
  |---|---|
34
68
  | `MYSPEC_USER_AUTH_URL` | user-auth base URL (default `https://auth.myspec.dev`) |
35
- | `MYSPEC_PLATFORM_URL` | platform API base URL (default `https://api.myspec.dev`) |
69
+ | `MYSPEC_PLATFORM_URL` | platform API base URL (default `https://platform.myspec.dev`) |
36
70
  | `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`. |
37
71
  | `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
38
- | `MYSPEC_WEBAPP_URL` | Webapp base URL used by `login --paste` to display the OAuth callback URL (default: derived from `MYSPEC_USER_AUTH_URL`) |
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
+ | `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
39
74
 
40
75
  ## Tools
41
76
 
package/dist/index.js CHANGED
@@ -90,6 +90,7 @@ async function loopbackLogin(opts) {
90
90
  expiresAt: now() + exchanged.expiresIn * 1e3,
91
91
  userAuthUrl: opts.userAuthUrl,
92
92
  platformUrl: opts.platformUrl,
93
+ webappUrl: opts.webappUrl,
93
94
  user: exchanged.user
94
95
  };
95
96
  }
@@ -266,6 +267,7 @@ async function pasteLogin(opts) {
266
267
  expiresAt: now() + exchanged.expiresIn * 1e3,
267
268
  userAuthUrl: opts.userAuthUrl,
268
269
  platformUrl: opts.platformUrl,
270
+ webappUrl: opts.webappUrl,
269
271
  user: exchanged.user
270
272
  };
271
273
  }
@@ -282,57 +284,117 @@ async function defaultPrompt(question) {
282
284
  import { promises as fs } from "fs";
283
285
  import os from "os";
284
286
  import path from "path";
285
- function defaultCredentialsPath(env = process.env) {
286
- if (process.platform === "win32") {
287
- const appData = env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
288
- return path.join(appData, "myspec", "mcp-server.json");
289
- }
290
- const xdg = env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
291
- return path.join(xdg, "myspec", "mcp-server.json");
287
+ function myspecDir() {
288
+ return path.join(os.homedir(), ".myspec");
289
+ }
290
+ function defaultSettingsPath() {
291
+ return path.join(myspecDir(), "settings.json");
292
292
  }
293
- function createFileCredentialsStore(filePath) {
294
- const target = filePath ?? defaultCredentialsPath();
293
+ function defaultOauthCredsPath() {
294
+ return path.join(myspecDir(), "oauth_creds.json");
295
+ }
296
+ function createFileCredentialsStore(paths = {}) {
297
+ const settingsPath = paths.settingsPath ?? defaultSettingsPath();
298
+ const oauthCredsPath = paths.oauthCredsPath ?? defaultOauthCredsPath();
295
299
  return {
296
- path: () => target,
300
+ // The secret bundle is the credential file users care about (`login`
301
+ // prints this); settings.json sits next to it.
302
+ path: () => oauthCredsPath,
297
303
  async load() {
298
- try {
299
- if (process.platform !== "win32") {
300
- const stat = await fs.stat(target);
301
- if ((stat.mode & 63) !== 0) {
302
- throw new Error(
303
- `Refusing to load credentials from ${target}: file mode is too permissive (${(stat.mode & 511).toString(8)}). Run \`chmod 600 ${target}\` and retry.`
304
- );
305
- }
306
- }
307
- const raw = await fs.readFile(target, "utf-8");
308
- const parsed = JSON.parse(raw);
309
- return parseCredentials(parsed);
310
- } catch (err) {
311
- if (isFsNotFound(err)) {
312
- return null;
313
- }
314
- throw err;
304
+ const oauth = await loadOauthCreds(oauthCredsPath);
305
+ if (!oauth) {
306
+ return null;
307
+ }
308
+ const settings = await loadSettings(settingsPath);
309
+ if (!settings) {
310
+ return null;
315
311
  }
312
+ return {
313
+ accessToken: oauth.accessToken,
314
+ refreshToken: oauth.refreshToken,
315
+ expiresAt: oauth.expiresAt,
316
+ userAuthUrl: settings.userAuthUrl,
317
+ platformUrl: settings.platformUrl,
318
+ webappUrl: settings.webappUrl,
319
+ user: oauth.user
320
+ };
316
321
  },
317
322
  async save(creds) {
318
- const dir = path.dirname(target);
319
- await fs.mkdir(dir, { recursive: true, mode: 448 });
320
- await fs.writeFile(target, JSON.stringify(creds, null, 2), { mode: 384 });
321
- if (process.platform !== "win32") {
322
- await fs.chmod(target, 384);
323
- }
323
+ await writeJsonFile(
324
+ settingsPath,
325
+ {
326
+ userAuthUrl: creds.userAuthUrl,
327
+ platformUrl: creds.platformUrl,
328
+ webappUrl: creds.webappUrl
329
+ },
330
+ { secret: false }
331
+ );
332
+ await writeJsonFile(
333
+ oauthCredsPath,
334
+ {
335
+ accessToken: creds.accessToken,
336
+ refreshToken: creds.refreshToken,
337
+ expiresAt: creds.expiresAt,
338
+ user: creds.user
339
+ },
340
+ { secret: true }
341
+ );
324
342
  },
325
343
  async clear() {
326
- try {
327
- await fs.unlink(target);
328
- } catch (err) {
329
- if (!isFsNotFound(err)) {
330
- throw err;
331
- }
332
- }
344
+ await unlinkIfExists(oauthCredsPath);
333
345
  }
334
346
  };
335
347
  }
348
+ async function loadOauthCreds(target) {
349
+ try {
350
+ if (process.platform !== "win32") {
351
+ const stat = await fs.stat(target);
352
+ if ((stat.mode & 63) !== 0) {
353
+ throw new Error(
354
+ `Refusing to load credentials from ${target}: file mode is too permissive (${(stat.mode & 511).toString(8)}). Run \`chmod 600 ${target}\` and retry.`
355
+ );
356
+ }
357
+ }
358
+ const raw = await fs.readFile(target, "utf-8");
359
+ return parseOauthCreds(JSON.parse(raw));
360
+ } catch (err) {
361
+ if (isFsNotFound(err)) {
362
+ return null;
363
+ }
364
+ throw err;
365
+ }
366
+ }
367
+ async function loadSettings(target) {
368
+ try {
369
+ const raw = await fs.readFile(target, "utf-8");
370
+ return parseSettings(JSON.parse(raw));
371
+ } catch (err) {
372
+ if (isFsNotFound(err)) {
373
+ return null;
374
+ }
375
+ throw err;
376
+ }
377
+ }
378
+ async function writeJsonFile(target, data, opts) {
379
+ const dir = path.dirname(target);
380
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
381
+ if (process.platform !== "win32") {
382
+ await fs.chmod(dir, 448);
383
+ }
384
+ await fs.writeFile(target, JSON.stringify(data, null, 2), { mode: 384 });
385
+ if (opts.secret && process.platform !== "win32") {
386
+ await fs.chmod(target, 384);
387
+ }
388
+ }
389
+ async function unlinkIfExists(target) {
390
+ try {
391
+ await fs.unlink(target);
392
+ } catch (err) {
393
+ if (!isFsNotFound(err)) {
394
+ throw err;
395
+ }
396
+ }
397
+ }
336
398
  var DEFAULT_USER_AUTH_URL = "https://auth.myspec.dev";
337
399
  function readEnvRefreshConfig(env = process.env) {
338
400
  if (env.MYSPEC_ACCESS_TOKEN || env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
@@ -380,19 +442,17 @@ function createCompositeCredentialsStore(opts) {
380
442
  envUserAuthUrl: () => envUserAuthUrl
381
443
  };
382
444
  }
383
- function parseCredentials(value) {
445
+ function parseOauthCreds(value) {
384
446
  if (typeof value !== "object" || value === null) {
385
- throw new Error("Credentials file is malformed (not an object)");
447
+ throw new Error("oauth_creds.json is malformed (not an object)");
386
448
  }
387
449
  const v = value;
388
450
  const accessToken = requireString(v, "accessToken");
389
451
  const refreshToken = requireString(v, "refreshToken");
390
452
  const expiresAt = requireNumber(v, "expiresAt");
391
- const userAuthUrl = requireString(v, "userAuthUrl");
392
- const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
393
453
  const userRaw = v.user;
394
454
  if (typeof userRaw !== "object" || userRaw === null) {
395
- throw new Error("Credentials file is malformed (missing user)");
455
+ throw new Error("oauth_creds.json is malformed (missing user)");
396
456
  }
397
457
  const userObj = userRaw;
398
458
  const user = {
@@ -400,7 +460,17 @@ function parseCredentials(value) {
400
460
  email: requireString(userObj, "email"),
401
461
  name: typeof userObj.name === "string" ? userObj.name : void 0
402
462
  };
403
- return { accessToken, refreshToken, expiresAt, userAuthUrl, platformUrl, user };
463
+ return { accessToken, refreshToken, expiresAt, user };
464
+ }
465
+ function parseSettings(value) {
466
+ if (typeof value !== "object" || value === null) {
467
+ throw new Error("settings.json is malformed (not an object)");
468
+ }
469
+ const v = value;
470
+ const userAuthUrl = requireString(v, "userAuthUrl");
471
+ const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
472
+ const webappUrl = typeof v.webappUrl === "string" ? v.webappUrl : void 0;
473
+ return { userAuthUrl, platformUrl, webappUrl };
404
474
  }
405
475
  function requireString(obj, key) {
406
476
  const value = obj[key];
@@ -424,8 +494,8 @@ function isFsNotFound(err) {
424
494
  function resolveConfig(sources = {}) {
425
495
  const env = sources.env ?? process.env;
426
496
  const userAuthUrl = sources.cliUserAuthUrl ?? env.MYSPEC_USER_AUTH_URL ?? sources.storedUserAuthUrl ?? "https://auth.myspec.dev";
427
- const platformUrl = sources.cliPlatformUrl ?? env.MYSPEC_PLATFORM_URL ?? sources.storedPlatformUrl ?? "https://api.myspec.dev";
428
- const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? deriveWebappFromAuth(userAuthUrl);
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);
429
499
  validateUrl(userAuthUrl, "user-auth URL");
430
500
  validateUrl(platformUrl, "platform URL");
431
501
  validateUrl(webappUrl, "webapp URL");
@@ -589,6 +659,7 @@ var TokenManager = class {
589
659
  expiresAt: 0,
590
660
  userAuthUrl: this.envFallback.userAuthUrl,
591
661
  platformUrl: creds.platformUrl,
662
+ webappUrl: creds.webappUrl,
592
663
  user: creds.user
593
664
  };
594
665
  try {
@@ -1079,7 +1150,9 @@ var HttpAttachmentClient = class {
1079
1150
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1080
1151
  const url = `${baseUrl}/project/v1/attachments/${attachmentId}/content`;
1081
1152
  const attempt = async () => {
1082
- const blob = new Blob([content], { type: "application/octet-stream" });
1153
+ const blob = new Blob([content], {
1154
+ type: "application/octet-stream"
1155
+ });
1083
1156
  const response = await fetch(url, {
1084
1157
  method: "PUT",
1085
1158
  headers: {
@@ -1173,7 +1246,9 @@ var HttpFileClient = class {
1173
1246
  async uploadContent(fileId, content, jwtToken) {
1174
1247
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1175
1248
  const url = `${baseUrl}/project/v1/files/${fileId}/content`;
1176
- const blob = new Blob([content], { type: "application/octet-stream" });
1249
+ const blob = new Blob([content], {
1250
+ type: "application/octet-stream"
1251
+ });
1177
1252
  const response = await fetch(url, {
1178
1253
  method: "PUT",
1179
1254
  headers: {
@@ -1200,7 +1275,9 @@ var HttpFileClient = class {
1200
1275
  async saveManualRevision(fileId, content, jwtToken) {
1201
1276
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1202
1277
  const url = `${baseUrl}/project/v1/files/${fileId}/revisions`;
1203
- const blob = new Blob([content], { type: "application/octet-stream" });
1278
+ const blob = new Blob([content], {
1279
+ type: "application/octet-stream"
1280
+ });
1204
1281
  const response = await fetch(url, {
1205
1282
  method: "POST",
1206
1283
  headers: {
@@ -1245,6 +1322,17 @@ var HttpFileClient = class {
1245
1322
  expiresAt: new Date(response.expires_at)
1246
1323
  };
1247
1324
  }
1325
+ async getRevisionDownloadUrl(fileId, revisionNumber, jwtToken) {
1326
+ const response = await this.httpClient.get(
1327
+ `/project/v1/files/${fileId}/revisions/${revisionNumber}`,
1328
+ jwtToken,
1329
+ { headers: { Accept: "application/json" } }
1330
+ );
1331
+ return {
1332
+ signedUrl: response.signed_url,
1333
+ expiresAt: new Date(response.expires_at)
1334
+ };
1335
+ }
1248
1336
  async downloadContent(fileId, jwtToken) {
1249
1337
  const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
1250
1338
  const url = `${baseUrl}/project/v1/files/${fileId}`;
@@ -1538,6 +1626,11 @@ var PlatformClient = class {
1538
1626
  async getFileDownloadUrl(fileId) {
1539
1627
  return this.withTokenRetry((jwt) => this.file.getDownloadUrl(fileId, jwt));
1540
1628
  }
1629
+ async getRevisionDownloadUrl(fileId, revisionNumber) {
1630
+ return this.withTokenRetry(
1631
+ (jwt) => this.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
1632
+ );
1633
+ }
1541
1634
  async getAttachmentDownloadUrl(attachmentId) {
1542
1635
  return this.withTokenRetry((jwt) => this.attachment.getDownloadUrl(attachmentId, jwt));
1543
1636
  }
@@ -1692,12 +1785,12 @@ import { z as z2 } from "zod";
1692
1785
  var inputSchema2 = {
1693
1786
  file_id: z2.string().min(1).describe("UUID of the file"),
1694
1787
  include_download_url: z2.number().int().nonnegative().optional().describe(
1695
- "Revision number to include a download URL for. Pass `revision_count` for the latest (signed URL); pass an older revision to get a URL plus `download_headers` carrying a short-lived bearer token. Omit or 0 to skip."
1788
+ "Revision number to include a download URL for. Returns a signed, time-limited URL for that revision (latest or historical) that needs no credentials. Omit or 0 to skip."
1696
1789
  )
1697
1790
  };
1698
1791
  var getFileTool = {
1699
1792
  name: "get_file",
1700
- description: "Get file metadata. With `include_download_url=N`, also returns a download URL for revision N: latest returns a signed URL; older revisions return a URL plus `download_headers` containing a short-lived bearer token (treat as sensitive).",
1793
+ description: "Get file metadata. With `include_download_url=N`, also returns a signed, time-limited download URL for revision N (latest or historical). The URL is self-authenticating, so no credentials are returned.",
1701
1794
  inputSchema: inputSchema2,
1702
1795
  handler: async (args, ctx) => {
1703
1796
  const meta = await ctx.client.getFileMetadata(args.file_id);
@@ -1723,24 +1816,12 @@ var getFileTool = {
1723
1816
  );
1724
1817
  }
1725
1818
  const isLatest = requested === meta.revisionCount;
1726
- let downloadBlock;
1727
- if (isLatest) {
1728
- const signed = await ctx.client.getFileDownloadUrl(meta.id);
1729
- downloadBlock = {
1730
- download_url: signed.signedUrl,
1731
- download_revision: meta.revisionCount,
1732
- expires_at: signed.expiresAt.toISOString()
1733
- };
1734
- } else {
1735
- const url = `${ctx.client.baseUrl}/project/v1/files/${encodeURIComponent(meta.id)}/revisions/${String(requested)}`;
1736
- const token = await ctx.client.getAccessTokenWithExpiry();
1737
- downloadBlock = {
1738
- download_url: url,
1739
- download_revision: requested,
1740
- expires_at: new Date(token.expiresAt).toISOString(),
1741
- download_headers: [`Authorization: Bearer ${token.accessToken}`]
1742
- };
1743
- }
1819
+ const signed = isLatest ? await ctx.client.getFileDownloadUrl(meta.id) : await ctx.client.getRevisionDownloadUrl(meta.id, requested);
1820
+ const downloadBlock = {
1821
+ download_url: signed.signedUrl,
1822
+ download_revision: requested,
1823
+ expires_at: signed.expiresAt.toISOString()
1824
+ };
1744
1825
  return jsonResult({ ...base, ...downloadBlock });
1745
1826
  }
1746
1827
  };
@@ -3300,7 +3381,8 @@ async function runOneConnection(opts) {
3300
3381
  headers: {
3301
3382
  Authorization: `Bearer ${opts.accessToken}`,
3302
3383
  "X-Mcp-Client": `myspec-mcp-server/${opts.version}`,
3303
- "X-Mcp-Root-Label": opts.rootLabel
3384
+ "X-Mcp-Root-Label": opts.rootLabel,
3385
+ "X-Client-Type": "mcp-server"
3304
3386
  }
3305
3387
  });
3306
3388
  await waitForOpen(ws);
@@ -3384,6 +3466,64 @@ function sleep2(ms) {
3384
3466
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3385
3467
  }
3386
3468
 
3469
+ // src/reverse/discover.ts
3470
+ async function discoverAgentUrl(opts) {
3471
+ const fetchImpl = opts.fetchImpl ?? fetch;
3472
+ const endpoint = `${opts.webappUrl}/api/v1/config`;
3473
+ let response;
3474
+ try {
3475
+ const token = await opts.getAccessToken();
3476
+ response = await fetchConfig(endpoint, token, fetchImpl);
3477
+ if (response.status === 401 && opts.forceRefresh) {
3478
+ const refreshed = await opts.forceRefresh();
3479
+ response = await fetchConfig(endpoint, refreshed, fetchImpl);
3480
+ }
3481
+ } catch (err) {
3482
+ const message = err instanceof Error ? err.message : String(err);
3483
+ throw discoveryError(endpoint, message);
3484
+ }
3485
+ if (!response.ok) {
3486
+ throw discoveryError(endpoint, `HTTP ${String(response.status)}`);
3487
+ }
3488
+ let body;
3489
+ try {
3490
+ body = await response.json();
3491
+ } catch {
3492
+ throw discoveryError(endpoint, "response is not valid JSON");
3493
+ }
3494
+ const agentUrl = body.aiAgentMcpReverseUrl;
3495
+ if (typeof agentUrl !== "string" || agentUrl.length === 0) {
3496
+ throw discoveryError(endpoint, "response is missing aiAgentMcpReverseUrl");
3497
+ }
3498
+ assertWebSocketUrl(agentUrl, endpoint);
3499
+ return agentUrl;
3500
+ }
3501
+ function fetchConfig(endpoint, token, fetchImpl) {
3502
+ return fetchImpl(endpoint, {
3503
+ method: "GET",
3504
+ headers: { Authorization: `Bearer ${token}` }
3505
+ });
3506
+ }
3507
+ function assertWebSocketUrl(value, endpoint) {
3508
+ let parsed;
3509
+ try {
3510
+ parsed = new URL(value);
3511
+ } catch {
3512
+ throw discoveryError(endpoint, `returned an invalid URL: ${value}`);
3513
+ }
3514
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
3515
+ throw discoveryError(
3516
+ endpoint,
3517
+ `returned a non-WebSocket URL (${value}); expected ws:// or wss://`
3518
+ );
3519
+ }
3520
+ }
3521
+ function discoveryError(endpoint, reason) {
3522
+ return new ConfigError(
3523
+ `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.`
3524
+ );
3525
+ }
3526
+
3387
3527
  // src/index.ts
3388
3528
  function parseArgs(argv) {
3389
3529
  const [first, ...rest] = argv;
@@ -3450,22 +3590,27 @@ function printHelp() {
3450
3590
  " login Sign in via browser (chooser page on the webapp)",
3451
3591
  " login --paste Sign in by pasting a one-time code",
3452
3592
  " logout Revoke refresh token and clear local credentials",
3453
- " reverse --root <dir> Connect to ai-agent and expose local_fs tools (spike).",
3593
+ " reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
3594
+ " The agent WebSocket URL is discovered from the webapp",
3595
+ " (GET /api/v1/config) using your saved credentials;",
3596
+ " override with --agent-url <url> or MYSPEC_AI_AGENT_WS_URL.",
3454
3597
  " Uses the saved refresh_token from `npx @myspec/mcp-server login`",
3455
3598
  " to obtain & auto-refresh access tokens. Override with",
3456
3599
  " --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
3457
3600
  " --version Print version",
3458
3601
  " --help Print this help",
3459
3602
  "",
3460
- "Login flags:",
3603
+ "Login / reverse flags:",
3461
3604
  " --user-auth-url <url> user-auth base URL (overrides env / defaults)",
3462
3605
  " --platform-url <url> platform API base URL",
3463
- " --webapp-url <url> webapp base URL hosting the /auth/cli page",
3606
+ " --webapp-url <url> webapp base URL (login: hosts the /auth/cli page;",
3607
+ " reverse: hosts the /api/v1/config discovery endpoint)",
3464
3608
  "",
3465
3609
  "Environment:",
3466
3610
  " MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
3467
- " MYSPEC_PLATFORM_URL platform API base URL (default https://api.myspec.dev)",
3611
+ " MYSPEC_PLATFORM_URL platform API base URL (default https://platform.myspec.dev)",
3468
3612
  " MYSPEC_WEBAPP_URL webapp base URL (default derived from user-auth host)",
3613
+ " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
3469
3614
  " MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
3470
3615
  " fresh access token on first use via this refresh",
3471
3616
  " token."
@@ -3516,16 +3661,35 @@ async function main(argv = process.argv.slice(2)) {
3516
3661
  return;
3517
3662
  }
3518
3663
  }
3664
+ async function resolveReverseAgentUrl(sources) {
3665
+ const env = sources.env ?? process.env;
3666
+ const explicitAgentUrl = sources.flagAgentUrl ?? env.MYSPEC_AI_AGENT_WS_URL;
3667
+ if (explicitAgentUrl) {
3668
+ return { agentUrl: explicitAgentUrl };
3669
+ }
3670
+ const stored = await sources.loadStored().catch(() => null);
3671
+ const config = resolveConfig({
3672
+ env,
3673
+ cliWebappUrl: sources.cliWebappUrl,
3674
+ storedUserAuthUrl: stored?.userAuthUrl,
3675
+ storedWebappUrl: stored?.webappUrl
3676
+ });
3677
+ const agentUrl = await sources.discover(config.webappUrl);
3678
+ return { agentUrl, discoveredVia: `${config.webappUrl}/api/v1/config` };
3679
+ }
3519
3680
  async function runReverseCommand(flags) {
3520
3681
  const root = flagString(flags, "root") ?? process.cwd();
3521
- const agentUrl = flagString(flags, "agent-url") ?? process.env.MYSPEC_AI_AGENT_WS_URL ?? "ws://localhost:3001/mcp/reverse";
3522
3682
  const inlineToken = flagString(flags, "access-token") ?? process.env.MYSPEC_ACCESS_TOKEN;
3523
3683
  let getAccessToken;
3524
3684
  let onAuthFailed;
3685
+ let forceRefresh;
3686
+ let loadStored;
3525
3687
  if (inlineToken) {
3526
3688
  const staticToken = inlineToken;
3527
3689
  getAccessToken = () => Promise.resolve(staticToken);
3528
3690
  onAuthFailed = void 0;
3691
+ forceRefresh = void 0;
3692
+ loadStored = () => Promise.resolve(null);
3529
3693
  } else {
3530
3694
  const envConfig = readEnvRefreshConfig();
3531
3695
  const store = createCompositeCredentialsStore({
@@ -3537,10 +3701,26 @@ async function runReverseCommand(flags) {
3537
3701
  onAuthFailed = async () => {
3538
3702
  await tokenManager.forceRefresh();
3539
3703
  };
3704
+ forceRefresh = () => tokenManager.forceRefresh();
3705
+ loadStored = () => store.load();
3706
+ }
3707
+ const resolved = await resolveReverseAgentUrl({
3708
+ flagAgentUrl: flagString(flags, "agent-url"),
3709
+ cliWebappUrl: flagString(flags, "webapp-url"),
3710
+ loadStored,
3711
+ discover: (webappUrl) => {
3712
+ return discoverAgentUrl({ webappUrl, getAccessToken, forceRefresh });
3713
+ }
3714
+ });
3715
+ if (resolved.discoveredVia) {
3716
+ process.stderr.write(
3717
+ `myspec-mcp reverse: discovered agent URL via ${resolved.discoveredVia}
3718
+ `
3719
+ );
3540
3720
  }
3541
3721
  await runReverse({
3542
3722
  root,
3543
- agentUrl,
3723
+ agentUrl: resolved.agentUrl,
3544
3724
  getAccessToken,
3545
3725
  onAuthFailed,
3546
3726
  version: readVersion()
@@ -3562,7 +3742,8 @@ async function runServe(flags) {
3562
3742
  cliUserAuthUrl: flagString(flags, "user-auth-url"),
3563
3743
  cliPlatformUrl: flagString(flags, "platform-url"),
3564
3744
  storedUserAuthUrl: initial?.userAuthUrl,
3565
- storedPlatformUrl: initial?.platformUrl
3745
+ storedPlatformUrl: initial?.platformUrl,
3746
+ storedWebappUrl: initial?.webappUrl
3566
3747
  });
3567
3748
  const tokenManager = new TokenManager({
3568
3749
  store,
@@ -3600,5 +3781,6 @@ if (isCliEntry()) {
3600
3781
  }
3601
3782
  export {
3602
3783
  main,
3784
+ resolveReverseAgentUrl,
3603
3785
  runServe
3604
3786
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.1.0-next.1",
3
+ "version": "0.1.0-next.11",
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": {
@@ -50,7 +50,7 @@
50
50
  "@eslint/js": "^10.0.1",
51
51
  "@myspec/platform-client": "file:../../packages/platform-client",
52
52
  "@myspec/shared": "file:../../packages/shared",
53
- "@types/node": "^22.10.0",
53
+ "@types/node": "^25.9.1",
54
54
  "@types/ws": "^8.18.1",
55
55
  "eslint": "^10.4.1",
56
56
  "tsup": "^8.3.5",