@myspec/mcp-server 0.2.0-next.78 → 0.2.0-next.80

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 +119 -2
  2. package/dist/index.js +183 -153
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,12 +20,130 @@ Run with `npx @myspec/mcp-server [command]` (or `myspec-mcp [command]` when inst
20
20
  (default) Start MCP server over stdio
21
21
  serve Start MCP server over stdio
22
22
  login Sign in via browser loopback OAuth
23
- login --paste Sign in by pasting a one-time code
23
+ login --paste Same as login, but never auto-opens a browser
24
24
  login --org <slug> Sign in and pin that organization
25
25
  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
+ ### Signing in from another device (`--paste`)
30
+
31
+ `login` opens a browser on this machine and catches the callback on a local
32
+ loopback port. When the browser lives somewhere else — a remote dev box, a
33
+ container, a phone — use `--paste`: the same flow, minus the auto-open.
34
+
35
+ ```bash
36
+ npx -y @myspec/mcp-server login --paste
37
+ ```
38
+
39
+ Open the printed URL wherever you can. If the page cannot reach this CLI, it
40
+ shows a token in a box that looks like `<code>.<state>`. Copy **the whole
41
+ value, including the dot** and paste it back into the terminal. Two traps:
42
+
43
+ - The page's copy button is labelled "Copy code" and the page URL contains a
44
+ bare `?code=`. Neither of those bare values works — the CLI rejects them with
45
+ `Pasted token is missing a state suffix`.
46
+ - The code expires **60 seconds after you finish signing in** — not 60 seconds
47
+ after the token appears. user-auth starts that clock before redirecting to
48
+ the page, and the page then spends part of it trying to reach this CLI
49
+ (instant if the port refuses the connection, but up to the browser's connect
50
+ timeout if it is filtered). So paste it the moment it appears; a stale one
51
+ fails with `OAuth code exchange failed: HTTP 401`. The CLI's own deadline is
52
+ minutes long, which is time to *reach* the token, not to use it.
53
+
54
+ > **Do not open the sign-in URL on a machine where you do not trust everything
55
+ > else running on it.** The page attempts a callback to `127.0.0.1:<port>` on
56
+ > whichever machine opens the URL, and anything listening on that port can take
57
+ > the one-time code and exchange it for an access **and** refresh token —
58
+ > keeping access until you run `logout`. The CLI prints the same warning with
59
+ > the actual port, before the URL.
60
+
61
+ ### API tokens (unattended setup)
62
+
63
+ `login` is interactive — it opens a browser. For anything that has to start
64
+ without a person present (a server, a container, a CI job, a shared machine),
65
+ use an API token instead.
66
+
67
+ **1. Create the token.** In the MySpec webapp, open the avatar menu → **API
68
+ tokens** → **Create token**. Choose the organization it should act in, whether
69
+ it may write or only read, and an expiry. The token is shown **once**; it
70
+ cannot be retrieved afterwards.
71
+
72
+ **2. Configure it.** Credentials live in two files under `~/.myspec/`, both of
73
+ which the server needs:
74
+
75
+ `~/.myspec/oauth_creds.json` — the secret, **mode 0600** (the store refuses any
76
+ file readable by group or other):
77
+
78
+ ```json
79
+ { "apiToken": "msp_pat_…" }
80
+ ```
81
+
82
+ `~/.myspec/settings.json` — which auth server to talk to:
83
+
84
+ ```json
85
+ { "userAuthUrl": "https://auth.myspec.dev" }
86
+ ```
87
+
88
+ ```bash
89
+ chmod 600 ~/.myspec/oauth_creds.json
90
+ ```
91
+
92
+ `accessToken`, `expiresAt` and the discovered service URLs are filled in
93
+ automatically on first use, and an API-token credential needs no `user` block —
94
+ the owner is resolved server-side at exchange.
95
+
96
+ There is **no environment variable for an API token** — these files are the
97
+ only way to configure one.
98
+
99
+ **3. Verify it.** The token is exchanged for a short-lived access token on
100
+ every run, so a token that works here works in the server:
101
+
102
+ ```bash
103
+ curl -sS -X POST "https://auth.myspec.dev/api/auth/token/exchange" \
104
+ -H "x-api-key: msp_pat_…" -w '\n%{http_code}\n'
105
+ ```
106
+
107
+ `200` with an `accessToken` in the body means it is good. Otherwise:
108
+
109
+ | Status | Meaning |
110
+ |---|---|
111
+ | `401` | Unknown, revoked or expired token — deliberately indistinguishable. Create a new one. |
112
+ | `403` | The token's organization is gone, or its owner is no longer a member of it. Create a token for an organization you belong to. |
113
+ | `429` | Rate limited. `Retry-After` says how long to wait; the body's `scope` says whether the limit was per-address or per-token. |
114
+
115
+ #### How it differs from `login`
116
+
117
+ | | `login` | API token |
118
+ |---|---|---|
119
+ | Needs a browser | Yes | No |
120
+ | Credential rotates | Yes, on every refresh | No |
121
+ | Survives a missed write | No | Yes |
122
+ | Scope | Whatever the session can reach | One organization, fixed at creation |
123
+ | Access | Full | Read-only or read-write, fixed at creation |
124
+
125
+ An API token's organization and access mode cannot be changed after creation —
126
+ create a new token instead. Expiry *can* be extended, from the same page, and
127
+ doing so does not change the token value, so nothing needs reconfiguring.
128
+
129
+ #### Rolling back to a refresh token
130
+
131
+ If a token does not work and you need the deployment running again now:
132
+
133
+ 1. **Delete the `apiToken` field** from `~/.myspec/oauth_creds.json`. Emptying
134
+ it is not enough and neither is adding a refresh token alongside it: when an
135
+ `apiToken` is present it is used exclusively, and a rejected one fails the
136
+ run rather than falling back — silently downgrading to a different identity
137
+ would be worse than stopping.
138
+ 2. Restore the previous credential — restore a backup of the credentials file
139
+ if you took one, or run `login` again.
140
+
141
+ Do **not** use `logout` to roll back. It deletes the whole credentials file,
142
+ API token included.
143
+
144
+ Migrating an existing install off `MYSPEC_REFRESH_TOKEN`? See the
145
+ [migration runbook](../../docs/runbooks/mcp-api-token-migration.md).
146
+
29
147
  ### Active organization
30
148
 
31
149
  Every MySpec access token carries the slug of one **active organization**, and
@@ -104,7 +222,6 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
104
222
  | Variable | Purpose |
105
223
  |---|---|
106
224
  | `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. |
107
- | `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`. |
108
225
  | `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_spec_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
109
226
  | `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
110
227
 
package/dist/index.js CHANGED
@@ -29,11 +29,13 @@ var HttpStatusError = class extends Error {
29
29
  };
30
30
  var BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._\-+/=]+/g;
31
31
  var REFRESH_TOKEN_PATTERN = /"refreshToken"\s*:\s*"[^"]+"/g;
32
+ var API_TOKEN_PATTERN = /"apiToken"\s*:\s*"[^"]+"/g;
33
+ var API_TOKEN_VALUE_PATTERN = /\bmsp_pat_[A-Za-z0-9]+/g;
32
34
  var ACCESS_TOKEN_PATTERN = /"accessToken"\s*:\s*"[^"]+"/g;
33
35
  var SNAKE_TOKEN_PATTERN = /"(access_token|refresh_token|id_token)"\s*:\s*"[^"]+"/g;
34
36
  var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
35
37
  function redactSensitive(value) {
36
- return value.replace(BEARER_PATTERN, "Bearer [REDACTED]").replace(REFRESH_TOKEN_PATTERN, '"refreshToken":"[REDACTED]"').replace(ACCESS_TOKEN_PATTERN, '"accessToken":"[REDACTED]"').replace(SNAKE_TOKEN_PATTERN, (_match, key) => `"${key}":"[REDACTED]"`).replace(JWT_PATTERN, "[REDACTED_JWT]");
38
+ return value.replace(BEARER_PATTERN, "Bearer [REDACTED]").replace(REFRESH_TOKEN_PATTERN, '"refreshToken":"[REDACTED]"').replace(API_TOKEN_PATTERN, '"apiToken":"[REDACTED]"').replace(API_TOKEN_VALUE_PATTERN, "[REDACTED_API_TOKEN]").replace(ACCESS_TOKEN_PATTERN, '"accessToken":"[REDACTED]"').replace(SNAKE_TOKEN_PATTERN, (_match, key) => `"${key}":"[REDACTED]"`).replace(JWT_PATTERN, "[REDACTED_JWT]");
37
39
  }
38
40
  var ConfigError = class extends Error {
39
41
  constructor(message) {
@@ -44,6 +46,13 @@ var ConfigError = class extends Error {
44
46
 
45
47
  // src/auth/oauth-loopback.ts
46
48
  var DEFAULT_TIMEOUT_MS = 5 * 6e4;
49
+ function callbackWarning(callbackBase) {
50
+ return `Note: the sign-in page will try to call back to ${callbackBase}
51
+ on whichever machine opens that URL. Do not open it on a machine where you
52
+ do not trust everything else running on it \u2014 anything listening on that port
53
+ can take the one-time code and exchange it for an access *and* refresh token,
54
+ keeping access until you run \`myspec-mcp logout\`.`;
55
+ }
47
56
  var SUCCESS_HTML = "<!doctype html><html><body><h1>Sign-in complete</h1><p>You may close this tab and return to your terminal.</p></body></html>";
