@myspec/mcp-server 0.2.1-next.87 → 0.3.0-next.89

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 +57 -10
  2. package/dist/index.js +216 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,8 +64,9 @@ value, including the dot** and paste it back into the terminal. Two traps:
64
64
  without a person present (a server, a container, a CI job, a shared machine),
65
65
  use an API token instead.
66
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
67
+ > `MYSPEC_API_TOKEN` needs **`@myspec/mcp-server` 0.3.0 or newer**; the
68
+ > `apiToken` file field needs **0.2.0 or newer** (`npm view @myspec/mcp-server
69
+ > version`). Earlier builds ignore the setting entirely and report that they are
69
70
  > not signed in, without mentioning the version.
70
71
 
71
72
  **1. Create the token.** In the MySpec webapp, open the avatar menu → **API
@@ -73,8 +74,48 @@ tokens** → **Create token**. Choose the organization it should act in, whether
73
74
  it may write or only read, and an expiry. The token is shown **once**; it
74
75
  cannot be retrieved afterwards.
75
76
 
76
- **2. Configure it.** Credentials live in two files under `~/.myspec/`, both of
77
- which the server needs:
77
+ **2. Configure it.** Either way works; the environment variable needs no files.
78
+
79
+ #### Option A — `MYSPEC_API_TOKEN` (recommended)
80
+
81
+ Put it in your MCP client's `env` block:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "myspec": {
87
+ "command": "npx",
88
+ "args": ["-y", "@myspec/mcp-server"],
89
+ "env": { "MYSPEC_API_TOKEN": "msp_pat_…" }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ or register it in one command:
96
+
97
+ ```bash
98
+ claude mcp add --scope user myspec \
99
+ --env MYSPEC_API_TOKEN=msp_pat_… \
100
+ -- npx -y @myspec/mcp-server
101
+ ```
102
+
103
+ Nothing else is required — no `login`, no files. **For a non-production token,
104
+ set `MYSPEC_USER_AUTH_URL` too** (e.g. `https://dev-auth.myspec.dev`); a dev
105
+ token against the production default fails with a bare 401 that reads like a bad
106
+ token.
107
+
108
+ The server writes `~/.myspec/settings.json` so restarts skip endpoint discovery.
109
+ It never writes the token to disk, so unsetting the variable removes the
110
+ credential and leaves any saved sign-in exactly as it was.
111
+
112
+ > One trade-off: an environment variable is visible in process listings and is
113
+ > inherited by child processes. On a shared machine, Option B's `0600` file is
114
+ > the tighter choice.
115
+
116
+ #### Option B — credentials files
117
+
118
+ Two files under `~/.myspec/`, both of which the server needs:
78
119
 
79
120
  `~/.myspec/oauth_creds.json` — the secret, **mode 0600** (the store refuses any
80
121
  file readable by group or other):
@@ -97,8 +138,11 @@ chmod 600 ~/.myspec/oauth_creds.json
97
138
  automatically on first use, and an API-token credential needs no `user` block —
98
139
  the owner is resolved server-side at exchange.
99
140
 
100
- There is **no environment variable for an API token** — these files are the
101
- only way to configure one.
141
+ #### Precedence
142
+
143
+ `MYSPEC_API_TOKEN` > `apiToken` in `oauth_creds.json` > the refresh token saved
144
+ by `login`. Whichever API token wins is used **exclusively**: if it is rejected
145
+ the run fails rather than quietly falling back to another identity.
102
146
 
103
147
  **3. Verify it.** The token is exchanged for a short-lived access token on
104
148
  every run, so a token that works here works in the server:
@@ -134,12 +178,14 @@ doing so does not change the token value, so nothing needs reconfiguring.
134
178
 
135
179
  If a token does not work and you need the deployment running again now:
136
180
 
