@myspec/mcp-server 0.2.0-next.79 → 0.2.0-next.81

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 +90 -1
  2. package/dist/index.js +161 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,6 +58,96 @@ value, including the dot** and paste it back into the terminal. Two traps:
58
58
  > keeping access until you run `logout`. The CLI prints the same warning with
59
59
  > the actual port, before the URL.
60
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
+ > Needs **`@myspec/mcp-server` 0.2.0 or newer** (`npm view @myspec/mcp-server
68
+ > version`). Earlier builds ignore the `apiToken` field and report that they are
69
+ > not signed in, without mentioning the version.
70
+
71
+ **1. Create the token.** In the MySpec webapp, open the avatar menu → **API
72
+ tokens** → **Create token**. Choose the organization it should act in, whether
73
+ it may write or only read, and an expiry. The token is shown **once**; it
74
+ cannot be retrieved afterwards.
75
+
76
+ **2. Configure it.** Credentials live in two files under `~/.myspec/`, both of
77
+ which the server needs:
78
+
79
+ `~/.myspec/oauth_creds.json` — the secret, **mode 0600** (the store refuses any
80
+ file readable by group or other):
81
+
82
+ ```json
83
+ { "apiToken": "msp_pat_…" }
84
+ ```
85
+
86
+ `~/.myspec/settings.json` — which auth server to talk to:
87
+
88
+ ```json
89
+ { "userAuthUrl": "https://auth.myspec.dev" }
90
+ ```
91
+
92
+ ```bash
93
+ chmod 600 ~/.myspec/oauth_creds.json
94
+ ```
95
+
96
+ `accessToken`, `expiresAt` and the discovered service URLs are filled in
97
+ automatically on first use, and an API-token credential needs no `user` block —
98
+ the owner is resolved server-side at exchange.
99
+
100
+ There is **no environment variable for an API token** — these files are the
101
+ only way to configure one.
102
+
103
+ **3. Verify it.** The token is exchanged for a short-lived access token on
104
+ every run, so a token that works here works in the server:
105
+
106
+ ```bash
107
+ curl -sS -X POST "https://auth.myspec.dev/api/auth/token/exchange" \
108
+ -H "x-api-key: msp_pat_…" -w '\n%{http_code}\n'
109
+ ```
110
+
111
+ `200` with an `accessToken` in the body means it is good. Otherwise:
112
+
113
+ | Status | Meaning |
114
+ |---|---|
115
+ | `401` | Unknown, revoked or expired token — deliberately indistinguishable. Create a new one. |
116
+ | `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. |
117
+ | `429` | Rate limited. `Retry-After` says how long to wait; the body's `scope` says whether the limit was per-address or per-token. |
118
+
119
+ #### How it differs from `login`
120
+
121
+ | | `login` | API token |
122
+ |---|---|---|
123
+ | Needs a browser | Yes | No |
124
+ | Credential rotates | Yes, on every refresh | No |
125
+ | Survives a missed write | No | Yes |
126
+ | Scope | Whatever the session can reach | One organization, fixed at creation |
127
+ | Access | Full | Read-only or read-write, fixed at creation |
128
+
129
+ An API token's organization and access mode cannot be changed after creation —
130
+ create a new token instead. Expiry *can* be extended, from the same page, and
131
+ doing so does not change the token value, so nothing needs reconfiguring.
132
+
133
+ #### Rolling back to a refresh token
134
+
135
+ If a token does not work and you need the deployment running again now:
136
+
137
+ 1. **Delete the `apiToken` field** from `~/.myspec/oauth_creds.json`. Emptying
138
+ it is not enough and neither is adding a refresh token alongside it: when an
139
+ `apiToken` is present it is used exclusively, and a rejected one fails the
140
+ run rather than falling back — silently downgrading to a different identity
141
+ would be worse than stopping.
142
+ 2. Restore the previous credential — restore a backup of the credentials file
143
+ if you took one, or run `login` again.
144
+
145
+ Do **not** use `logout` to roll back. It deletes the whole credentials file,
146
+ API token included.
147
+
148
+ Migrating an existing install off `MYSPEC_REFRESH_TOKEN`? See the
149
+ [migration runbook](../../docs/runbooks/mcp-api-token-migration.md).
150
+
61
151
  ### Active organization
62
152
 
63
153
  Every MySpec access token carries the slug of one **active organization**, and
@@ -136,7 +226,6 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
136
226
  | Variable | Purpose |
137
227
  |---|---|
138
228
  | `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. |
