@myspec/mcp-server 0.1.0-next.4 → 0.1.0-next.5

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 -6
  2. package/dist/index.js +118 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,28 +23,40 @@ 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 (experimental)
26
+ reverse --root <dir> Connect to ai-agent and expose local_fs tools
27
27
  ```
28
28
 
29
- ### `reverse` (experimental)
29
+ ### `reverse`
30
30
 
31
31
  `reverse` connects out to the ai-agent service over WebSocket and exposes a set
32
32
  of `local_fs` tools scoped to a single local directory, so a cloud agent can
33
- read files from your machine. This is an early spike.
33
+ read files from your machine.
34
34
 
35
35
  ```bash
36
36
  npx @myspec/mcp-server@next reverse --root /path/to/project
37
37
  ```
38
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
+
39
50
  | Flag / env | Purpose |
40
51
  |---|---|
41
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. |
42
- | `--agent-url <url>` / `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL (default `ws://localhost:3001/mcp/reverse`). |
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). |
43
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. |
44
56
 
45
57
  Local state is stored under `~/.myspec/` (alongside the on-disk cache root), split into two files:
46
58
 
47
- - `settings.json` — non-secret config: the auth-server and platform URLs you logged in with.
59
+ - `settings.json` — non-secret config: the auth-server, platform, and webapp URLs you logged in with.
48
60
  - `oauth_creds.json` — the OAuth tokens (access/refresh/expiry) and your user identity, written mode `0600`.
49
61
 
50
62
  `logout` removes `oauth_creds.json`; `settings.json` is left in place.
@@ -57,7 +69,8 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
57
69
  | `MYSPEC_PLATFORM_URL` | platform API base URL (default `https://platform.myspec.dev`) |
58
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`. |
59
71
  | `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
60
- | `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 |
61
74
 
62
75
  ## Tools
63
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
  }
@@ -313,13 +315,18 @@ function createFileCredentialsStore(paths = {}) {
313
315
  expiresAt: oauth.expiresAt,
314
316
  userAuthUrl: settings.userAuthUrl,
315
317
  platformUrl: settings.platformUrl,
318
+ webappUrl: settings.webappUrl,
316
319
  user: oauth.user
317
320
  };
318
321
  },
319
322
  async save(creds) {
320
323
  await writeJsonFile(
321
324
  settingsPath,
322
- { userAuthUrl: creds.userAuthUrl, platformUrl: creds.platformUrl },
325
+ {
326
+ userAuthUrl: creds.userAuthUrl,
327
+ platformUrl: creds.platformUrl,
328
+ webappUrl: creds.webappUrl
329
+ },
323
330
  { secret: false }
324
331
  );
325
332
  await writeJsonFile(
@@ -462,7 +469,8 @@ function parseSettings(value) {
462
469
  const v = value;
463
470
  const userAuthUrl = requireString(v, "userAuthUrl");
464
471
  const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
465
- return { userAuthUrl, platformUrl };
472
+ const webappUrl = typeof v.webappUrl === "string" ? v.webappUrl : void 0;
473
+ return { userAuthUrl, platformUrl, webappUrl };
466
474
  }
467
475
  function requireString(obj, key) {
468
476
  const value = obj[key];
@@ -487,7 +495,7 @@ function resolveConfig(sources = {}) {
487
495
  const env = sources.env ?? process.env;
488
496
  const userAuthUrl = sources.cliUserAuthUrl ?? env.MYSPEC_USER_AUTH_URL ?? sources.storedUserAuthUrl ?? "https://auth.myspec.dev";
489
497
  const platformUrl = sources.cliPlatformUrl ?? env.MYSPEC_PLATFORM_URL ?? sources.storedPlatformUrl ?? "https://platform.myspec.dev";
490
- const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? deriveWebappFromAuth(userAuthUrl);
498
+ const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? sources.storedWebappUrl ?? deriveWebappFromAuth(userAuthUrl);
491
499
  validateUrl(userAuthUrl, "user-auth URL");
492
500
  validateUrl(platformUrl, "platform URL");
493
501
  validateUrl(webappUrl, "webapp URL");
@@ -651,6 +659,7 @@ var TokenManager = class {
651
659
  expiresAt: 0,
652
660
  userAuthUrl: this.envFallback.userAuthUrl,
653
661
  platformUrl: creds.platformUrl,
662
+ webappUrl: creds.webappUrl,
654
663
  user: creds.user
655
664
  };
656
665
  try {
@@ -3446,6 +3455,64 @@ function sleep2(ms) {
3446
3455
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3447
3456
  }
3448
3457
 
3458
+ // src/reverse/discover.ts
3459
+ async function discoverAgentUrl(opts) {
3460
+ const fetchImpl = opts.fetchImpl ?? fetch;
3461
+ const endpoint = `${opts.webappUrl}/api/v1/config`;
3462
+ let response;
3463
+ try {
3464
+ const token = await opts.getAccessToken();
3465
+ response = await fetchConfig(endpoint, token, fetchImpl);
3466
+ if (response.status === 401 && opts.forceRefresh) {
3467
+ const refreshed = await opts.forceRefresh();
3468
+ response = await fetchConfig(endpoint, refreshed, fetchImpl);
3469
+ }
3470
+ } catch (err) {
3471
+ const message = err instanceof Error ? err.message : String(err);
3472
+ throw discoveryError(endpoint, message);
3473
+ }
3474
+ if (!response.ok) {
3475
+ throw discoveryError(endpoint, `HTTP ${String(response.status)}`);
3476
+ }
3477
+ let body;
3478
+ try {
3479
+ body = await response.json();
3480
+ } catch {
3481
+ throw discoveryError(endpoint, "response is not valid JSON");
3482
+ }
3483
+ const agentUrl = body.aiAgentMcpReverseUrl;
3484
+ if (typeof agentUrl !== "string" || agentUrl.length === 0) {
3485
+ throw discoveryError(endpoint, "response is missing aiAgentMcpReverseUrl");
3486
+ }
3487
+ assertWebSocketUrl(agentUrl, endpoint);
3488
+ return agentUrl;
3489
+ }
3490
+ function fetchConfig(endpoint, token, fetchImpl) {
3491
+ return fetchImpl(endpoint, {
3492
+ method: "GET",
3493
+ headers: { Authorization: `Bearer ${token}` }
3494
+ });
3495
+ }
3496
+ function assertWebSocketUrl(value, endpoint) {
3497
+ let parsed;
3498
+ try {
3499
+ parsed = new URL(value);
3500
+ } catch {
3501
+ throw discoveryError(endpoint, `returned an invalid URL: ${value}`);
3502
+ }
3503
+ if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
3504
+ throw discoveryError(
3505
+ endpoint,
3506
+ `returned a non-WebSocket URL (${value}); expected ws:// or wss://`
3507
+ );
3508
+ }
3509
+ }
3510
+ function discoveryError(endpoint, reason) {
3511
+ return new ConfigError(
3512
+ `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.`
3513
+ );
3514
+ }
3515
+
3449
3516
  // src/index.ts