137
- 1. **Delete the `apiToken` field** from `~/.myspec/oauth_creds.json`. Emptying
181
+ 1. **Unset `MYSPEC_API_TOKEN`** if it is set — it wins over the file, so
182
+ editing the file changes nothing while the variable is in place.
183
+ 2. **Delete the `apiToken` field** from `~/.myspec/oauth_creds.json`. Emptying
138
184
  it is not enough and neither is adding a refresh token alongside it: when an
139
185
  `apiToken` is present it is used exclusively, and a rejected one fails the
140
186
  run rather than falling back — silently downgrading to a different identity
141
187
  would be worse than stopping.
142
- 2. Restore the previous credential — restore a backup of the credentials file
188
+ 3. Restore the previous credential — restore a backup of the credentials file
143
189
  if you took one, or run `login` again.
144
190
 
145
191
  Do **not** use `logout` to roll back. It deletes the whole credentials file,
@@ -212,12 +258,12 @@ npx @myspec/mcp-server reverse --root . --agent-url ws://localhost:3001/mcp/reve
212
258
  |---|---|
213
259
  | `--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. |
214
260
  | `--agent-url <url>` / `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL override; skips webapp discovery entirely. |
215
- | `--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. |
261
+ | `--access-token <jwt>` / `MYSPEC_ACCESS_TOKEN` | Use a static access token for local testing instead of the saved credentials. It overrides `MYSPEC_API_TOKEN` too (with a warning) and cannot be refreshed. Otherwise `reverse` uses `MYSPEC_API_TOKEN`, else the saved `apiToken`, else the refresh token from `npx @myspec/mcp-server login`, and auto-refreshes. |
216
262
 
217
263
  Local state is stored under `~/.myspec/` (alongside the on-disk cache root), split into two files:
218
264
 
219
265
  - `settings.json` — non-secret config: the auth-server URL you logged in with, plus the derived webapp URL and the discovered platform / ai-agent URLs (refreshed on every successful discovery).
220
- - `oauth_creds.json` — the OAuth tokens (access/refresh/expiry) and your user identity, written mode `0600`.
266
+ - `oauth_creds.json` — the OAuth tokens (access/refresh/expiry) and your user identity, written mode `0600`. A session configured through `MYSPEC_API_TOKEN` never writes this file — it writes `settings.json` only.
221
267
 
222
268
  `logout` removes `oauth_creds.json`; `settings.json` is left in place.
223
269
 
@@ -225,6 +271,7 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
225
271
 
226
272
  | Variable | Purpose |
227
273
  |---|---|
274
+ | `MYSPEC_API_TOKEN` | Long-lived MySpec API token (`msp_pat_…`) for unattended use. Takes precedence over anything in `~/.myspec/oauth_creds.json` and is never written to disk. Needs 0.3.0+. |
228
275
  | `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. |
229
276
  | `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_spec_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
230
277
  | `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import readline from "readline";
11
11
 
12
12
  // src/errors.ts