139
- | `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`. |
140
229
  | `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_spec_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
141
230
  | `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
142
231
 
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) {
@@ -284,6 +286,7 @@ function createFileCredentialsStore(paths = {}) {
284
286
  return {
285
287
  accessToken: oauth.accessToken,
286
288
  refreshToken: oauth.refreshToken,
289
+ apiToken: oauth.apiToken,
287
290
  expiresAt: oauth.expiresAt,
288
291
  userAuthUrl: settings.userAuthUrl,
289
292
  platformUrl: settings.platformUrl,
@@ -308,6 +311,10 @@ function createFileCredentialsStore(paths = {}) {
308
311
  {
309
312
  accessToken: creds.accessToken,
310
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,
311
318
  expiresAt: creds.expiresAt,
312
319
  user: creds.user
313
320
  },
@@ -369,64 +376,39 @@ async function unlinkIfExists(target) {
369
376
  }
370
377
  }
371
378
  }
372
- var DEFAULT_USER_AUTH_URL = "https://auth.myspec.dev";
373
- function readEnvRefreshConfig(env = process.env) {
374
- if (env.MYSPEC_ACCESS_TOKEN || env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
375
- throw new ConfigError(
376
- "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"
377
383
  );
378
384
  }
379
- if (!env.MYSPEC_REFRESH_TOKEN) {
380
- return null;
385
+ if (!env.MYSPEC_ACCESS_TOKEN && !env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
386
+ return;
381
387
  }
382
- return {
383
- refreshToken: env.MYSPEC_REFRESH_TOKEN,
384
- userAuthUrl: env.MYSPEC_USER_AUTH_URL ?? DEFAULT_USER_AUTH_URL
385
- };
386
- }
387
- function createCompositeCredentialsStore(opts) {
388
- const { fileStore, envConfig } = opts;
389
- const envRefreshToken = envConfig?.refreshToken;
390
- const envUserAuthUrl = envConfig?.userAuthUrl ?? DEFAULT_USER_AUTH_URL;
391
- return {
392
- path: () => fileStore.path(),
393
- async load() {
394
- const fromFile = await fileStore.load();
395
- if (fromFile) {
396
- return fromFile;
397
- }
398
- if (!envRefreshToken) {
399
- return null;
400
- }
401
- return {
402
- accessToken: "",
403
- refreshToken: envRefreshToken,
404
- expiresAt: 0,
405
- userAuthUrl: envUserAuthUrl,
406
- user: { id: "env", email: "env@mcp" }
407
- };
408
- },
409
- save(creds) {
410
- return fileStore.save(creds);
411
- },
412
- clear() {
413
- return fileStore.clear();
414
- },
415
- envRefreshToken: () => envRefreshToken,
416
- envUserAuthUrl: () => envUserAuthUrl
417
- };
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
+ );
418
391
  }
419
392
  function parseOauthCreds(value) {
420
393
  if (typeof value !== "object" || value === null) {
421
394
  throw new Error("oauth_creds.json is malformed (not an object)");
422
395
  }
423
396
  const v = value;
424
- const accessToken = requireString(v, "accessToken");
425
- const refreshToken = requireString(v, "refreshToken");
426
- 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");
427
406
  const userRaw = v.user;
428
407
  if (typeof userRaw !== "object" || userRaw === null) {
429
- 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 };
430
412
  }
431
413
  const userObj = userRaw;
432
414
  const user = {
@@ -434,7 +416,17 @@ function parseOauthCreds(value) {
434
416
  email: requireString(userObj, "email"),
435
417
  name: typeof userObj.name === "string" ? userObj.name : void 0
436
418
  };
437
- 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;
438
430
  }
439
431
  function parseSettings(value) {
440
432
  if (typeof value !== "object" || value === null) {
@@ -471,14 +463,21 @@ var TokenManager = class {
471
463
  store;
472
464
  fetchImpl;
473
465
  now;
474
- envFallback;
475
466
  cached = null;
476
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;
477
477
  constructor(deps) {
478
478
  this.store = deps.store;
479
479
  this.fetchImpl = deps.fetchImpl ?? fetch;
480
480
  this.now = deps.now ?? Date.now;
481
- this.envFallback = deps.envFallback ?? null;
482
481
  }
483
482
  async getValidAccessToken() {
484
483
  const creds = await this.ensureLoaded();
@@ -521,27 +520,20 @@ var TokenManager = class {
521
520
  return this.refreshInFlight;
522
521
  }
523
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
+ }
524
534
  try {
525
535
  return await this.performRefresh(creds);
526
536
  } catch (err) {
527
- if (err instanceof RefreshRejectedError && this.envFallback && this.envFallback.refreshToken !== creds.refreshToken) {
528
- const bootstrap = {
529
- accessToken: "",
530
- refreshToken: this.envFallback.refreshToken,
531
- expiresAt: 0,
532
- userAuthUrl: this.envFallback.userAuthUrl,
533
- platformUrl: creds.platformUrl,
534
- webappUrl: creds.webappUrl,
535
- aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl,
536
- user: creds.user
537
- };
538
- try {
539
- return await this.performRefresh(bootstrap);
540
- } catch (retryErr) {
541
- await this.invalidateOnAuthFailure(retryErr);
542
- throw retryErr;
543
- }
544
- }
545
537
  await this.invalidateOnAuthFailure(err);
546
538
  throw err;
547
539
  }
@@ -552,6 +544,60 @@ var TokenManager = class {
552
544
  await this.store.clear();
553
545
  }
554
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.status === 404) {
569
+ const body = await response.text().catch(() => "");
570
+ throw new HttpStatusError(
571
+ response.status,
572
+ body,
573
+ `Token exchange failed: ${url} returned 404, so no exchange endpoint is there. This URL comes from userAuthUrl in ~/.myspec/settings.json \u2014 check it names the environment the token was created in, and rewrite it with \`npx @myspec/mcp-server login --user-auth-url <url>\`. Note that --user-auth-url and MYSPEC_USER_AUTH_URL do not redirect the exchange on their own, and userAuthUrl in oauth_creds.json is ignored entirely.`
574
+ );
575
+ }
576
+ if (!response.ok) {
577
+ const body = await response.text().catch(() => "");
578
+ throw new HttpStatusError(
579
+ response.status,
580
+ body,
581
+ `Token exchange failed: HTTP ${String(response.status)} from ${url}`
582
+ );
583
+ }
584
+ const payload = await response.json().catch(() => null);
585
+ if (!payload || typeof payload.accessToken !== "string" || typeof payload.expiresIn !== "number") {
586
+ throw new HttpStatusError(
587
+ response.status,
588
+ "",
589
+ "Token exchange response was malformed."
590
+ );
591
+ }
592
+ const next = {
593
+ ...creds,
594
+ accessToken: payload.accessToken,
595
+ expiresAt: this.now() + payload.expiresIn * 1e3
596
+ };
597
+ this.cached = next;
598
+ await this.store.save(next);
599
+ return next;
600
+ }
555
601
  async performRefresh(creds) {
556
602
  const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
557
603
  const response = await this.fetchImpl(url, {
@@ -587,6 +633,8 @@ var TokenManager = class {
587
633
  };
588
634
  var RefreshRejectedError = class extends NeedsLoginError {
589
635
  };
636
+ var ApiTokenRejectedError = class extends ConfigError {
637
+ };
590
638
 
591
639
  // src/auth/organization.ts
592
640
  import readline2 from "readline";
@@ -797,11 +845,11 @@ async function discoverConfig(opts) {
797
845
  } catch {
798
846
  throw discoveryError(endpoint, "response is not valid JSON", hint);
799
847
  }
800
- const platformUrl = optionalString(body.platformUrl);
848
+ const platformUrl = optionalString2(body.platformUrl);
801
849
  if (platformUrl !== void 0) {
802
850
  assertHttpUrl(platformUrl, endpoint, hint);
803
851
  }
804
- const aiAgentMcpReverseUrl = optionalString(body.aiAgentMcpReverseUrl);
852
+ const aiAgentMcpReverseUrl = optionalString2(body.aiAgentMcpReverseUrl);
805
853
  if (aiAgentMcpReverseUrl !== void 0) {
806
854
  assertWebSocketUrl(aiAgentMcpReverseUrl, endpoint, hint);
807
855
  }
@@ -827,7 +875,7 @@ function fetchConfig(endpoint, token, fetchImpl) {
827
875
  headers: { Authorization: `Bearer ${token}` }
828
876
  });
829
877
  }
830
- function optionalString(value) {
878
+ function optionalString2(value) {
831
879
  return typeof value === "string" && value.length > 0 ? value : void 0;
832
880
  }
833
881
  function assertHttpUrl(value, endpoint, hint) {
@@ -887,11 +935,13 @@ async function runLogin(options, deps = {}) {
887
935
  webappUrl: config.webappUrl
888
936
  });
889
937
  const enriched = await discoverAfterLogin(credentials, config.webappUrl, deps.discover, log);
890
- await store.save(enriched);
891
- log(`Signed in as ${enriched.user.email}.`);
938
+ const prior = await store.load().catch(() => null);
939
+ const preserved = prior?.apiToken === void 0 ? enriched : { ...enriched, apiToken: prior.apiToken };
940
+ await store.save(preserved);
941
+ log(`Signed in as ${enriched.user?.email ?? "unknown"}.`);
892
942
  log(`Credentials saved to ${store.path()}`);
893
943
  await ensureActiveOrganization({
894
- credentials: enriched,
944
+ credentials: preserved,
895
945
  webappUrl: config.webappUrl,
896
946
  requestedSlug: options.org,
897
947
  store,
@@ -1024,11 +1074,31 @@ async function runLogout() {
1024
1074
  "Content-Type": "application/json",
1025
1075
  Authorization: `Bearer ${existing.accessToken}`
1026
1076
  },
1027
- body: JSON.stringify({ refreshToken: existing.refreshToken }),
1077
+ // Only session-derived credentials have a server-side session to end.
1078
+ // An API-token credential has none, so there is nothing to revoke here —
1079
+ // revoking the token itself is done from the MySpec web UI.
1080
+ body: JSON.stringify(
1081
+ existing.refreshToken ? { refreshToken: existing.refreshToken } : {}
1082
+ ),
1028
1083
  signal: AbortSignal.timeout(5e3)
1029
1084
  });
1030
1085
  } catch {
1031
1086
  }
1087
+ if (existing.apiToken) {
1088
+ await store.save({
1089
+ apiToken: existing.apiToken,
1090
+ accessToken: "",
1091
+ expiresAt: 0,
1092
+ userAuthUrl: existing.userAuthUrl,
1093
+ ...existing.platformUrl ? { platformUrl: existing.platformUrl } : {},
1094
+ ...existing.webappUrl ? { webappUrl: existing.webappUrl } : {},
1095
+ ...existing.aiAgentMcpReverseUrl ? { aiAgentMcpReverseUrl: existing.aiAgentMcpReverseUrl } : {}
1096
+ });
1097
+ process.stderr.write(
1098
+ "Signed out. The configured API token was kept \u2014 delete the `apiToken` field in ~/.myspec/oauth_creds.json to remove it.\n"
1099
+ );
1100
+ return;
1101
+ }
1032
1102
  await store.clear();
1033
1103
  process.stderr.write("Signed out and cleared local credentials.\n");
1034
1104
  }
@@ -4713,7 +4783,7 @@ Press Ctrl-C to stop. Auto-reconnect enabled.
4713
4783
  const message = err instanceof Error ? err.message : String(err);
4714
4784
  process.stderr.write(
4715
4785
  `myspec-mcp reverse: cannot obtain access token: ${message}
4716
- Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again.
4786
+ Run \`npx @myspec/mcp-server login\`, or configure an API token, and try again.
4717
4787
  `
4718
4788
  );
4719
4789
  return;
@@ -4995,13 +5065,16 @@ function printHelp() {
4995
5065
  " login --org <slug> Sign in and pin that organization. Tokens only work",
4996
5066
  " when an organization is active; the CLI asks when you",
4997
5067
  " belong to several and this flag is not given.",
4998
- " logout Revoke refresh token and clear local credentials",
5068
+ " logout Sign out and clear the session credentials. A",
5069
+ " configured apiToken is KEPT, so an unattended",
5070
+ " install keeps working.",
4999
5071
  " reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
5000
5072
  " The agent WebSocket URL is discovered from the webapp",
5001
5073
  " (GET /api/v1/config) using your saved credentials;",
5002
5074
  " override with --agent-url <url> or MYSPEC_AI_AGENT_WS_URL.",
5003
- " Uses the saved refresh_token from `npx @myspec/mcp-server login`",
5004
- " to obtain & auto-refresh access tokens. Override with",
5075
+ " Uses the apiToken from ~/.myspec/oauth_creds.json if one",
5076
+ " is set, otherwise the refresh_token saved by `login`, to",
5077
+ " obtain & auto-refresh access tokens. Override with",
5005
5078
  " --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
5006
5079
  " --version Print version",
5007
5080
  " --help Print this help",
@@ -5015,10 +5088,7 @@ function printHelp() {
5015
5088
  "",
5016
5089
  "Environment:",
5017
5090
  " MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
5018
- " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
5019
- " MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
5020
- " fresh access token on first use via this refresh",
5021
- " token."
5091
+ " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)"
5022
5092
  ];
5023
5093
  process.stderr.write(lines.join("\n") + "\n");
5024
5094
  }
@@ -5126,12 +5196,9 @@ async function runReverseCommand(flags) {
5126
5196
  loadStored = () => Promise.resolve(null);
5127
5197
  persist = void 0;
5128
5198
  } else {
5129
- const envConfig = readEnvRefreshConfig();
5130
- const store = createCompositeCredentialsStore({
5131
- fileStore: createFileCredentialsStore(),
5132
- envConfig
5133
- });
5134
- const tokenManager = new TokenManager({ store, envFallback: envConfig });
5199
+ assertNoEnvAccessToken();
5200
+ const store = createFileCredentialsStore();
5201
+ const tokenManager = new TokenManager({ store });
5135
5202
  getAccessToken = () => tokenManager.getValidAccessToken();
5136
5203
  onAuthFailed = async () => {
5137
5204
  await tokenManager.forceRefresh();
@@ -5198,25 +5265,19 @@ function createPlatformUrlResolver(deps) {
5198
5265
  };
5199
5266
  }
5200
5267
  async function runServe(flags) {
5201
- const envConfig = readEnvRefreshConfig();
5202
- const store = createCompositeCredentialsStore({
5203
- fileStore: createFileCredentialsStore(),
5204
- envConfig
5205
- });
5268
+ assertNoEnvAccessToken();
5269
+ const store = createFileCredentialsStore();
5206
5270
  const initial = await store.load();
5207
5271
  if (!initial) {
5208
5272
  process.stderr.write(
5209
- "myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login` (or set MYSPEC_REFRESH_TOKEN) to enable tools.\n"
5273
+ "myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login`, or configure an API token, to enable tools.\n"
5210
5274
  );
5211
5275
  }
5212
5276
  const config = resolveConfig({
5213
5277
  cliUserAuthUrl: flagString(flags, "user-auth-url"),
5214
5278
  storedUserAuthUrl: initial?.userAuthUrl
5215
5279
  });
5216
- const tokenManager = new TokenManager({
5217
- store,
5218
- envFallback: envConfig
5219
- });
5280
+ const tokenManager = new TokenManager({ store });
5220
5281
  const client = new PlatformClient({
5221
5282
  resolveBaseUrl: createPlatformUrlResolver({
5222
5283
  store,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.2.0-next.79",
3
+ "version": "0.2.0-next.81",
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": {