3450
3517
  function parseArgs(argv) {
3451
3518
  const [first, ...rest] = argv;
@@ -3512,22 +3579,27 @@ function printHelp() {
3512
3579
  " login Sign in via browser (chooser page on the webapp)",
3513
3580
  " login --paste Sign in by pasting a one-time code",
3514
3581
  " logout Revoke refresh token and clear local credentials",
3515
- " reverse --root <dir> Connect to ai-agent and expose local_fs tools (spike).",
3582
+ " reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
3583
+ " The agent WebSocket URL is discovered from the webapp",
3584
+ " (GET /api/v1/config) using your saved credentials;",
3585
+ " override with --agent-url <url> or MYSPEC_AI_AGENT_WS_URL.",
3516
3586
  " Uses the saved refresh_token from `npx @myspec/mcp-server login`",
3517
3587
  " to obtain & auto-refresh access tokens. Override with",
3518
3588
  " --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
3519
3589
  " --version Print version",
3520
3590
  " --help Print this help",
3521
3591
  "",
3522
- "Login flags:",
3592
+ "Login / reverse flags:",
3523
3593
  " --user-auth-url <url> user-auth base URL (overrides env / defaults)",
3524
3594
  " --platform-url <url> platform API base URL",
3525
- " --webapp-url <url> webapp base URL hosting the /auth/cli page",
3595
+ " --webapp-url <url> webapp base URL (login: hosts the /auth/cli page;",
3596
+ " reverse: hosts the /api/v1/config discovery endpoint)",
3526
3597
  "",
3527
3598
  "Environment:",
3528
3599
  " MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
3529
3600
  " MYSPEC_PLATFORM_URL platform API base URL (default https://platform.myspec.dev)",
3530
3601
  " MYSPEC_WEBAPP_URL webapp base URL (default derived from user-auth host)",
3602
+ " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
3531
3603
  " MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
3532
3604
  " fresh access token on first use via this refresh",
3533
3605
  " token."
@@ -3578,16 +3650,35 @@ async function main(argv = process.argv.slice(2)) {
3578
3650
  return;
3579
3651
  }
3580
3652
  }
3653
+ async function resolveReverseAgentUrl(sources) {
3654
+ const env = sources.env ?? process.env;
3655
+ const explicitAgentUrl = sources.flagAgentUrl ?? env.MYSPEC_AI_AGENT_WS_URL;
3656
+ if (explicitAgentUrl) {
3657
+ return { agentUrl: explicitAgentUrl };
3658
+ }
3659
+ const stored = await sources.loadStored().catch(() => null);
3660
+ const config = resolveConfig({
3661
+ env,
3662
+ cliWebappUrl: sources.cliWebappUrl,
3663
+ storedUserAuthUrl: stored?.userAuthUrl,
3664
+ storedWebappUrl: stored?.webappUrl
3665
+ });
3666
+ const agentUrl = await sources.discover(config.webappUrl);
3667
+ return { agentUrl, discoveredVia: `${config.webappUrl}/api/v1/config` };
3668
+ }
3581
3669
  async function runReverseCommand(flags) {
3582
3670
  const root = flagString(flags, "root") ?? process.cwd();
3583
- const agentUrl = flagString(flags, "agent-url") ?? process.env.MYSPEC_AI_AGENT_WS_URL ?? "ws://localhost:3001/mcp/reverse";
3584
3671
  const inlineToken = flagString(flags, "access-token") ?? process.env.MYSPEC_ACCESS_TOKEN;
3585
3672
  let getAccessToken;
3586
3673
  let onAuthFailed;
3674
+ let forceRefresh;
3675
+ let loadStored;
3587
3676
  if (inlineToken) {
3588
3677
  const staticToken = inlineToken;
3589
3678
  getAccessToken = () => Promise.resolve(staticToken);
3590
3679
  onAuthFailed = void 0;
3680
+ forceRefresh = void 0;
3681
+ loadStored = () => Promise.resolve(null);
3591
3682
  } else {
3592
3683
  const envConfig = readEnvRefreshConfig();
3593
3684
  const store = createCompositeCredentialsStore({
@@ -3599,10 +3690,26 @@ async function runReverseCommand(flags) {
3599
3690
  onAuthFailed = async () => {
3600
3691
  await tokenManager.forceRefresh();
3601
3692
  };
3693
+ forceRefresh = () => tokenManager.forceRefresh();
3694
+ loadStored = () => store.load();
3695
+ }
3696
+ const resolved = await resolveReverseAgentUrl({
3697
+ flagAgentUrl: flagString(flags, "agent-url"),
3698
+ cliWebappUrl: flagString(flags, "webapp-url"),
3699
+ loadStored,
3700
+ discover: (webappUrl) => {
3701
+ return discoverAgentUrl({ webappUrl, getAccessToken, forceRefresh });
3702
+ }
3703
+ });
3704
+ if (resolved.discoveredVia) {
3705
+ process.stderr.write(
3706
+ `myspec-mcp reverse: discovered agent URL via ${resolved.discoveredVia}
3707
+ `
3708
+ );
3602
3709
  }
3603
3710
  await runReverse({
3604
3711
  root,
3605
- agentUrl,
3712
+ agentUrl: resolved.agentUrl,
3606
3713
  getAccessToken,
3607
3714
  onAuthFailed,
3608
3715
  version: readVersion()
@@ -3624,7 +3731,8 @@ async function runServe(flags) {
3624
3731
  cliUserAuthUrl: flagString(flags, "user-auth-url"),
3625
3732
  cliPlatformUrl: flagString(flags, "platform-url"),
3626
3733
  storedUserAuthUrl: initial?.userAuthUrl,
3627
- storedPlatformUrl: initial?.platformUrl
3734
+ storedPlatformUrl: initial?.platformUrl,
3735
+ storedWebappUrl: initial?.webappUrl
3628
3736
  });
3629
3737
  const tokenManager = new TokenManager({
3630
3738
  store,
@@ -3662,5 +3770,6 @@ if (isCliEntry()) {
3662
3770
  }
3663
3771
  export {
3664
3772
  main,
3773
+ resolveReverseAgentUrl,
3665
3774
  runServe
3666
3775
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.1.0-next.4",
3
+ "version": "0.1.0-next.5",
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": {