@myspec/mcp-server 0.2.0-next.79 → 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.
- package/README.md +86 -1
- package/dist/index.js +147 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,92 @@ 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
|
+
**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
|
+
|
|
61
147
|
### Active organization
|
|
62
148
|
|
|
63
149
|
Every MySpec access token carries the slug of one **active organization**, and
|
|
@@ -136,7 +222,6 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
|
|
|
136
222
|
| Variable | Purpose |
|
|
137
223
|
|---|---|
|
|
138
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. |
|
|
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
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. |
|
|
141
226
|
| `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
|
|
142
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) {
|
|
@@ -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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
380
|
-
return
|
|
385
|
+
if (!env.MYSPEC_ACCESS_TOKEN && !env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
|
|
386
|
+
return;
|
|
381
387
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
425
|
-
const
|
|
426
|
-
|
|
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
|
-
|
|
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,52 @@ 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.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
|
+
}
|
|
555
593
|
async performRefresh(creds) {
|
|
556
594
|
const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
|
|
557
595
|
const response = await this.fetchImpl(url, {
|
|
@@ -587,6 +625,8 @@ var TokenManager = class {
|
|
|
587
625
|
};
|
|
588
626
|
var RefreshRejectedError = class extends NeedsLoginError {
|
|
589
627
|
};
|
|
628
|
+
var ApiTokenRejectedError = class extends ConfigError {
|
|
629
|
+
};
|
|
590
630
|
|
|
591
631
|
// src/auth/organization.ts
|
|
592
632
|
import readline2 from "readline";
|
|
@@ -797,11 +837,11 @@ async function discoverConfig(opts) {
|
|
|
797
837
|
} catch {
|
|
798
838
|
throw discoveryError(endpoint, "response is not valid JSON", hint);
|
|
799
839
|
}
|
|
800
|
-
const platformUrl =
|
|
840
|
+
const platformUrl = optionalString2(body.platformUrl);
|
|
801
841
|
if (platformUrl !== void 0) {
|
|
802
842
|
assertHttpUrl(platformUrl, endpoint, hint);
|
|
803
843
|
}
|
|
804
|
-
const aiAgentMcpReverseUrl =
|
|
844
|
+
const aiAgentMcpReverseUrl = optionalString2(body.aiAgentMcpReverseUrl);
|
|
805
845
|
if (aiAgentMcpReverseUrl !== void 0) {
|
|
806
846
|
assertWebSocketUrl(aiAgentMcpReverseUrl, endpoint, hint);
|
|
807
847
|
}
|
|
@@ -827,7 +867,7 @@ function fetchConfig(endpoint, token, fetchImpl) {
|
|
|
827
867
|
headers: { Authorization: `Bearer ${token}` }
|
|
828
868
|
});
|
|
829
869
|
}
|
|
830
|
-
function
|
|
870
|
+
function optionalString2(value) {
|
|
831
871
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
832
872
|
}
|
|
833
873
|
function assertHttpUrl(value, endpoint, hint) {
|
|
@@ -887,11 +927,13 @@ async function runLogin(options, deps = {}) {
|
|
|
887
927
|
webappUrl: config.webappUrl
|
|
888
928
|
});
|
|
889
929
|
const enriched = await discoverAfterLogin(credentials, config.webappUrl, deps.discover, log);
|
|
890
|
-
await store.
|
|
891
|
-
|
|
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"}.`);
|
|
892
934
|
log(`Credentials saved to ${store.path()}`);
|
|
893
935
|
await ensureActiveOrganization({
|
|
894
|
-
credentials:
|
|
936
|
+
credentials: preserved,
|
|
895
937
|
webappUrl: config.webappUrl,
|
|
896
938
|
requestedSlug: options.org,
|
|
897
939
|
store,
|
|
@@ -1024,11 +1066,31 @@ async function runLogout() {
|
|
|
1024
1066
|
"Content-Type": "application/json",
|
|
1025
1067
|
Authorization: `Bearer ${existing.accessToken}`
|
|
1026
1068
|
},
|
|
1027
|
-
|
|
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
|
+
),
|
|
1028
1075
|
signal: AbortSignal.timeout(5e3)
|
|
1029
1076
|
});
|
|
1030
1077
|
} catch {
|
|
1031
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
|
+
}
|
|
1032
1094
|
await store.clear();
|
|
1033
1095
|
process.stderr.write("Signed out and cleared local credentials.\n");
|
|
1034
1096
|
}
|
|
@@ -4713,7 +4775,7 @@ Press Ctrl-C to stop. Auto-reconnect enabled.
|
|
|
4713
4775
|
const message = err instanceof Error ? err.message : String(err);
|
|
4714
4776
|
process.stderr.write(
|
|
4715
4777
|
`myspec-mcp reverse: cannot obtain access token: ${message}
|
|
4716
|
-
Run \`npx @myspec/mcp-server login
|
|
4778
|
+
Run \`npx @myspec/mcp-server login\`, or configure an API token, and try again.
|
|
4717
4779
|
`
|
|
4718
4780
|
);
|
|
4719
4781
|
return;
|
|
@@ -5015,10 +5077,7 @@ function printHelp() {
|
|
|
5015
5077
|
"",
|
|
5016
5078
|
"Environment:",
|
|
5017
5079
|
" 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."
|
|
5080
|
+
" MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)"
|
|
5022
5081
|
];
|
|
5023
5082
|
process.stderr.write(lines.join("\n") + "\n");
|
|
5024
5083
|
}
|
|
@@ -5126,12 +5185,9 @@ async function runReverseCommand(flags) {
|
|
|
5126
5185
|
loadStored = () => Promise.resolve(null);
|
|
5127
5186
|
persist = void 0;
|
|
5128
5187
|
} else {
|
|
5129
|
-
|
|
5130
|
-
const store =
|
|
5131
|
-
|
|
5132
|
-
envConfig
|
|
5133
|
-
});
|
|
5134
|
-
const tokenManager = new TokenManager({ store, envFallback: envConfig });
|
|
5188
|
+
assertNoEnvAccessToken();
|
|
5189
|
+
const store = createFileCredentialsStore();
|
|
5190
|
+
const tokenManager = new TokenManager({ store });
|
|
5135
5191
|
getAccessToken = () => tokenManager.getValidAccessToken();
|
|
5136
5192
|
onAuthFailed = async () => {
|
|
5137
5193
|
await tokenManager.forceRefresh();
|
|
@@ -5198,25 +5254,19 @@ function createPlatformUrlResolver(deps) {
|
|
|
5198
5254
|
};
|
|
5199
5255
|
}
|
|
5200
5256
|
async function runServe(flags) {
|
|
5201
|
-
|
|
5202
|
-
const store =
|
|
5203
|
-
fileStore: createFileCredentialsStore(),
|
|
5204
|
-
envConfig
|
|
5205
|
-
});
|
|
5257
|
+
assertNoEnvAccessToken();
|
|
5258
|
+
const store = createFileCredentialsStore();
|
|
5206
5259
|
const initial = await store.load();
|
|
5207
5260
|
if (!initial) {
|
|
5208
5261
|
process.stderr.write(
|
|
5209
|
-
"myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login
|
|
5262
|
+
"myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login`, or configure an API token, to enable tools.\n"
|
|
5210
5263
|
);
|
|
5211
5264
|
}
|
|
5212
5265
|
const config = resolveConfig({
|
|
5213
5266
|
cliUserAuthUrl: flagString(flags, "user-auth-url"),
|
|
5214
5267
|
storedUserAuthUrl: initial?.userAuthUrl
|
|
5215
5268
|
});
|
|
5216
|
-
const tokenManager = new TokenManager({
|
|
5217
|
-
store,
|
|
5218
|
-
envFallback: envConfig
|
|
5219
|
-
});
|
|
5269
|
+
const tokenManager = new TokenManager({ store });
|
|
5220
5270
|
const client = new PlatformClient({
|
|
5221
5271
|
resolveBaseUrl: createPlatformUrlResolver({
|
|
5222
5272
|
store,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myspec/mcp-server",
|
|
3
|
-
"version": "0.2.0-next.
|
|
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": {
|