@myspec/mcp-server 0.1.0-next.1 → 0.1.0-next.11
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 +38 -3
- package/dist/index.js +261 -79
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -23,19 +23,54 @@ login Sign in via browser loopback OAuth
|
|
|
23
23
|
login --paste Sign in by pasting a one-time code
|
|
24
24
|
login --provider <google|github|gitlab|linear|atlassian>
|
|
25
25
|
logout Revoke refresh token and clear local credentials
|
|
26
|
+
reverse --root <dir> Connect to ai-agent and expose local_fs tools
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
### `reverse`
|
|
30
|
+
|
|
31
|
+
`reverse` connects out to the ai-agent service over WebSocket and exposes a set
|
|
32
|
+
of `local_fs` tools scoped to a single local directory, so a cloud agent can
|
|
33
|
+
read files from your machine.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx @myspec/mcp-server@next reverse --root /path/to/project
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The ai-agent WebSocket URL is **discovered at startup** from the webapp's
|
|
40
|
+
authenticated `GET /api/v1/config` endpoint using your saved credentials, so
|
|
41
|
+
the agent domain is never hardcoded in the CLI and can change server-side.
|
|
42
|
+
If discovery fails, `reverse` exits with an actionable error instead of
|
|
43
|
+
guessing — pass `--agent-url` or set `MYSPEC_AI_AGENT_WS_URL` to skip
|
|
44
|
+
discovery (e.g. against a local ai-agent):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx @myspec/mcp-server@next reverse --root . --agent-url ws://localhost:3001/mcp/reverse
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Flag / env | Purpose |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `--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. |
|
|
53
|
+
| `--agent-url <url>` / `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL override; skips webapp discovery entirely. |
|
|
54
|
+
| `--webapp-url <url>` | Webapp base URL used for discovery (default: the URL stored at login, else derived from the auth host). |
|
|
55
|
+
| `--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. |
|
|
56
|
+
|
|
57
|
+
Local state is stored under `~/.myspec/` (alongside the on-disk cache root), split into two files:
|
|
58
|
+
|
|
59
|
+
- `settings.json` — non-secret config: the auth-server, platform, and webapp URLs you logged in with.
|
|
60
|
+
- `oauth_creds.json` — the OAuth tokens (access/refresh/expiry) and your user identity, written mode `0600`.
|
|
61
|
+
|
|
62
|
+
`logout` removes `oauth_creds.json`; `settings.json` is left in place.
|
|
29
63
|
|
|
30
64
|
## Environment variables
|
|
31
65
|
|
|
32
66
|
| Variable | Purpose |
|
|
33
67
|
|---|---|
|
|
34
68
|
| `MYSPEC_USER_AUTH_URL` | user-auth base URL (default `https://auth.myspec.dev`) |
|
|
35
|
-
| `MYSPEC_PLATFORM_URL` | platform API base URL (default `https://
|
|
69
|
+
| `MYSPEC_PLATFORM_URL` | platform API base URL (default `https://platform.myspec.dev`) |
|
|
36
70
|
| `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`. |
|
|
37
71
|
| `MYSPEC_DOWNLOAD_ROOT` | Absolute path used by `read_file` as its on-disk cache root (default: `~/.myspec`). Tools never write outside this root. |
|
|
38
|
-
| `MYSPEC_WEBAPP_URL` | Webapp base URL used by `login
|
|
72
|
+
| `MYSPEC_WEBAPP_URL` | Webapp base URL used by `login` (the `/auth/cli` page) and by `reverse` discovery (default: the URL stored at login, else derived from `MYSPEC_USER_AUTH_URL`) |
|
|
73
|
+
| `MYSPEC_AI_AGENT_WS_URL` | ai-agent WebSocket URL for `reverse`; skips the webapp discovery call |
|
|
39
74
|
|
|
40
75
|
## Tools
|
|
41
76
|
|
package/dist/index.js
CHANGED
|
@@ -90,6 +90,7 @@ async function loopbackLogin(opts) {
|
|
|
90
90
|
expiresAt: now() + exchanged.expiresIn * 1e3,
|
|
91
91
|
userAuthUrl: opts.userAuthUrl,
|
|
92
92
|
platformUrl: opts.platformUrl,
|
|
93
|
+
webappUrl: opts.webappUrl,
|
|
93
94
|
user: exchanged.user
|
|
94
95
|
};
|
|
95
96
|
}
|
|
@@ -266,6 +267,7 @@ async function pasteLogin(opts) {
|
|
|
266
267
|
expiresAt: now() + exchanged.expiresIn * 1e3,
|
|
267
268
|
userAuthUrl: opts.userAuthUrl,
|
|
268
269
|
platformUrl: opts.platformUrl,
|
|
270
|
+
webappUrl: opts.webappUrl,
|
|
269
271
|
user: exchanged.user
|
|
270
272
|
};
|
|
271
273
|
}
|
|
@@ -282,57 +284,117 @@ async function defaultPrompt(question) {
|
|
|
282
284
|
import { promises as fs } from "fs";
|
|
283
285
|
import os from "os";
|
|
284
286
|
import path from "path";
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const xdg = env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
291
|
-
return path.join(xdg, "myspec", "mcp-server.json");
|
|
287
|
+
function myspecDir() {
|
|
288
|
+
return path.join(os.homedir(), ".myspec");
|
|
289
|
+
}
|
|
290
|
+
function defaultSettingsPath() {
|
|
291
|
+
return path.join(myspecDir(), "settings.json");
|
|
292
292
|
}
|
|
293
|
-
function
|
|
294
|
-
|
|
293
|
+
function defaultOauthCredsPath() {
|
|
294
|
+
return path.join(myspecDir(), "oauth_creds.json");
|
|
295
|
+
}
|
|
296
|
+
function createFileCredentialsStore(paths = {}) {
|
|
297
|
+
const settingsPath = paths.settingsPath ?? defaultSettingsPath();
|
|
298
|
+
const oauthCredsPath = paths.oauthCredsPath ?? defaultOauthCredsPath();
|
|
295
299
|
return {
|
|
296
|
-
|
|
300
|
+
// The secret bundle is the credential file users care about (`login`
|
|
301
|
+
// prints this); settings.json sits next to it.
|
|
302
|
+
path: () => oauthCredsPath,
|
|
297
303
|
async load() {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
const raw = await fs.readFile(target, "utf-8");
|
|
308
|
-
const parsed = JSON.parse(raw);
|
|
309
|
-
return parseCredentials(parsed);
|
|
310
|
-
} catch (err) {
|
|
311
|
-
if (isFsNotFound(err)) {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
throw err;
|
|
304
|
+
const oauth = await loadOauthCreds(oauthCredsPath);
|
|
305
|
+
if (!oauth) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const settings = await loadSettings(settingsPath);
|
|
309
|
+
if (!settings) {
|
|
310
|
+
return null;
|
|
315
311
|
}
|
|
312
|
+
return {
|
|
313
|
+
accessToken: oauth.accessToken,
|
|
314
|
+
refreshToken: oauth.refreshToken,
|
|
315
|
+
expiresAt: oauth.expiresAt,
|
|
316
|
+
userAuthUrl: settings.userAuthUrl,
|
|
317
|
+
platformUrl: settings.platformUrl,
|
|
318
|
+
webappUrl: settings.webappUrl,
|
|
319
|
+
user: oauth.user
|
|
320
|
+
};
|
|
316
321
|
},
|
|
317
322
|
async save(creds) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
323
|
+
await writeJsonFile(
|
|
324
|
+
settingsPath,
|
|
325
|
+
{
|
|
326
|
+
userAuthUrl: creds.userAuthUrl,
|
|
327
|
+
platformUrl: creds.platformUrl,
|
|
328
|
+
webappUrl: creds.webappUrl
|
|
329
|
+
},
|
|
330
|
+
{ secret: false }
|
|
331
|
+
);
|
|
332
|
+
await writeJsonFile(
|
|
333
|
+
oauthCredsPath,
|
|
334
|
+
{
|
|
335
|
+
accessToken: creds.accessToken,
|
|
336
|
+
refreshToken: creds.refreshToken,
|
|
337
|
+
expiresAt: creds.expiresAt,
|
|
338
|
+
user: creds.user
|
|
339
|
+
},
|
|
340
|
+
{ secret: true }
|
|
341
|
+
);
|
|
324
342
|
},
|
|
325
343
|
async clear() {
|
|
326
|
-
|
|
327
|
-
await fs.unlink(target);
|
|
328
|
-
} catch (err) {
|
|
329
|
-
if (!isFsNotFound(err)) {
|
|
330
|
-
throw err;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
344
|
+
await unlinkIfExists(oauthCredsPath);
|
|
333
345
|
}
|
|
334
346
|
};
|
|
335
347
|
}
|
|
348
|
+
async function loadOauthCreds(target) {
|
|
349
|
+
try {
|
|
350
|
+
if (process.platform !== "win32") {
|
|
351
|
+
const stat = await fs.stat(target);
|
|
352
|
+
if ((stat.mode & 63) !== 0) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Refusing to load credentials from ${target}: file mode is too permissive (${(stat.mode & 511).toString(8)}). Run \`chmod 600 ${target}\` and retry.`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const raw = await fs.readFile(target, "utf-8");
|
|
359
|
+
return parseOauthCreds(JSON.parse(raw));
|
|
360
|
+
} catch (err) {
|
|
361
|
+
if (isFsNotFound(err)) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function loadSettings(target) {
|
|
368
|
+
try {
|
|
369
|
+
const raw = await fs.readFile(target, "utf-8");
|
|
370
|
+
return parseSettings(JSON.parse(raw));
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (isFsNotFound(err)) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function writeJsonFile(target, data, opts) {
|
|
379
|
+
const dir = path.dirname(target);
|
|
380
|
+
await fs.mkdir(dir, { recursive: true, mode: 448 });
|
|
381
|
+
if (process.platform !== "win32") {
|
|
382
|
+
await fs.chmod(dir, 448);
|
|
383
|
+
}
|
|
384
|
+
await fs.writeFile(target, JSON.stringify(data, null, 2), { mode: 384 });
|
|
385
|
+
if (opts.secret && process.platform !== "win32") {
|
|
386
|
+
await fs.chmod(target, 384);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function unlinkIfExists(target) {
|
|
390
|
+
try {
|
|
391
|
+
await fs.unlink(target);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (!isFsNotFound(err)) {
|
|
394
|
+
throw err;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
336
398
|
var DEFAULT_USER_AUTH_URL = "https://auth.myspec.dev";
|
|
337
399
|
function readEnvRefreshConfig(env = process.env) {
|
|
338
400
|
if (env.MYSPEC_ACCESS_TOKEN || env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
|
|
@@ -380,19 +442,17 @@ function createCompositeCredentialsStore(opts) {
|
|
|
380
442
|
envUserAuthUrl: () => envUserAuthUrl
|
|
381
443
|
};
|
|
382
444
|
}
|
|
383
|
-
function
|
|
445
|
+
function parseOauthCreds(value) {
|
|
384
446
|
if (typeof value !== "object" || value === null) {
|
|
385
|
-
throw new Error("
|
|
447
|
+
throw new Error("oauth_creds.json is malformed (not an object)");
|
|
386
448
|
}
|
|
387
449
|
const v = value;
|
|
388
450
|
const accessToken = requireString(v, "accessToken");
|
|
389
451
|
const refreshToken = requireString(v, "refreshToken");
|
|
390
452
|
const expiresAt = requireNumber(v, "expiresAt");
|
|
391
|
-
const userAuthUrl = requireString(v, "userAuthUrl");
|
|
392
|
-
const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
|
|
393
453
|
const userRaw = v.user;
|
|
394
454
|
if (typeof userRaw !== "object" || userRaw === null) {
|
|
395
|
-
throw new Error("
|
|
455
|
+
throw new Error("oauth_creds.json is malformed (missing user)");
|
|
396
456
|
}
|
|
397
457
|
const userObj = userRaw;
|
|
398
458
|
const user = {
|
|
@@ -400,7 +460,17 @@ function parseCredentials(value) {
|
|
|
400
460
|
email: requireString(userObj, "email"),
|
|
401
461
|
name: typeof userObj.name === "string" ? userObj.name : void 0
|
|
402
462
|
};
|
|
403
|
-
return { accessToken, refreshToken, expiresAt,
|
|
463
|
+
return { accessToken, refreshToken, expiresAt, user };
|
|
464
|
+
}
|
|
465
|
+
function parseSettings(value) {
|
|
466
|
+
if (typeof value !== "object" || value === null) {
|
|
467
|
+
throw new Error("settings.json is malformed (not an object)");
|
|
468
|
+
}
|
|
469
|
+
const v = value;
|
|
470
|
+
const userAuthUrl = requireString(v, "userAuthUrl");
|
|
471
|
+
const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
|
|
472
|
+
const webappUrl = typeof v.webappUrl === "string" ? v.webappUrl : void 0;
|
|
473
|
+
return { userAuthUrl, platformUrl, webappUrl };
|
|
404
474
|
}
|
|
405
475
|
function requireString(obj, key) {
|
|
406
476
|
const value = obj[key];
|
|
@@ -424,8 +494,8 @@ function isFsNotFound(err) {
|
|
|
424
494
|
function resolveConfig(sources = {}) {
|
|
425
495
|
const env = sources.env ?? process.env;
|
|
426
496
|
const userAuthUrl = sources.cliUserAuthUrl ?? env.MYSPEC_USER_AUTH_URL ?? sources.storedUserAuthUrl ?? "https://auth.myspec.dev";
|
|
427
|
-
const platformUrl = sources.cliPlatformUrl ?? env.MYSPEC_PLATFORM_URL ?? sources.storedPlatformUrl ?? "https://
|
|
428
|
-
const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? deriveWebappFromAuth(userAuthUrl);
|
|
497
|
+
const platformUrl = sources.cliPlatformUrl ?? env.MYSPEC_PLATFORM_URL ?? sources.storedPlatformUrl ?? "https://platform.myspec.dev";
|
|
498
|
+
const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? sources.storedWebappUrl ?? deriveWebappFromAuth(userAuthUrl);
|
|
429
499
|
validateUrl(userAuthUrl, "user-auth URL");
|
|
430
500
|
validateUrl(platformUrl, "platform URL");
|
|
431
501
|
validateUrl(webappUrl, "webapp URL");
|
|
@@ -589,6 +659,7 @@ var TokenManager = class {
|
|
|
589
659
|
expiresAt: 0,
|
|
590
660
|
userAuthUrl: this.envFallback.userAuthUrl,
|
|
591
661
|
platformUrl: creds.platformUrl,
|
|
662
|
+
webappUrl: creds.webappUrl,
|
|
592
663
|
user: creds.user
|
|
593
664
|
};
|
|
594
665
|
try {
|
|
@@ -1079,7 +1150,9 @@ var HttpAttachmentClient = class {
|
|
|
1079
1150
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1080
1151
|
const url = `${baseUrl}/project/v1/attachments/${attachmentId}/content`;
|
|
1081
1152
|
const attempt = async () => {
|
|
1082
|
-
const blob = new Blob([content], {
|
|
1153
|
+
const blob = new Blob([content], {
|
|
1154
|
+
type: "application/octet-stream"
|
|
1155
|
+
});
|
|
1083
1156
|
const response = await fetch(url, {
|
|
1084
1157
|
method: "PUT",
|
|
1085
1158
|
headers: {
|
|
@@ -1173,7 +1246,9 @@ var HttpFileClient = class {
|
|
|
1173
1246
|
async uploadContent(fileId, content, jwtToken) {
|
|
1174
1247
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1175
1248
|
const url = `${baseUrl}/project/v1/files/${fileId}/content`;
|
|
1176
|
-
const blob = new Blob([content], {
|
|
1249
|
+
const blob = new Blob([content], {
|
|
1250
|
+
type: "application/octet-stream"
|
|
1251
|
+
});
|
|
1177
1252
|
const response = await fetch(url, {
|
|
1178
1253
|
method: "PUT",
|
|
1179
1254
|
headers: {
|
|
@@ -1200,7 +1275,9 @@ var HttpFileClient = class {
|
|
|
1200
1275
|
async saveManualRevision(fileId, content, jwtToken) {
|
|
1201
1276
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1202
1277
|
const url = `${baseUrl}/project/v1/files/${fileId}/revisions`;
|
|
1203
|
-
const blob = new Blob([content], {
|
|
1278
|
+
const blob = new Blob([content], {
|
|
1279
|
+
type: "application/octet-stream"
|
|
1280
|
+
});
|
|
1204
1281
|
const response = await fetch(url, {
|
|
1205
1282
|
method: "POST",
|
|
1206
1283
|
headers: {
|
|
@@ -1245,6 +1322,17 @@ var HttpFileClient = class {
|
|
|
1245
1322
|
expiresAt: new Date(response.expires_at)
|
|
1246
1323
|
};
|
|
1247
1324
|
}
|
|
1325
|
+
async getRevisionDownloadUrl(fileId, revisionNumber, jwtToken) {
|
|
1326
|
+
const response = await this.httpClient.get(
|
|
1327
|
+
`/project/v1/files/${fileId}/revisions/${revisionNumber}`,
|
|
1328
|
+
jwtToken,
|
|
1329
|
+
{ headers: { Accept: "application/json" } }
|
|
1330
|
+
);
|
|
1331
|
+
return {
|
|
1332
|
+
signedUrl: response.signed_url,
|
|
1333
|
+
expiresAt: new Date(response.expires_at)
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1248
1336
|
async downloadContent(fileId, jwtToken) {
|
|
1249
1337
|
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1250
1338
|
const url = `${baseUrl}/project/v1/files/${fileId}`;
|
|
@@ -1538,6 +1626,11 @@ var PlatformClient = class {
|
|
|
1538
1626
|
async getFileDownloadUrl(fileId) {
|
|
1539
1627
|
return this.withTokenRetry((jwt) => this.file.getDownloadUrl(fileId, jwt));
|
|
1540
1628
|
}
|
|
1629
|
+
async getRevisionDownloadUrl(fileId, revisionNumber) {
|
|
1630
|
+
return this.withTokenRetry(
|
|
1631
|
+
(jwt) => this.file.getRevisionDownloadUrl(fileId, revisionNumber, jwt)
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1541
1634
|
async getAttachmentDownloadUrl(attachmentId) {
|
|
1542
1635
|
return this.withTokenRetry((jwt) => this.attachment.getDownloadUrl(attachmentId, jwt));
|
|
1543
1636
|
}
|
|
@@ -1692,12 +1785,12 @@ import { z as z2 } from "zod";
|
|
|
1692
1785
|
var inputSchema2 = {
|
|
1693
1786
|
file_id: z2.string().min(1).describe("UUID of the file"),
|
|
1694
1787
|
include_download_url: z2.number().int().nonnegative().optional().describe(
|
|
1695
|
-
"Revision number to include a download URL for.
|
|
1788
|
+
"Revision number to include a download URL for. Returns a signed, time-limited URL for that revision (latest or historical) that needs no credentials. Omit or 0 to skip."
|
|
1696
1789
|
)
|
|
1697
1790
|
};
|
|
1698
1791
|
var getFileTool = {
|
|
1699
1792
|
name: "get_file",
|
|
1700
|
-
description: "Get file metadata. With `include_download_url=N`, also returns a download URL for revision N
|
|
1793
|
+
description: "Get file metadata. With `include_download_url=N`, also returns a signed, time-limited download URL for revision N (latest or historical). The URL is self-authenticating, so no credentials are returned.",
|
|
1701
1794
|
inputSchema: inputSchema2,
|
|
1702
1795
|
handler: async (args, ctx) => {
|
|
1703
1796
|
const meta = await ctx.client.getFileMetadata(args.file_id);
|
|
@@ -1723,24 +1816,12 @@ var getFileTool = {
|
|
|
1723
1816
|
);
|
|
1724
1817
|
}
|
|
1725
1818
|
const isLatest = requested === meta.revisionCount;
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
expires_at: signed.expiresAt.toISOString()
|
|
1733
|
-
};
|
|
1734
|
-
} else {
|
|
1735
|
-
const url = `${ctx.client.baseUrl}/project/v1/files/${encodeURIComponent(meta.id)}/revisions/${String(requested)}`;
|
|
1736
|
-
const token = await ctx.client.getAccessTokenWithExpiry();
|
|
1737
|
-
downloadBlock = {
|
|
1738
|
-
download_url: url,
|
|
1739
|
-
download_revision: requested,
|
|
1740
|
-
expires_at: new Date(token.expiresAt).toISOString(),
|
|
1741
|
-
download_headers: [`Authorization: Bearer ${token.accessToken}`]
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1819
|
+
const signed = isLatest ? await ctx.client.getFileDownloadUrl(meta.id) : await ctx.client.getRevisionDownloadUrl(meta.id, requested);
|
|
1820
|
+
const downloadBlock = {
|
|
1821
|
+
download_url: signed.signedUrl,
|
|
1822
|
+
download_revision: requested,
|
|
1823
|
+
expires_at: signed.expiresAt.toISOString()
|
|
1824
|
+
};
|
|
1744
1825
|
return jsonResult({ ...base, ...downloadBlock });
|
|
1745
1826
|
}
|
|
1746
1827
|
};
|
|
@@ -3300,7 +3381,8 @@ async function runOneConnection(opts) {
|
|
|
3300
3381
|
headers: {
|
|
3301
3382
|
Authorization: `Bearer ${opts.accessToken}`,
|
|
3302
3383
|
"X-Mcp-Client": `myspec-mcp-server/${opts.version}`,
|
|
3303
|
-
"X-Mcp-Root-Label": opts.rootLabel
|
|
3384
|
+
"X-Mcp-Root-Label": opts.rootLabel,
|
|
3385
|
+
"X-Client-Type": "mcp-server"
|
|
3304
3386
|
}
|
|
3305
3387
|
});
|
|
3306
3388
|
await waitForOpen(ws);
|
|
@@ -3384,6 +3466,64 @@ function sleep2(ms) {
|
|
|
3384
3466
|
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3385
3467
|
}
|
|
3386
3468
|
|
|
3469
|
+
// src/reverse/discover.ts
|
|
3470
|
+
async function discoverAgentUrl(opts) {
|
|
3471
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
3472
|
+
const endpoint = `${opts.webappUrl}/api/v1/config`;
|
|
3473
|
+
let response;
|
|
3474
|
+
try {
|
|
3475
|
+
const token = await opts.getAccessToken();
|
|
3476
|
+
response = await fetchConfig(endpoint, token, fetchImpl);
|
|
3477
|
+
if (response.status === 401 && opts.forceRefresh) {
|
|
3478
|
+
const refreshed = await opts.forceRefresh();
|
|
3479
|
+
response = await fetchConfig(endpoint, refreshed, fetchImpl);
|
|
3480
|
+
}
|
|
3481
|
+
} catch (err) {
|
|
3482
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3483
|
+
throw discoveryError(endpoint, message);
|
|
3484
|
+
}
|
|
3485
|
+
if (!response.ok) {
|
|
3486
|
+
throw discoveryError(endpoint, `HTTP ${String(response.status)}`);
|
|
3487
|
+
}
|
|
3488
|
+
let body;
|
|
3489
|
+
try {
|
|
3490
|
+
body = await response.json();
|
|
3491
|
+
} catch {
|
|
3492
|
+
throw discoveryError(endpoint, "response is not valid JSON");
|
|
3493
|
+
}
|
|
3494
|
+
const agentUrl = body.aiAgentMcpReverseUrl;
|
|
3495
|
+
if (typeof agentUrl !== "string" || agentUrl.length === 0) {
|
|
3496
|
+
throw discoveryError(endpoint, "response is missing aiAgentMcpReverseUrl");
|
|
3497
|
+
}
|
|
3498
|
+
assertWebSocketUrl(agentUrl, endpoint);
|
|
3499
|
+
return agentUrl;
|
|
3500
|
+
}
|
|
3501
|
+
function fetchConfig(endpoint, token, fetchImpl) {
|
|
3502
|
+
return fetchImpl(endpoint, {
|
|
3503
|
+
method: "GET",
|
|
3504
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
function assertWebSocketUrl(value, endpoint) {
|
|
3508
|
+
let parsed;
|
|
3509
|
+
try {
|
|
3510
|
+
parsed = new URL(value);
|
|
3511
|
+
} catch {
|
|
3512
|
+
throw discoveryError(endpoint, `returned an invalid URL: ${value}`);
|
|
3513
|
+
}
|
|
3514
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
3515
|
+
throw discoveryError(
|
|
3516
|
+
endpoint,
|
|
3517
|
+
`returned a non-WebSocket URL (${value}); expected ws:// or wss://`
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
function discoveryError(endpoint, reason) {
|
|
3522
|
+
return new ConfigError(
|
|
3523
|
+
`Could not discover the ai-agent URL from ${endpoint} (${reason}). Pass --agent-url <url>, set MYSPEC_AI_AGENT_WS_URL, or run \`npx @myspec/mcp-server login\` and retry.`
|
|
3524
|
+
);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3387
3527
|
// src/index.ts
|
|
3388
3528
|
function parseArgs(argv) {
|
|
3389
3529
|
const [first, ...rest] = argv;
|
|
@@ -3450,22 +3590,27 @@ function printHelp() {
|
|
|
3450
3590
|
" login Sign in via browser (chooser page on the webapp)",
|
|
3451
3591
|
" login --paste Sign in by pasting a one-time code",
|
|
3452
3592
|
" logout Revoke refresh token and clear local credentials",
|
|
3453
|
-
" reverse --root <dir> Connect to ai-agent and expose local_fs tools
|
|
3593
|
+
" reverse --root <dir> Connect to ai-agent and expose local_fs tools.",
|
|
3594
|
+
" The agent WebSocket URL is discovered from the webapp",
|
|
3595
|
+
" (GET /api/v1/config) using your saved credentials;",
|
|
3596
|
+
" override with --agent-url <url> or MYSPEC_AI_AGENT_WS_URL.",
|
|
3454
3597
|
" Uses the saved refresh_token from `npx @myspec/mcp-server login`",
|
|
3455
3598
|
" to obtain & auto-refresh access tokens. Override with",
|
|
3456
3599
|
" --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
|
|
3457
3600
|
" --version Print version",
|
|
3458
3601
|
" --help Print this help",
|
|
3459
3602
|
"",
|
|
3460
|
-
"Login flags:",
|
|
3603
|
+
"Login / reverse flags:",
|
|
3461
3604
|
" --user-auth-url <url> user-auth base URL (overrides env / defaults)",
|
|
3462
3605
|
" --platform-url <url> platform API base URL",
|
|
3463
|
-
" --webapp-url <url> webapp base URL
|
|
3606
|
+
" --webapp-url <url> webapp base URL (login: hosts the /auth/cli page;",
|
|
3607
|
+
" reverse: hosts the /api/v1/config discovery endpoint)",
|
|
3464
3608
|
"",
|
|
3465
3609
|
"Environment:",
|
|
3466
3610
|
" MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
|
|
3467
|
-
" MYSPEC_PLATFORM_URL platform API base URL (default https://
|
|
3611
|
+
" MYSPEC_PLATFORM_URL platform API base URL (default https://platform.myspec.dev)",
|
|
3468
3612
|
" MYSPEC_WEBAPP_URL webapp base URL (default derived from user-auth host)",
|
|
3613
|
+
" MYSPEC_AI_AGENT_WS_URL ai-agent WebSocket URL for `reverse` (skips discovery)",
|
|
3469
3614
|
" MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
|
|
3470
3615
|
" fresh access token on first use via this refresh",
|
|
3471
3616
|
" token."
|
|
@@ -3516,16 +3661,35 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
3516
3661
|
return;
|
|
3517
3662
|
}
|
|
3518
3663
|
}
|
|
3664
|
+
async function resolveReverseAgentUrl(sources) {
|
|
3665
|
+
const env = sources.env ?? process.env;
|
|
3666
|
+
const explicitAgentUrl = sources.flagAgentUrl ?? env.MYSPEC_AI_AGENT_WS_URL;
|
|
3667
|
+
if (explicitAgentUrl) {
|
|
3668
|
+
return { agentUrl: explicitAgentUrl };
|
|
3669
|
+
}
|
|
3670
|
+
const stored = await sources.loadStored().catch(() => null);
|
|
3671
|
+
const config = resolveConfig({
|
|
3672
|
+
env,
|
|
3673
|
+
cliWebappUrl: sources.cliWebappUrl,
|
|
3674
|
+
storedUserAuthUrl: stored?.userAuthUrl,
|
|
3675
|
+
storedWebappUrl: stored?.webappUrl
|
|
3676
|
+
});
|
|
3677
|
+
const agentUrl = await sources.discover(config.webappUrl);
|
|
3678
|
+
return { agentUrl, discoveredVia: `${config.webappUrl}/api/v1/config` };
|
|
3679
|
+
}
|
|
3519
3680
|
async function runReverseCommand(flags) {
|
|
3520
3681
|
const root = flagString(flags, "root") ?? process.cwd();
|
|
3521
|
-
const agentUrl = flagString(flags, "agent-url") ?? process.env.MYSPEC_AI_AGENT_WS_URL ?? "ws://localhost:3001/mcp/reverse";
|
|
3522
3682
|
const inlineToken = flagString(flags, "access-token") ?? process.env.MYSPEC_ACCESS_TOKEN;
|
|
3523
3683
|
let getAccessToken;
|
|
3524
3684
|
let onAuthFailed;
|
|
3685
|
+
let forceRefresh;
|
|
3686
|
+
let loadStored;
|
|
3525
3687
|
if (inlineToken) {
|
|
3526
3688
|
const staticToken = inlineToken;
|
|
3527
3689
|
getAccessToken = () => Promise.resolve(staticToken);
|
|
3528
3690
|
onAuthFailed = void 0;
|
|
3691
|
+
forceRefresh = void 0;
|
|
3692
|
+
loadStored = () => Promise.resolve(null);
|
|
3529
3693
|
} else {
|
|
3530
3694
|
const envConfig = readEnvRefreshConfig();
|
|
3531
3695
|
const store = createCompositeCredentialsStore({
|
|
@@ -3537,10 +3701,26 @@ async function runReverseCommand(flags) {
|
|
|
3537
3701
|
onAuthFailed = async () => {
|
|
3538
3702
|
await tokenManager.forceRefresh();
|
|
3539
3703
|
};
|
|
3704
|
+
forceRefresh = () => tokenManager.forceRefresh();
|
|
3705
|
+
loadStored = () => store.load();
|
|
3706
|
+
}
|
|
3707
|
+
const resolved = await resolveReverseAgentUrl({
|
|
3708
|
+
flagAgentUrl: flagString(flags, "agent-url"),
|
|
3709
|
+
cliWebappUrl: flagString(flags, "webapp-url"),
|
|
3710
|
+
loadStored,
|
|
3711
|
+
discover: (webappUrl) => {
|
|
3712
|
+
return discoverAgentUrl({ webappUrl, getAccessToken, forceRefresh });
|
|
3713
|
+
}
|
|
3714
|
+
});
|
|
3715
|
+
if (resolved.discoveredVia) {
|
|
3716
|
+
process.stderr.write(
|
|
3717
|
+
`myspec-mcp reverse: discovered agent URL via ${resolved.discoveredVia}
|
|
3718
|
+
`
|
|
3719
|
+
);
|
|
3540
3720
|
}
|
|
3541
3721
|
await runReverse({
|
|
3542
3722
|
root,
|
|
3543
|
-
agentUrl,
|
|
3723
|
+
agentUrl: resolved.agentUrl,
|
|
3544
3724
|
getAccessToken,
|
|
3545
3725
|
onAuthFailed,
|
|
3546
3726
|
version: readVersion()
|
|
@@ -3562,7 +3742,8 @@ async function runServe(flags) {
|
|
|
3562
3742
|
cliUserAuthUrl: flagString(flags, "user-auth-url"),
|
|
3563
3743
|
cliPlatformUrl: flagString(flags, "platform-url"),
|
|
3564
3744
|
storedUserAuthUrl: initial?.userAuthUrl,
|
|
3565
|
-
storedPlatformUrl: initial?.platformUrl
|
|
3745
|
+
storedPlatformUrl: initial?.platformUrl,
|
|
3746
|
+
storedWebappUrl: initial?.webappUrl
|
|
3566
3747
|
});
|
|
3567
3748
|
const tokenManager = new TokenManager({
|
|
3568
3749
|
store,
|
|
@@ -3600,5 +3781,6 @@ if (isCliEntry()) {
|
|
|
3600
3781
|
}
|
|
3601
3782
|
export {
|
|
3602
3783
|
main,
|
|
3784
|
+
resolveReverseAgentUrl,
|
|
3603
3785
|
runServe
|
|
3604
3786
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myspec/mcp-server",
|
|
3
|
-
"version": "0.1.0-next.
|
|
3
|
+
"version": "0.1.0-next.11",
|
|
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": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@eslint/js": "^10.0.1",
|
|
51
51
|
"@myspec/platform-client": "file:../../packages/platform-client",
|
|
52
52
|
"@myspec/shared": "file:../../packages/shared",
|
|
53
|
-
"@types/node": "^
|
|
53
|
+
"@types/node": "^25.9.1",
|
|
54
54
|
"@types/ws": "^8.18.1",
|
|
55
55
|
"eslint": "^10.4.1",
|
|
56
56
|
"tsup": "^8.3.5",
|