@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.
- package/README.md +90 -1
- package/dist/index.js +161 -100
- 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
|
-
|
|
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,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 =
|
|
848
|
+
const platformUrl = optionalString2(body.platformUrl);
|
|
801
849
|
if (platformUrl !== void 0) {
|
|
802
850
|
assertHttpUrl(platformUrl, endpoint, hint);
|
|
803
851
|
}
|
|
804
|
-
const 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
|
|
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.
|
|
891
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
5004
|
-
"
|
|
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
|
-
|
|
5130
|
-
const store =
|
|
5131
|
-
|
|
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
|
-
|
|
5202
|
-
const store =
|
|
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
|
|
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.
|
|
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": {
|