48
57
  var ERROR_HTML = "<!doctype html><html><body><h1>Sign-in failed</h1><p>Return to your terminal for details.</p></body></html>";
49
58
  async function loopbackLogin(opts) {
@@ -67,17 +76,29 @@ async function loopbackLogin(opts) {
67
76
  const webappLogin = new URL(`${opts.webappUrl.replace(/\/$/, "")}/auth/cli`);
68
77
  webappLogin.searchParams.set("target", loopbackTarget);
69
78
  const signInUrl = webappLogin.toString();
79
+ const remoteBrowser = opts.remoteBrowser ?? !opts.openBrowser;
80
+ if (remoteBrowser) {
81
+ logger(callbackWarning(callbackBase));
82
+ }
70
83
  logger(`Open this URL in your browser to sign in:
71
84
  ${signInUrl}`);
72
85
  if (!opts.disablePasteFallback) {
86
+ const lead = remoteBrowser ? "Sign in there. If the page shows a token box, copy the whole value" : "If the browser cannot reach this CLI directly, copy the whole token";
73
87
  logger(
74
- "If the browser cannot reach this CLI directly, paste the code shown on the\n/auth/cli page here and press Enter."
88
+ `${lead}
89
+ shown in the box \u2014 it looks like \`<code>.<state>\` \u2014 and paste it here,
90
+ then press Enter. (Not the \`code=\` value from the URL.) The code expires
91
+ 60 seconds after you finish signing in, and the page spends part of that
92
+ trying to reach this CLI, so paste it the moment it appears.`
75
93
  );
76
94
  }
77
95
  if (opts.openBrowser) {
78
96
  opts.openBrowser(signInUrl).catch((err) => {
79
97
  const message = err instanceof Error ? err.message : String(err);
80
98
  logger(`Failed to open browser automatically: ${message}`);
99
+ if (!remoteBrowser) {
100
+ logger(callbackWarning(callbackBase));
101
+ }
81
102
  });
82
103
  }
83
104
  }
@@ -233,51 +254,6 @@ async function exchangeCode(userAuthUrl, code, fetchImpl) {
233
254
  return await response.json();
234
255
  }
235
256
 
236
- // src/auth/oauth-paste.ts
237
- import readline2 from "readline/promises";
238
- async function pasteLogin(opts) {
239
- const fetchImpl = opts.fetchImpl ?? fetch;
240
- const now = opts.now ?? Date.now;
241
- const logger = opts.logger ?? ((m) => {
242
- process.stderr.write(m + "\n");
243
- });
244
- const signInUrl = `${opts.webappUrl.replace(/\/$/, "")}/auth/cli`;
245
- logger("Open this URL in your browser, sign in, and copy the one-time code shown:");
246
- logger(` ${signInUrl}`);
247
- const ask = opts.prompt ?? defaultPrompt;
248
- const raw = await ask("Paste the one-time code: ");
249
- const code = raw.trim();
250
- if (!code) {
251
- throw new Error("No code provided.");
252
- }
253
- const response = await fetchImpl(`${opts.userAuthUrl}/api/auth/oauth/exchange`, {
254
- method: "POST",
255
- headers: { "Content-Type": "application/json" },
256
- body: JSON.stringify({ code })
257
- });
258
- if (!response.ok) {
259
- const body = await response.text().catch(() => "");
260
- throw new HttpStatusError(response.status, body, `OAuth code exchange failed: HTTP ${String(response.status)}`);
261
- }
262
- const exchanged = await response.json();
263
- return {
264
- accessToken: exchanged.accessToken,
265
- refreshToken: exchanged.refreshToken,
266
- expiresAt: now() + exchanged.expiresIn * 1e3,
267
- userAuthUrl: opts.userAuthUrl,
268
- webappUrl: opts.webappUrl,
269
- user: exchanged.user
270
- };
271
- }
272
- async function defaultPrompt(question) {
273
- const rl = readline2.createInterface({ input: process.stdin, output: process.stderr });
274
- try {
275
- return await rl.question(question);
276
- } finally {
277
- rl.close();
278
- }
279
- }
280
-
281
257
  // src/auth/credentials-store.ts
282
258
  import { promises as fs } from "fs";
283
259
  import os from "os";
@@ -310,6 +286,7 @@ function createFileCredentialsStore(paths = {}) {
310
286
  return {
311
287
  accessToken: oauth.accessToken,
312
288
  refreshToken: oauth.refreshToken,
289
+ apiToken: oauth.apiToken,
313
290
  expiresAt: oauth.expiresAt,
314
291
  userAuthUrl: settings.userAuthUrl,
315
292
  platformUrl: settings.platformUrl,
@@ -334,6 +311,10 @@ function createFileCredentialsStore(paths = {}) {
334
311
  {
335
312
  accessToken: creds.accessToken,
336
313
  refreshToken: creds.refreshToken,
314
+ // Carried through every save. Omitting it would delete a configured
315
+ // API token on the first successful exchange, since save rewrites
316
+ // the whole file.
317
+ apiToken: creds.apiToken,
337
318
  expiresAt: creds.expiresAt,
338
319
  user: creds.user
339
320
  },
@@ -395,64 +376,39 @@ async function unlinkIfExists(target) {
395
376
  }
396
377
  }
397
378
  }
398
- var DEFAULT_USER_AUTH_URL = "https://auth.myspec.dev";
399
- function readEnvRefreshConfig(env = process.env) {
400
- if (env.MYSPEC_ACCESS_TOKEN || env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
401
- throw new ConfigError(
402
- "MYSPEC_ACCESS_TOKEN / MYSPEC_ACCESS_TOKEN_EXPIRES_AT are not supported. The MCP server mints a fresh access token via MYSPEC_REFRESH_TOKEN; unset MYSPEC_ACCESS_TOKEN (and MYSPEC_ACCESS_TOKEN_EXPIRES_AT if set) and provide only MYSPEC_REFRESH_TOKEN."
379
+ function assertNoEnvAccessToken(env = process.env) {
380
+ if (env.MYSPEC_REFRESH_TOKEN) {
381
+ process.stderr.write(
382
+ "myspec-mcp: MYSPEC_REFRESH_TOKEN is set but no longer supported and is being ignored. Set an `apiToken` in ~/.myspec/oauth_creds.json, or run `npx @myspec/mcp-server login`. See docs/runbooks/mcp-api-token-migration.md\n"
403
383
  );
404
384
  }
405
- if (!env.MYSPEC_REFRESH_TOKEN) {
406
- return null;
385
+ if (!env.MYSPEC_ACCESS_TOKEN && !env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
386
+ return;
407
387
  }
408
- return {
409
- refreshToken: env.MYSPEC_REFRESH_TOKEN,
410
- userAuthUrl: env.MYSPEC_USER_AUTH_URL ?? DEFAULT_USER_AUTH_URL
411
- };
412
- }
413
- function createCompositeCredentialsStore(opts) {
414
- const { fileStore, envConfig } = opts;
415
- const envRefreshToken = envConfig?.refreshToken;
416
- const envUserAuthUrl = envConfig?.userAuthUrl ?? DEFAULT_USER_AUTH_URL;
417
- return {
418
- path: () => fileStore.path(),
419
- async load() {
420
- const fromFile = await fileStore.load();
421
- if (fromFile) {
422
- return fromFile;
423
- }
424
- if (!envRefreshToken) {
425
- return null;
426
- }
427
- return {
428
- accessToken: "",
429
- refreshToken: envRefreshToken,
430
- expiresAt: 0,
431
- userAuthUrl: envUserAuthUrl,
432
- user: { id: "env", email: "env@mcp" }
433
- };
434
- },
435
- save(creds) {
436
- return fileStore.save(creds);
437
- },
438
- clear() {
439
- return fileStore.clear();
440
- },
441
- envRefreshToken: () => envRefreshToken,
442
- envUserAuthUrl: () => envUserAuthUrl
443
- };
388
+ throw new ConfigError(
389
+ "MYSPEC_ACCESS_TOKEN / MYSPEC_ACCESS_TOKEN_EXPIRES_AT are not supported. Run `npx @myspec/mcp-server login`, or put a long-lived API token in the `apiToken` field of your credentials file, and unset these variables."
390
+ );
444
391
  }
445
392
  function parseOauthCreds(value) {
446
393
  if (typeof value !== "object" || value === null) {
447
394
  throw new Error("oauth_creds.json is malformed (not an object)");
448
395
  }
449
396
  const v = value;
450
- const accessToken = requireString(v, "accessToken");
451
- const refreshToken = requireString(v, "refreshToken");
452
- const expiresAt = requireNumber(v, "expiresAt");
397
+ const refreshToken = optionalString(v, "refreshToken");
398
+ const apiToken = optionalString(v, "apiToken");
399
+ if (!refreshToken && !apiToken) {
400
+ throw new NeedsLoginError(
401
+ "oauth_creds.json has no apiToken or refreshToken. Run `npx @myspec/mcp-server login` to sign in, or provision an API token."
402
+ );
403
+ }
404
+ const accessToken = optionalString(v, "accessToken") ?? "";
405
+ const expiresAt = v.expiresAt === void 0 ? 0 : requireNumber(v, "expiresAt");
453
406
  const userRaw = v.user;
454
407
  if (typeof userRaw !== "object" || userRaw === null) {
455
- throw new Error("oauth_creds.json is malformed (missing user)");
408
+ if (!apiToken) {
409
+ throw new Error("oauth_creds.json is malformed (missing user)");
410
+ }
411
+ return { accessToken, refreshToken, apiToken, expiresAt };
456
412
  }
457
413
  const userObj = userRaw;
458
414
  const user = {
@@ -460,7 +416,17 @@ function parseOauthCreds(value) {
460
416
  email: requireString(userObj, "email"),
461
417
  name: typeof userObj.name === "string" ? userObj.name : void 0
462
418
  };
463
- return { accessToken, refreshToken, expiresAt, user };
419
+ return { accessToken, refreshToken, apiToken, expiresAt, user };
420
+ }
421
+ function optionalString(v, key) {
422
+ const raw = v[key];
423
+ if (raw === void 0 || raw === null) {
424
+ return void 0;
425
+ }
426
+ if (typeof raw !== "string") {
427
+ throw new Error(`Credentials file is malformed (invalid ${key})`);
428
+ }
429
+ return raw.length === 0 ? void 0 : raw;
464
430
  }
465
431
  function parseSettings(value) {
466
432
  if (typeof value !== "object" || value === null) {
@@ -497,14 +463,21 @@ var TokenManager = class {
497
463
  store;
498
464
  fetchImpl;
499
465
  now;
500
- envFallback;
501
466
  cached = null;
502
467
  refreshInFlight = null;
468
+ /**
469
+ * Set once the exchange endpoint rejects the API token outright.
470
+ *
471
+ * The rejection deliberately does not wipe the credential, so without this
472
+ * every subsequent tool call would re-POST a known-bad token to the endpoint
473
+ * the server rate-limits as its brute-force control. Latching turns a loop
474
+ * into one failed request.
475
+ */
476
+ apiTokenRejection = null;
503
477
  constructor(deps) {
504
478
  this.store = deps.store;
505
479
  this.fetchImpl = deps.fetchImpl ?? fetch;
506
480
  this.now = deps.now ?? Date.now;
507
- this.envFallback = deps.envFallback ?? null;
508
481
  }
509
482
  async getValidAccessToken() {
510
483
  const creds = await this.ensureLoaded();
@@ -547,27 +520,20 @@ var TokenManager = class {
547
520
  return this.refreshInFlight;
548
521
  }
549
522
  async doRefresh(creds) {
523
+ if (creds.apiToken) {
524
+ if (this.apiTokenRejection) {
525
+ throw this.apiTokenRejection;
526
+ }
527
+ return this.performExchange(creds, creds.apiToken);
528
+ }
529
+ if (!creds.refreshToken) {
530
+ throw new NeedsLoginError(
531
+ "No credential is configured. Either run `npx @myspec/mcp-server login` to sign in interactively, or add a long-lived API token to the `apiToken` field of your credentials file \u2014 create one in the MySpec webapp under the avatar menu, API tokens."
532
+ );
533
+ }
550
534
  try {
551
535
  return await this.performRefresh(creds);
552
536
  } catch (err) {
553
- if (err instanceof RefreshRejectedError && this.envFallback && this.envFallback.refreshToken !== creds.refreshToken) {
554
- const bootstrap = {
555
- accessToken: "",
556
- refreshToken: this.envFallback.refreshToken,
557
- expiresAt: 0,
558
- userAuthUrl: this.envFallback.userAuthUrl,
559
- platformUrl: creds.platformUrl,
560
- webappUrl: creds.webappUrl,
561
- aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl,
562
- user: creds.user
563
- };
564
- try {
565
- return await this.performRefresh(bootstrap);
566
- } catch (retryErr) {
567
- await this.invalidateOnAuthFailure(retryErr);
568
- throw retryErr;
569
- }
570
- }
571
537
  await this.invalidateOnAuthFailure(err);
572
538
  throw err;
573
539
  }
@@ -578,6 +544,52 @@ var TokenManager = class {
578
544
  await this.store.clear();
579
545
  }
580
546
  }
547
+ /**
548
+ * Trades the long-lived API token for a short-lived access token.
549
+ *
550
+ * Unlike refresh, nothing here is persisted except the new access token and
551
+ * its expiry — the API token is unchanged by the exchange, so re-saving it
552
+ * would be a no-op and expecting a rotated value back would be wrong.
553
+ */
554
+ async performExchange(creds, apiToken) {
555
+ const url = `${creds.userAuthUrl}/api/auth/token/exchange`;
556
+ const response = await this.fetchImpl(url, {
557
+ method: "POST",
558
+ headers: { "x-api-key": apiToken }
559
+ });
560
+ if (response.status === 401 || response.status === 403) {
561
+ const reason = response.status === 403 ? "the token owner no longer has access to its organization" : "the token is invalid, disabled, or expired";
562
+ const rejection = new ApiTokenRejectedError(
563
+ `API token exchange failed: ${reason}. Create a new API token in MySpec and write it to the apiToken field of your credentials file.`
564
+ );
565
+ this.apiTokenRejection = rejection;
566
+ throw rejection;
567
+ }
568
+ if (!response.ok) {
569
+ const body = await response.text().catch(() => "");
570
+ throw new HttpStatusError(
571
+ response.status,
572
+ body,
573
+ `Token exchange failed: HTTP ${String(response.status)}`
574
+ );
575
+ }
576
+ const payload = await response.json().catch(() => null);
577
+ if (!payload || typeof payload.accessToken !== "string" || typeof payload.expiresIn !== "number") {
578
+ throw new HttpStatusError(
579
+ response.status,
580
+ "",
581
+ "Token exchange response was malformed."
582
+ );
583
+ }
584
+ const next = {
585
+ ...creds,
586
+ accessToken: payload.accessToken,
587
+ expiresAt: this.now() + payload.expiresIn * 1e3
588
+ };
589
+ this.cached = next;
590
+ await this.store.save(next);
591
+ return next;
592
+ }
581
593
  async performRefresh(creds) {
582
594
  const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
583
595
  const response = await this.fetchImpl(url, {
@@ -613,9 +625,11 @@ var TokenManager = class {
613
625
  };
614
626
  var RefreshRejectedError = class extends NeedsLoginError {
615
627
  };
628
+ var ApiTokenRejectedError = class extends ConfigError {
629
+ };
616
630
 
617
631
  // src/auth/organization.ts
618
- import readline3 from "readline";
632
+ import readline2 from "readline";
619
633
  function readOrgClaim(accessToken) {
620
634
  const payload = accessToken.split(".")[1];
621
635
  if (payload === void 0) {
@@ -716,7 +730,7 @@ async function promptForOrganization(organizations, deps = {}) {
716
730
  output.write(` ${String(index + 1)}) ${org.name} (${org.slug})
717
731
  `);
718
732
  });
719
- const rl = readline3.createInterface({ input, output });
733
+ const rl = readline2.createInterface({ input, output });
720
734
  try {
721
735
  for (; ; ) {
722
736
  const answer = await new Promise((resolve3) => {
@@ -823,11 +837,11 @@ async function discoverConfig(opts) {
823
837
  } catch {
824
838
  throw discoveryError(endpoint, "response is not valid JSON", hint);
825
839
  }
826
- const platformUrl = optionalString(body.platformUrl);
840
+ const platformUrl = optionalString2(body.platformUrl);
827
841
  if (platformUrl !== void 0) {
828
842
  assertHttpUrl(platformUrl, endpoint, hint);
829
843
  }
830
- const aiAgentMcpReverseUrl = optionalString(body.aiAgentMcpReverseUrl);
844
+ const aiAgentMcpReverseUrl = optionalString2(body.aiAgentMcpReverseUrl);
831
845
  if (aiAgentMcpReverseUrl !== void 0) {
832
846
  assertWebSocketUrl(aiAgentMcpReverseUrl, endpoint, hint);
833
847
  }
@@ -853,7 +867,7 @@ function fetchConfig(endpoint, token, fetchImpl) {
853
867
  headers: { Authorization: `Bearer ${token}` }
854
868
  });
855
869
  }
856
- function optionalString(value) {
870
+ function optionalString2(value) {
857
871
  return typeof value === "string" && value.length > 0 ? value : void 0;
858
872
  }
859
873
  function assertHttpUrl(value, endpoint, hint) {
@@ -891,29 +905,35 @@ function discoveryError(endpoint, reason, hint) {
891
905
  }
892
906
 
893
907
  // src/cli/login.ts
908
+ var PASTE_TIMEOUT_MS = 15 * 6e4;
894
909
  async function runLogin(options, deps = {}) {
895
910
  const config = resolveConfig({ cliUserAuthUrl: options.userAuthUrl });
896
911
  const store = deps.store ?? createFileCredentialsStore();
897
912
  const log = deps.log ?? ((message) => {
898
913
  process.stderr.write(message + "\n");
899
914
  });
900
- const doLogin = deps.doLogin ?? ((cfg) => options.paste ? pasteLogin(cfg) : loopbackLogin({
901
- ...cfg,
902
- openBrowser: async (url) => {
903
- const mod = await import("open");
904
- await mod.default(url);
905
- }
906
- }));
915
+ const openBrowser = async (url) => {
916
+ const mod = await import("open");
917
+ await mod.default(url);
918
+ };
919
+ const loopback = deps.loopback ?? loopbackLogin;
920
+ const doLogin = deps.doLogin ?? ((cfg) => {
921
+ return loopback(
922
+ options.paste ? { ...cfg, remoteBrowser: true, timeoutMs: PASTE_TIMEOUT_MS } : { ...cfg, openBrowser }
923
+ );
924
+ });
907
925
  const credentials = await doLogin({
908
926
  userAuthUrl: config.userAuthUrl,
909
927
  webappUrl: config.webappUrl
910
928
  });
911
929
  const enriched = await discoverAfterLogin(credentials, config.webappUrl, deps.discover, log);
912
- await store.save(enriched);
913
- log(`Signed in as ${enriched.user.email}.`);
930
+ const prior = await store.load().catch(() => null);
931
+ const preserved = prior?.apiToken === void 0 ? enriched : { ...enriched, apiToken: prior.apiToken };
932
+ await store.save(preserved);
933
+ log(`Signed in as ${enriched.user?.email ?? "unknown"}.`);
914
934
  log(`Credentials saved to ${store.path()}`);
915
935
  await ensureActiveOrganization({
916
- credentials: enriched,
936
+ credentials: preserved,
917
937
  webappUrl: config.webappUrl,
918
938
  requestedSlug: options.org,
919
939
  store,
@@ -1046,11 +1066,31 @@ async function runLogout() {
1046
1066
  "Content-Type": "application/json",
1047
1067
  Authorization: `Bearer ${existing.accessToken}`
1048
1068
  },
1049
- body: JSON.stringify({ refreshToken: existing.refreshToken }),
1069
+ // Only session-derived credentials have a server-side session to end.
1070
+ // An API-token credential has none, so there is nothing to revoke here —
1071
+ // revoking the token itself is done from the MySpec web UI.
1072
+ body: JSON.stringify(
1073
+ existing.refreshToken ? { refreshToken: existing.refreshToken } : {}
1074
+ ),
1050
1075
  signal: AbortSignal.timeout(5e3)
1051
1076
  });
1052
1077
  } catch {
1053
1078
  }
1079
+ if (existing.apiToken) {
1080
+ await store.save({
1081
+ apiToken: existing.apiToken,
1082
+ accessToken: "",
1083
+ expiresAt: 0,
1084
+ userAuthUrl: existing.userAuthUrl,
1085
+ ...existing.platformUrl ? { platformUrl: existing.platformUrl } : {},
1086
+ ...existing.webappUrl ? { webappUrl: existing.webappUrl } : {},
1087
+ ...existing.aiAgentMcpReverseUrl ? { aiAgentMcpReverseUrl: existing.aiAgentMcpReverseUrl } : {}
1088
+ });
1089
+ process.stderr.write(
1090
+ "Signed out. The configured API token was kept \u2014 delete the `apiToken` field in ~/.myspec/oauth_creds.json to remove it.\n"
1091
+ );
1092
+ return;
1093
+ }
1054
1094
  await store.clear();
1055
1095
  process.stderr.write("Signed out and cleared local credentials.\n");
1056
1096
  }
@@ -4735,7 +4775,7 @@ Press Ctrl-C to stop. Auto-reconnect enabled.
4735
4775
  const message = err instanceof Error ? err.message : String(err);
4736
4776
  process.stderr.write(
4737
4777
  `myspec-mcp reverse: cannot obtain access token: ${message}
4738
- Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again.
4778
+ Run \`npx @myspec/mcp-server login\`, or configure an API token, and try again.
4739
4779
  `
4740
4780
  );
4741
4781
  return;
@@ -5011,7 +5051,9 @@ function printHelp() {
5011
5051
  " (default) Start MCP server over stdio",
5012
5052
  " serve Start MCP server over stdio",
5013
5053
  " login Sign in via browser (chooser page on the webapp)",
5014
- " login --paste Sign in by pasting a one-time code",
5054
+ " login --paste Same as login, but never auto-opens a browser. Open",
5055
+ " the printed URL yourself; if the page cannot reach this",
5056
+ " CLI it shows a `<code>.<state>` token to paste back.",
5015
5057
  " login --org <slug> Sign in and pin that organization. Tokens only work",
5016
5058
  " when an organization is active; the CLI asks when you",
5017
5059
  " belong to several and this flag is not given.",
@@ -5035,10 +5077,7 @@ function printHelp() {
5035
5077
  "",
5036
5078
  "Environment:",
5037
5079
  " MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
5038
- " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
5039
- " MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
5040
- " fresh access token on first use via this refresh",
5041
- " token."
5080
+ " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)"
5042
5081
  ];
5043
5082
  process.stderr.write(lines.join("\n") + "\n");
5044
5083
  }
@@ -5146,12 +5185,9 @@ async function runReverseCommand(flags) {
5146
5185
  loadStored = () => Promise.resolve(null);
5147
5186
  persist = void 0;
5148
5187
  } else {
5149
- const envConfig = readEnvRefreshConfig();
5150
- const store = createCompositeCredentialsStore({
5151
- fileStore: createFileCredentialsStore(),
5152
- envConfig
5153
- });
5154
- const tokenManager = new TokenManager({ store, envFallback: envConfig });
5188
+ assertNoEnvAccessToken();
5189
+ const store = createFileCredentialsStore();
5190
+ const tokenManager = new TokenManager({ store });
5155
5191
  getAccessToken = () => tokenManager.getValidAccessToken();
5156
5192
  onAuthFailed = async () => {
5157
5193
  await tokenManager.forceRefresh();
@@ -5218,25 +5254,19 @@ function createPlatformUrlResolver(deps) {
5218
5254
  };
5219
5255
  }
5220
5256
  async function runServe(flags) {
5221
- const envConfig = readEnvRefreshConfig();
5222
- const store = createCompositeCredentialsStore({
5223
- fileStore: createFileCredentialsStore(),
5224
- envConfig
5225
- });
5257
+ assertNoEnvAccessToken();
5258
+ const store = createFileCredentialsStore();
5226
5259
  const initial = await store.load();
5227
5260
  if (!initial) {
5228
5261
  process.stderr.write(
5229
- "myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login` (or set MYSPEC_REFRESH_TOKEN) to enable tools.\n"
5262
+ "myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login`, or configure an API token, to enable tools.\n"
5230
5263
  );
5231
5264
  }
5232
5265
  const config = resolveConfig({
5233
5266
  cliUserAuthUrl: flagString(flags, "user-auth-url"),
5234
5267
  storedUserAuthUrl: initial?.userAuthUrl
5235
5268
  });
5236
- const tokenManager = new TokenManager({
5237
- store,
5238
- envFallback: envConfig
5239
- });
5269
+ const tokenManager = new TokenManager({ store });
5240
5270
  const client = new PlatformClient({
5241
5271
  resolveBaseUrl: createPlatformUrlResolver({
5242
5272
  store,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.2.0-next.78",
3
+ "version": "0.2.0-next.80",
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": {