13
13
  var NeedsLoginError = class extends Error {
14
- constructor(message = "Not authenticated. Run `npx @myspec/mcp-server login` to sign in.") {
14
+ constructor(message = "Not authenticated. Set MYSPEC_API_TOKEN to a MySpec API token, or run `npx @myspec/mcp-server login` to sign in.") {
15
15
  super(message);
16
16
  this.name = "NeedsLoginError";
17
17
  }
@@ -296,16 +296,7 @@ function createFileCredentialsStore(paths = {}) {
296
296
  };
297
297
  },
298
298
  async save(creds) {
299
- await writeJsonFile(
300
- settingsPath,
301
- {
302
- userAuthUrl: creds.userAuthUrl,
303
- platformUrl: creds.platformUrl,
304
- webappUrl: creds.webappUrl,
305
- aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl
306
- },
307
- { secret: false }
308
- );
299
+ await writeSettingsFile(settingsPath, creds);
309
300
  await writeJsonFile(
310
301
  oauthCredsPath,
311
302
  {
@@ -326,6 +317,18 @@ function createFileCredentialsStore(paths = {}) {
326
317
  }
327
318
  };
328
319
  }
320
+ async function writeSettingsFile(target, creds) {
321
+ await writeJsonFile(
322
+ target,
323
+ {
324
+ userAuthUrl: creds.userAuthUrl,
325
+ platformUrl: creds.platformUrl,
326
+ webappUrl: creds.webappUrl,
327
+ aiAgentMcpReverseUrl: creds.aiAgentMcpReverseUrl
328
+ },
329
+ { secret: false }
330
+ );
331
+ }
329
332
  async function loadOauthCreds(target) {
330
333
  try {
331
334
  if (process.platform !== "win32") {
@@ -376,17 +379,133 @@ async function unlinkIfExists(target) {
376
379
  }
377
380
  }
378
381
  }
382
+ var API_TOKEN_PREFIX = "msp_pat_";
383
+ function isUnusableAsHeaderValue(value) {
384
+ for (const char of value) {
385
+ const code = char.codePointAt(0) ?? 0;
386
+ if (code <= 32 || code === 127) {
387
+ return true;
388
+ }
389
+ }
390
+ return false;
391
+ }
392
+ function defaultWarn(message) {
393
+ process.stderr.write(message + "\n");
394
+ }
395
+ function readEnvApiToken(userAuthUrl, env = process.env, warn = defaultWarn) {
396
+ const raw = env.MYSPEC_API_TOKEN;
397
+ if (!raw) {
398
+ return null;
399
+ }
400
+ const apiToken = raw.trim();
401
+ if (!apiToken) {
402
+ return null;
403
+ }
404
+ if (isUnusableAsHeaderValue(apiToken)) {
405
+ warn(
406
+ "myspec-mcp: MYSPEC_API_TOKEN contains whitespace or control characters and cannot be sent as a header \u2014 it is being ignored. Check for a line break in the value (a wrapped paste into .mcp.json is the usual cause)."
407
+ );
408
+ return null;
409
+ }
410
+ return { apiToken, userAuthUrl };
411
+ }
412
+ function hasEnvApiToken(env = process.env) {
413
+ return Boolean(env.MYSPEC_API_TOKEN);
414
+ }
415
+ function createEnvApiTokenStore(opts) {
416
+ const { fileStore, envConfig } = opts;
417
+ if (!envConfig) {
418
+ return fileStore;
419
+ }
420
+ const settingsPath = opts.settingsPath ?? defaultSettingsPath();
421
+ const warn = opts.warn ?? defaultWarn;
422
+ const warned = /* @__PURE__ */ new Set();
423
+ const warnOnce = (key, message) => {
424
+ if (warned.has(key)) {
425
+ return;
426
+ }
427
+ warned.add(key);
428
+ warn(message);
429
+ };
430
+ if (!envConfig.apiToken.startsWith(API_TOKEN_PREFIX)) {
431
+ warnOnce(
432
+ "prefix",
433
+ `myspec-mcp: MYSPEC_API_TOKEN does not start with \`${API_TOKEN_PREFIX}\`, so it is probably not a MySpec API token. Create one in the MySpec webapp under the avatar menu, API tokens.`
434
+ );
435
+ }
436
+ return {
437
+ // Not a filesystem path — the credential has no file. Only ever printed.
438
+ path: () => "MYSPEC_API_TOKEN (environment)",
439
+ async load() {
440
+ const fromFile = await fileStore.load().catch((err) => {
441
+ const message = err instanceof Error ? err.message : String(err);
442
+ warnOnce(
443
+ "file-unreadable",
444
+ `myspec-mcp: using MYSPEC_API_TOKEN; the existing credentials file could not be read (${message})`
445
+ );
446
+ return null;
447
+ });
448
+ if (fromFile && (fromFile.apiToken !== void 0 || fromFile.refreshToken !== void 0)) {
449
+ warnOnce(
450
+ "shadowed",
451
+ `myspec-mcp: MYSPEC_API_TOKEN is set and takes precedence over the credentials in ${fileStore.path()}.`
452
+ );
453
+ }
454
+ const cached = fromFile ?? await loadSettings(settingsPath).catch(() => null);
455
+ const sameEnvironment = cached?.userAuthUrl === envConfig.userAuthUrl;
456
+ return {
457
+ accessToken: "",
458
+ apiToken: envConfig.apiToken,
459
+ expiresAt: 0,
460
+ userAuthUrl: envConfig.userAuthUrl,
461
+ // `sameEnvironment` implies `cached` is non-null: a null cached record
462
+ // compares undefined against a string.
463
+ ...sameEnvironment && cached.platformUrl ? { platformUrl: cached.platformUrl } : {},
464
+ ...sameEnvironment && cached.webappUrl ? { webappUrl: cached.webappUrl } : {},
465
+ ...sameEnvironment && cached.aiAgentMcpReverseUrl ? { aiAgentMcpReverseUrl: cached.aiAgentMcpReverseUrl } : {}
466
+ };
467
+ },
468
+ async save(creds) {
469
+ let existing;
470
+ try {
471
+ existing = await loadSettings(settingsPath);
472
+ } catch (err) {
473
+ const message = err instanceof Error ? err.message : String(err);
474
+ warnOnce(
475
+ "settings-unreadable",
476
+ `myspec-mcp: leaving ${settingsPath} alone \u2014 it could not be read (${message}).`
477
+ );
478
+ return;
479
+ }
480
+ if (existing && existing.userAuthUrl !== envConfig.userAuthUrl) {
481
+ warnOnce(
482
+ "settings-mismatch",
483
+ `myspec-mcp: leaving ${settingsPath} alone \u2014 it names ${existing.userAuthUrl}, but MYSPEC_API_TOKEN is being used against ${envConfig.userAuthUrl}. Overwriting it would break the sign-in saved for that environment.`
484
+ );
485
+ return;
486
+ }
487
+ await writeSettingsFile(settingsPath, creds);
488
+ },
489
+ clear() {
490
+ warnOnce(
491
+ "clear",
492
+ "myspec-mcp: credentials come from MYSPEC_API_TOKEN; nothing was cleared. Unset the variable to stop using it."
493
+ );
494
+ return Promise.resolve();
495
+ }
496
+ };
497
+ }
379
498
  function assertNoEnvAccessToken(env = process.env) {
380
499
  if (env.MYSPEC_REFRESH_TOKEN) {
381
500
  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"
501
+ "myspec-mcp: MYSPEC_REFRESH_TOKEN is set but no longer supported and is being ignored. Set MYSPEC_API_TOKEN to a MySpec API token instead (or an `apiToken` in ~/.myspec/oauth_creds.json), or run `npx @myspec/mcp-server login`. See docs/runbooks/mcp-api-token-migration.md\n"
383
502
  );
384
503
  }
385
504
  if (!env.MYSPEC_ACCESS_TOKEN && !env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
386
505
  return;
387
506
  }
388
507
  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."
508
+ "MYSPEC_ACCESS_TOKEN / MYSPEC_ACCESS_TOKEN_EXPIRES_AT are not supported. Set MYSPEC_API_TOKEN to a long-lived MySpec API token instead (or put one in the `apiToken` field of your credentials file), or run `npx @myspec/mcp-server login`, and unset these variables."
390
509
  );
391
510
  }
392
511
  function parseOauthCreds(value) {
@@ -528,7 +647,7 @@ var TokenManager = class {
528
647
  }
529
648
  if (!creds.refreshToken) {
530
649
  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."
650
+ "No credential is configured. Either set MYSPEC_API_TOKEN to a long-lived API token (or put one in the `apiToken` field of your credentials file) \u2014 create one in the MySpec webapp under the avatar menu, API tokens \u2014 or run `npx @myspec/mcp-server login` to sign in interactively."
532
651
  );
533
652
  }
534
653
  try {
@@ -560,7 +679,7 @@ var TokenManager = class {
560
679
  if (response.status === 401 || response.status === 403) {
561
680
  const reason = response.status === 403 ? "the token owner no longer has access to its organization" : "the token is invalid, disabled, or expired";
562
681
  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.`
682
+ `API token exchange failed: ${reason}. Create a new API token in MySpec and set it as MYSPEC_API_TOKEN (or write it to the apiToken field of your credentials file).`
564
683
  );
565
684
  this.apiTokenRejection = rejection;
566
685
  throw rejection;
@@ -570,7 +689,7 @@ var TokenManager = class {
570
689
  throw new HttpStatusError(
571
690
  response.status,
572
691
  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.`
692
+ `Token exchange failed: ${url} returned 404, so no exchange endpoint is there. Check that URL names the environment the token was created in. With MYSPEC_API_TOKEN it comes from MYSPEC_USER_AUTH_URL (or --user-auth-url), defaulting to https://auth.myspec.dev. Otherwise it comes from userAuthUrl in ~/.myspec/settings.json, which only \`npx @myspec/mcp-server login --user-auth-url <url>\` rewrites \u2014 the flag and the variable do not redirect a file-configured exchange on their own, and userAuthUrl in oauth_creds.json is ignored entirely.`
574
693
  );
575
694
  }
576
695
  if (!response.ok) {
@@ -920,6 +1039,11 @@ async function runLogin(options, deps = {}) {
920
1039
  const log = deps.log ?? ((message) => {
921
1040
  process.stderr.write(message + "\n");
922
1041
  });
1042
+ if (hasEnvApiToken(deps.env)) {
1043
+ log(
1044
+ "myspec-mcp: MYSPEC_API_TOKEN is set and takes precedence \u2014 this login will not take effect until you unset it."
1045
+ );
1046
+ }
923
1047
  const openBrowser = async (url) => {
924
1048
  const mod = await import("open");
925
1049
  await mod.default(url);
@@ -1059,11 +1183,15 @@ async function discoverAfterLogin(credentials, webappUrl, discover = discoverCon
1059
1183
  }
1060
1184
 
1061
1185
  // src/cli/logout.ts
1062
- async function runLogout() {
1186
+ var ENV_TOKEN_STILL_SET = "MYSPEC_API_TOKEN is still set in this environment and will keep being used \u2014 remove it from your MCP client config (or shell) to stop.\n";
1187
+ async function runLogout(env = process.env) {
1063
1188
  const store = createFileCredentialsStore();
1189
+ const usingEnvToken = hasEnvApiToken(env);
1064
1190
  const existing = await store.load();
1065
1191
  if (!existing) {
1066
- process.stderr.write("No credentials to clear.\n");
1192
+ process.stderr.write(
1193
+ usingEnvToken ? `No credentials file to clear. ${ENV_TOKEN_STILL_SET}` : "No credentials to clear.\n"
1194
+ );
1067
1195
  return;
1068
1196
  }
1069
1197
  const config = resolveConfig({ storedUserAuthUrl: existing.userAuthUrl });
@@ -1097,10 +1225,16 @@ async function runLogout() {
1097
1225
  process.stderr.write(
1098
1226
  "Signed out. The configured API token was kept \u2014 delete the `apiToken` field in ~/.myspec/oauth_creds.json to remove it.\n"
1099
1227
  );
1228
+ if (usingEnvToken) {
1229
+ process.stderr.write(ENV_TOKEN_STILL_SET);
1230
+ }
1100
1231
  return;
1101
1232
  }
1102
1233
  await store.clear();
1103
1234
  process.stderr.write("Signed out and cleared local credentials.\n");
1235
+ if (usingEnvToken) {
1236
+ process.stderr.write(ENV_TOKEN_STILL_SET);
1237
+ }
1104
1238
  }
1105
1239
 
1106
1240
  // src/platform/client.ts
@@ -5155,10 +5289,11 @@ function printHelp() {
5155
5289
  " The agent WebSocket URL is discovered from the webapp",
5156
5290
  " (GET /api/v1/config) using your saved credentials;",
5157
5291
  " override with --agent-url <url> or MYSPEC_AI_AGENT_WS_URL.",
5158
- " Uses the apiToken from ~/.myspec/oauth_creds.json if one",
5159
- " is set, otherwise the refresh_token saved by `login`, to",
5160
- " obtain & auto-refresh access tokens. Override with",
5161
- " --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
5292
+ " Uses MYSPEC_API_TOKEN if set, else the apiToken from",
5293
+ " ~/.myspec/oauth_creds.json, else the refresh_token saved",
5294
+ " by `login`, to obtain & auto-refresh access tokens.",
5295
+ " Override with --access-token <jwt> or MYSPEC_ACCESS_TOKEN",
5296
+ " for local testing.",
5162
5297
  " --version Print version",
5163
5298
  " --help Print this help",
5164
5299
  "",
@@ -5170,6 +5305,11 @@ function printHelp() {
5170
5305
  " via the webapp and saved to ~/.myspec/settings.json.",
5171
5306
  "",
5172
5307
  "Environment:",
5308
+ " MYSPEC_API_TOKEN Long-lived MySpec API token (msp_pat_...). Set it in your",
5309
+ " MCP client's `env` block to configure the server with no",
5310
+ " credential files. Takes precedence over anything saved by",
5311
+ " `login`; create one in the webapp under avatar menu > API",
5312
+ " tokens.",
5173
5313
  " MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
5174
5314
  " MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)"
5175
5315
  ];
@@ -5236,7 +5376,11 @@ async function resolveReverseAgentUrl(sources) {
5236
5376
  return { agentUrl: explicitAgentUrl };
5237
5377
  }
5238
5378
  const stored = await sources.loadStored().catch(() => null);
5239
- const config = resolveConfig({ env, storedUserAuthUrl: stored?.userAuthUrl });
5379
+ const config = resolveConfig({
5380
+ env,
5381
+ cliUserAuthUrl: sources.cliUserAuthUrl,
5382
+ storedUserAuthUrl: stored?.userAuthUrl
5383
+ });
5240
5384
  const endpoint = configEndpoint(config.webappUrl);
5241
5385
  try {
5242
5386
  const discovered = await sources.discover(config.webappUrl);
@@ -5263,9 +5407,41 @@ async function resolveReverseAgentUrl(sources) {
5263
5407
  throw err;
5264
5408
  }
5265
5409
  }
5410
+ async function buildCredentialsStore(opts = {}) {
5411
+ const env = opts.env ?? process.env;
5412
+ const fileStore = opts.fileStore ?? createFileCredentialsStore();
5413
+ const usingEnvApiToken = hasEnvApiToken(env);
5414
+ const tolerate = opts.toleratesUnreadableFile ?? usingEnvApiToken;
5415
+ const stored = tolerate ? await fileStore.load().catch(() => null) : await fileStore.load();
5416
+ const config = resolveConfig({
5417
+ env,
5418
+ cliUserAuthUrl: opts.cliUserAuthUrl,
5419
+ storedUserAuthUrl: stored?.userAuthUrl
5420
+ });
5421
+ return {
5422
+ store: createEnvApiTokenStore({
5423
+ fileStore,
5424
+ envConfig: readEnvApiToken(config.userAuthUrl, env)
5425
+ }),
5426
+ config,
5427
+ usingEnvApiToken
5428
+ };
5429
+ }
5430
+ function warnIfStaticTokenShadowsApiToken(inlineToken, env = process.env, warn = (message) => {
5431
+ process.stderr.write(message + "\n");
5432
+ }) {
5433
+ if (!inlineToken || !hasEnvApiToken(env)) {
5434
+ return;
5435
+ }
5436
+ warn(
5437
+ "myspec-mcp reverse: MYSPEC_ACCESS_TOKEN (or --access-token) overrides MYSPEC_API_TOKEN; using the static access token, which cannot be refreshed."
5438
+ );
5439
+ }
5266
5440
  async function runReverseCommand(flags) {
5267
5441
  const root = flagString(flags, "root") ?? process.cwd();
5442
+ const cliUserAuthUrl = flagString(flags, "user-auth-url");
5268
5443
  const inlineToken = flagString(flags, "access-token") ?? process.env.MYSPEC_ACCESS_TOKEN;
5444
+ warnIfStaticTokenShadowsApiToken(inlineToken);
5269
5445
  let getAccessToken;
5270
5446
  let onAuthFailed;
5271
5447
  let forceRefresh;
@@ -5280,7 +5456,11 @@ async function runReverseCommand(flags) {
5280
5456
  persist = void 0;
5281
5457
  } else {
5282
5458
  assertNoEnvAccessToken();
5283
- const store = createFileCredentialsStore();
5459
+ const built = await buildCredentialsStore({
5460
+ cliUserAuthUrl,
5461
+ toleratesUnreadableFile: true
5462
+ });
5463
+ const store = built.store;
5284
5464
  const tokenManager = new TokenManager({ store });
5285
5465
  getAccessToken = () => tokenManager.getValidAccessToken();
5286
5466
  onAuthFailed = async () => {
@@ -5292,6 +5472,10 @@ async function runReverseCommand(flags) {
5292
5472
  }
5293
5473
  const resolved = await resolveReverseAgentUrl({
5294
5474
  flagAgentUrl: flagString(flags, "agent-url"),
5475
+ // Passed through so --user-auth-url means the same thing on both paths.
5476
+ // buildCredentialsStore validates it above; without this, discovery would
5477
+ // keep using settings.json and the flag would be validated but ignored.
5478
+ cliUserAuthUrl,
5295
5479
  loadStored,
5296
5480
  persist,
5297
5481
  discover: (webappUrl) => {
@@ -5349,17 +5533,15 @@ function createPlatformUrlResolver(deps) {
5349
5533
  }
5350
5534
  async function runServe(flags) {
5351
5535
  assertNoEnvAccessToken();
5352
- const store = createFileCredentialsStore();
5536
+ const { store, config } = await buildCredentialsStore({
5537
+ cliUserAuthUrl: flagString(flags, "user-auth-url")
5538
+ });
5353
5539
  const initial = await store.load();
5354
5540
  if (!initial) {
5355
5541
  process.stderr.write(
5356
- "myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login`, or configure an API token, to enable tools.\n"
5542
+ "myspec-mcp: starting unauthenticated. Set MYSPEC_API_TOKEN to a MySpec API token, or run `npx @myspec/mcp-server login`, to enable tools.\n"
5357
5543
  );
5358
5544
  }
5359
- const config = resolveConfig({
5360
- cliUserAuthUrl: flagString(flags, "user-auth-url"),
5361
- storedUserAuthUrl: initial?.userAuthUrl
5362
- });
5363
5545
  const tokenManager = new TokenManager({ store });
5364
5546
  const client = new PlatformClient({
5365
5547
  resolveBaseUrl: createPlatformUrlResolver({
@@ -5395,14 +5577,16 @@ if (isCliEntry()) {
5395
5577
  process.exit(2);
5396
5578
  }
5397
5579
  const message = err instanceof Error ? err.stack ?? err.message : String(err);
5398
- process.stderr.write(message + "\n");
5580
+ process.stderr.write(redactSensitive(message) + "\n");
5399
5581
  process.exit(1);
5400
5582
  });
5401
5583
  }
5402
5584
  export {
5403
5585
  REVERSE_DISCOVERY_HINT,
5586
+ buildCredentialsStore,
5404
5587
  createPlatformUrlResolver,
5405
5588
  main,
5406
5589
  resolveReverseAgentUrl,
5407
- runServe
5590
+ runServe,
5591
+ warnIfStaticTokenShadowsApiToken
5408
5592
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.2.1-next.87",
3
+ "version": "0.3.0-next.89",
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": {