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