@myspec/mcp-server 0.1.0-next.1
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 +154 -0
- package/dist/index.js +3604 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync, realpathSync } from "fs";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
|
|
7
|
+
// src/auth/oauth-loopback.ts
|
|
8
|
+
import http from "http";
|
|
9
|
+
import { randomBytes } from "crypto";
|
|
10
|
+
import readline from "readline";
|
|
11
|
+
|
|
12
|
+
// src/errors.ts
|
|
13
|
+
var NeedsLoginError = class extends Error {
|
|
14
|
+
constructor(message = "Not authenticated. Run `npx @myspec/mcp-server login` to sign in.") {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "NeedsLoginError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var HttpStatusError = class extends Error {
|
|
20
|
+
status;
|
|
21
|
+
body;
|
|
22
|
+
constructor(status, body, message) {
|
|
23
|
+
const safeBody = redactSensitive(body);
|
|
24
|
+
super(message ?? `HTTP ${String(status)}: ${safeBody.slice(0, 200)}`);
|
|
25
|
+
this.name = "HttpStatusError";
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.body = safeBody;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._\-+/=]+/g;
|
|
31
|
+
var REFRESH_TOKEN_PATTERN = /"refreshToken"\s*:\s*"[^"]+"/g;
|
|
32
|
+
var ACCESS_TOKEN_PATTERN = /"accessToken"\s*:\s*"[^"]+"/g;
|
|
33
|
+
var SNAKE_TOKEN_PATTERN = /"(access_token|refresh_token|id_token)"\s*:\s*"[^"]+"/g;
|
|
34
|
+
var JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
|
|
35
|
+
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]");
|
|
37
|
+
}
|
|
38
|
+
var ConfigError = class extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "ConfigError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// src/auth/oauth-loopback.ts
|
|
46
|
+
var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
47
|
+
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
|
+
var ERROR_HTML = "<!doctype html><html><body><h1>Sign-in failed</h1><p>Return to your terminal for details.</p></body></html>";
|
|
49
|
+
async function loopbackLogin(opts) {
|
|
50
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
51
|
+
const now = opts.now ?? Date.now;
|
|
52
|
+
const callbackPath = opts.callbackPath ?? "/cb";
|
|
53
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
54
|
+
const logger = opts.logger ?? ((m) => {
|
|
55
|
+
process.stderr.write(m + "\n");
|
|
56
|
+
});
|
|
57
|
+
const state = randomBytes(32).toString("base64url");
|
|
58
|
+
const { code } = await waitForOAuthCallback({
|
|
59
|
+
callbackPath,
|
|
60
|
+
timeoutMs,
|
|
61
|
+
expectedState: state,
|
|
62
|
+
pasteInput: opts.disablePasteFallback ? void 0 : opts.pasteInput ?? process.stdin,
|
|
63
|
+
logger,
|
|
64
|
+
onListen: (boundPort) => {
|
|
65
|
+
const callbackBase = `http://127.0.0.1:${String(boundPort)}${callbackPath}`;
|
|
66
|
+
const loopbackTarget = `${callbackBase}?mode=cli&state=${state}`;
|
|
67
|
+
const webappLogin = new URL(`${opts.webappUrl.replace(/\/$/, "")}/auth/cli`);
|
|
68
|
+
webappLogin.searchParams.set("target", loopbackTarget);
|
|
69
|
+
const signInUrl = webappLogin.toString();
|
|
70
|
+
logger(`Open this URL in your browser to sign in:
|
|
71
|
+
${signInUrl}`);
|
|
72
|
+
if (!opts.disablePasteFallback) {
|
|
73
|
+
logger(
|
|
74
|
+
"If the browser cannot reach this CLI directly, paste the code shown on the\n/auth/cli page here and press Enter."
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (opts.openBrowser) {
|
|
78
|
+
opts.openBrowser(signInUrl).catch((err) => {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
logger(`Failed to open browser automatically: ${message}`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
logger("Captured authorization code. Exchanging for tokens...");
|
|
86
|
+
const exchanged = await exchangeCode(opts.userAuthUrl, code, fetchImpl);
|
|
87
|
+
return {
|
|
88
|
+
accessToken: exchanged.accessToken,
|
|
89
|
+
refreshToken: exchanged.refreshToken,
|
|
90
|
+
expiresAt: now() + exchanged.expiresIn * 1e3,
|
|
91
|
+
userAuthUrl: opts.userAuthUrl,
|
|
92
|
+
platformUrl: opts.platformUrl,
|
|
93
|
+
user: exchanged.user
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function applyCorsHeaders(res) {
|
|
97
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
98
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
99
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
100
|
+
}
|
|
101
|
+
async function waitForOAuthCallback(opts) {
|
|
102
|
+
return new Promise((resolve2, reject) => {
|
|
103
|
+
const settledFlag = { value: false };
|
|
104
|
+
const timeoutRef = { handle: void 0 };
|
|
105
|
+
const rlRef = { handle: void 0 };
|
|
106
|
+
const settle = (fn) => {
|
|
107
|
+
if (settledFlag.value) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
settledFlag.value = true;
|
|
111
|
+
if (timeoutRef.handle) {
|
|
112
|
+
clearTimeout(timeoutRef.handle);
|
|
113
|
+
}
|
|
114
|
+
if (rlRef.handle) {
|
|
115
|
+
rlRef.handle.close();
|
|
116
|
+
}
|
|
117
|
+
server.close();
|
|
118
|
+
fn();
|
|
119
|
+
};
|
|
120
|
+
const server = http.createServer((req, res) => {
|
|
121
|
+
applyCorsHeaders(res);
|
|
122
|
+
if (req.method === "OPTIONS") {
|
|
123
|
+
res.statusCode = 204;
|
|
124
|
+
res.end();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
128
|
+
if (url.pathname !== opts.callbackPath) {
|
|
129
|
+
res.statusCode = 404;
|
|
130
|
+
res.end("Not Found");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const error = url.searchParams.get("error");
|
|
134
|
+
const code = url.searchParams.get("code");
|
|
135
|
+
const state = url.searchParams.get("state");
|
|
136
|
+
if (!state || !timingSafeEqualStrings(state, opts.expectedState)) {
|
|
137
|
+
res.statusCode = 400;
|
|
138
|
+
res.setHeader("Content-Type", "text/html");
|
|
139
|
+
res.end(ERROR_HTML);
|
|
140
|
+
settle(() => {
|
|
141
|
+
reject(new Error("OAuth callback state mismatch (possible CSRF or stale request)"));
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (error || !code) {
|
|
146
|
+
res.statusCode = 400;
|
|
147
|
+
res.setHeader("Content-Type", "text/html");
|
|
148
|
+
res.end(ERROR_HTML);
|
|
149
|
+
settle(() => {
|
|
150
|
+
reject(
|
|
151
|
+
new Error(error ? `OAuth callback returned error=${error}` : "OAuth callback missing code")
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.statusCode = 200;
|
|
157
|
+
res.setHeader("Content-Type", "text/html");
|
|
158
|
+
res.end(SUCCESS_HTML);
|
|
159
|
+
settle(() => {
|
|
160
|
+
resolve2({ code });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
server.on("error", (err) => {
|
|
164
|
+
settle(() => {
|
|
165
|
+
reject(err);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
server.listen(0, "127.0.0.1", () => {
|
|
169
|
+
const port = server.address().port;
|
|
170
|
+
Promise.resolve(opts.onListen(port)).catch((err) => {
|
|
171
|
+
settle(() => {
|
|
172
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
timeoutRef.handle = setTimeout(() => {
|
|
177
|
+
settle(() => {
|
|
178
|
+
reject(new Error(`Timed out waiting for OAuth callback after ${String(opts.timeoutMs)}ms`));
|
|
179
|
+
});
|
|
180
|
+
}, opts.timeoutMs);
|
|
181
|
+
timeoutRef.handle.unref();
|
|
182
|
+
if (opts.pasteInput) {
|
|
183
|
+
const rl = readline.createInterface({ input: opts.pasteInput });
|
|
184
|
+
rlRef.handle = rl;
|
|
185
|
+
rl.on("line", (raw) => {
|
|
186
|
+
const trimmed = raw.trim();
|
|
187
|
+
if (!trimmed) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const dot = trimmed.lastIndexOf(".");
|
|
191
|
+
if (dot < 1 || dot === trimmed.length - 1) {
|
|
192
|
+
opts.logger(
|
|
193
|
+
"Pasted token is missing a state suffix; expected `<code>.<state>`. Re-copy the value shown on the /auth/cli page."
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const code = trimmed.slice(0, dot);
|
|
198
|
+
const state = trimmed.slice(dot + 1);
|
|
199
|
+
if (!timingSafeEqualStrings(state, opts.expectedState)) {
|
|
200
|
+
opts.logger(
|
|
201
|
+
"Pasted token state does not match this CLI session. Refusing to exchange the code."
|
|
202
|
+
);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
settle(() => {
|
|
206
|
+
opts.logger("Captured pasted token; finishing sign-in...");
|
|
207
|
+
resolve2({ code });
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function timingSafeEqualStrings(a, b) {
|
|
214
|
+
if (a.length !== b.length) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
let mismatch = 0;
|
|
218
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
219
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
220
|
+
}
|
|
221
|
+
return mismatch === 0;
|
|
222
|
+
}
|
|
223
|
+
async function exchangeCode(userAuthUrl, code, fetchImpl) {
|
|
224
|
+
const response = await fetchImpl(`${userAuthUrl}/api/auth/oauth/exchange`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "Content-Type": "application/json" },
|
|
227
|
+
body: JSON.stringify({ code })
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) {
|
|
230
|
+
const body = await response.text().catch(() => "");
|
|
231
|
+
throw new HttpStatusError(response.status, body, `OAuth code exchange failed: HTTP ${String(response.status)}`);
|
|
232
|
+
}
|
|
233
|
+
return await response.json();
|
|
234
|
+
}
|
|
235
|
+
|
|
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
|
+
platformUrl: opts.platformUrl,
|
|
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
|
+
// src/auth/credentials-store.ts
|
|
282
|
+
import { promises as fs } from "fs";
|
|
283
|
+
import os from "os";
|
|
284
|
+
import path from "path";
|
|
285
|
+
function defaultCredentialsPath(env = process.env) {
|
|
286
|
+
if (process.platform === "win32") {
|
|
287
|
+
const appData = env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
288
|
+
return path.join(appData, "myspec", "mcp-server.json");
|
|
289
|
+
}
|
|
290
|
+
const xdg = env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
|
|
291
|
+
return path.join(xdg, "myspec", "mcp-server.json");
|
|
292
|
+
}
|
|
293
|
+
function createFileCredentialsStore(filePath) {
|
|
294
|
+
const target = filePath ?? defaultCredentialsPath();
|
|
295
|
+
return {
|
|
296
|
+
path: () => target,
|
|
297
|
+
async load() {
|
|
298
|
+
try {
|
|
299
|
+
if (process.platform !== "win32") {
|
|
300
|
+
const stat = await fs.stat(target);
|
|
301
|
+
if ((stat.mode & 63) !== 0) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`Refusing to load credentials from ${target}: file mode is too permissive (${(stat.mode & 511).toString(8)}). Run \`chmod 600 ${target}\` and retry.`
|
|
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;
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
async save(creds) {
|
|
318
|
+
const dir = path.dirname(target);
|
|
319
|
+
await fs.mkdir(dir, { recursive: true, mode: 448 });
|
|
320
|
+
await fs.writeFile(target, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
321
|
+
if (process.platform !== "win32") {
|
|
322
|
+
await fs.chmod(target, 384);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
async clear() {
|
|
326
|
+
try {
|
|
327
|
+
await fs.unlink(target);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (!isFsNotFound(err)) {
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
var DEFAULT_USER_AUTH_URL = "https://auth.myspec.dev";
|
|
337
|
+
function readEnvRefreshConfig(env = process.env) {
|
|
338
|
+
if (env.MYSPEC_ACCESS_TOKEN || env.MYSPEC_ACCESS_TOKEN_EXPIRES_AT) {
|
|
339
|
+
throw new ConfigError(
|
|
340
|
+
"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."
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (!env.MYSPEC_REFRESH_TOKEN) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
refreshToken: env.MYSPEC_REFRESH_TOKEN,
|
|
348
|
+
userAuthUrl: env.MYSPEC_USER_AUTH_URL ?? DEFAULT_USER_AUTH_URL
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function createCompositeCredentialsStore(opts) {
|
|
352
|
+
const { fileStore, envConfig } = opts;
|
|
353
|
+
const envRefreshToken = envConfig?.refreshToken;
|
|
354
|
+
const envUserAuthUrl = envConfig?.userAuthUrl ?? DEFAULT_USER_AUTH_URL;
|
|
355
|
+
return {
|
|
356
|
+
path: () => fileStore.path(),
|
|
357
|
+
async load() {
|
|
358
|
+
const fromFile = await fileStore.load();
|
|
359
|
+
if (fromFile) {
|
|
360
|
+
return fromFile;
|
|
361
|
+
}
|
|
362
|
+
if (!envRefreshToken) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
accessToken: "",
|
|
367
|
+
refreshToken: envRefreshToken,
|
|
368
|
+
expiresAt: 0,
|
|
369
|
+
userAuthUrl: envUserAuthUrl,
|
|
370
|
+
user: { id: "env", email: "env@mcp" }
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
save(creds) {
|
|
374
|
+
return fileStore.save(creds);
|
|
375
|
+
},
|
|
376
|
+
clear() {
|
|
377
|
+
return fileStore.clear();
|
|
378
|
+
},
|
|
379
|
+
envRefreshToken: () => envRefreshToken,
|
|
380
|
+
envUserAuthUrl: () => envUserAuthUrl
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function parseCredentials(value) {
|
|
384
|
+
if (typeof value !== "object" || value === null) {
|
|
385
|
+
throw new Error("Credentials file is malformed (not an object)");
|
|
386
|
+
}
|
|
387
|
+
const v = value;
|
|
388
|
+
const accessToken = requireString(v, "accessToken");
|
|
389
|
+
const refreshToken = requireString(v, "refreshToken");
|
|
390
|
+
const expiresAt = requireNumber(v, "expiresAt");
|
|
391
|
+
const userAuthUrl = requireString(v, "userAuthUrl");
|
|
392
|
+
const platformUrl = typeof v.platformUrl === "string" ? v.platformUrl : void 0;
|
|
393
|
+
const userRaw = v.user;
|
|
394
|
+
if (typeof userRaw !== "object" || userRaw === null) {
|
|
395
|
+
throw new Error("Credentials file is malformed (missing user)");
|
|
396
|
+
}
|
|
397
|
+
const userObj = userRaw;
|
|
398
|
+
const user = {
|
|
399
|
+
id: requireString(userObj, "id"),
|
|
400
|
+
email: requireString(userObj, "email"),
|
|
401
|
+
name: typeof userObj.name === "string" ? userObj.name : void 0
|
|
402
|
+
};
|
|
403
|
+
return { accessToken, refreshToken, expiresAt, userAuthUrl, platformUrl, user };
|
|
404
|
+
}
|
|
405
|
+
function requireString(obj, key) {
|
|
406
|
+
const value = obj[key];
|
|
407
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
408
|
+
throw new Error(`Credentials file is malformed (missing ${key})`);
|
|
409
|
+
}
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
function requireNumber(obj, key) {
|
|
413
|
+
const value = obj[key];
|
|
414
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
415
|
+
throw new Error(`Credentials file is malformed (invalid ${key})`);
|
|
416
|
+
}
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
function isFsNotFound(err) {
|
|
420
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/config.ts
|
|
424
|
+
function resolveConfig(sources = {}) {
|
|
425
|
+
const env = sources.env ?? process.env;
|
|
426
|
+
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://api.myspec.dev";
|
|
428
|
+
const webappUrl = sources.cliWebappUrl ?? env.MYSPEC_WEBAPP_URL ?? deriveWebappFromAuth(userAuthUrl);
|
|
429
|
+
validateUrl(userAuthUrl, "user-auth URL");
|
|
430
|
+
validateUrl(platformUrl, "platform URL");
|
|
431
|
+
validateUrl(webappUrl, "webapp URL");
|
|
432
|
+
return {
|
|
433
|
+
userAuthUrl: stripTrailingSlash(userAuthUrl),
|
|
434
|
+
platformUrl: stripTrailingSlash(platformUrl),
|
|
435
|
+
webappUrl: stripTrailingSlash(webappUrl)
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function deriveWebappFromAuth(userAuthUrl) {
|
|
439
|
+
try {
|
|
440
|
+
const parsed = new URL(userAuthUrl);
|
|
441
|
+
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
|
442
|
+
return `${parsed.protocol}//${parsed.hostname}:5173`;
|
|
443
|
+
}
|
|
444
|
+
parsed.hostname = parsed.hostname.replace(/^auth\./, "app.").replace(/^dev-auth\./, "dev-app.").replace(/^staging-auth\./, "staging-app.");
|
|
445
|
+
return parsed.toString().replace(/\/$/, "");
|
|
446
|
+
} catch {
|
|
447
|
+
return "https://app.myspec.dev";
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function validateUrl(value, label) {
|
|
451
|
+
let parsed;
|
|
452
|
+
try {
|
|
453
|
+
parsed = new URL(value);
|
|
454
|
+
} catch {
|
|
455
|
+
throw new ConfigError(`${label} is not a valid URL: ${value}`);
|
|
456
|
+
}
|
|
457
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
458
|
+
throw new ConfigError(`${label} must use http or https: ${value}`);
|
|
459
|
+
}
|
|
460
|
+
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
461
|
+
throw new ConfigError(
|
|
462
|
+
`${label} must use https:// for non-loopback hosts (got http://${parsed.hostname})`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function isLoopbackHost(hostname) {
|
|
467
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
468
|
+
}
|
|
469
|
+
function stripTrailingSlash(value) {
|
|
470
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/cli/login.ts
|
|
474
|
+
async function runLogin(options) {
|
|
475
|
+
const config = resolveConfig({
|
|
476
|
+
cliUserAuthUrl: options.userAuthUrl,
|
|
477
|
+
cliPlatformUrl: options.platformUrl,
|
|
478
|
+
cliWebappUrl: options.webappUrl
|
|
479
|
+
});
|
|
480
|
+
const store = createFileCredentialsStore();
|
|
481
|
+
const credentials = options.paste ? await pasteLogin({
|
|
482
|
+
userAuthUrl: config.userAuthUrl,
|
|
483
|
+
platformUrl: config.platformUrl,
|
|
484
|
+
webappUrl: config.webappUrl
|
|
485
|
+
}) : await loopbackLogin({
|
|
486
|
+
userAuthUrl: config.userAuthUrl,
|
|
487
|
+
platformUrl: config.platformUrl,
|
|
488
|
+
webappUrl: config.webappUrl,
|
|
489
|
+
openBrowser: async (url) => {
|
|
490
|
+
const mod = await import("open");
|
|
491
|
+
await mod.default(url);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
await store.save(credentials);
|
|
495
|
+
process.stderr.write(`Signed in as ${credentials.user.email}.
|
|
496
|
+
`);
|
|
497
|
+
process.stderr.write(`Credentials saved to ${store.path()}
|
|
498
|
+
`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/cli/logout.ts
|
|
502
|
+
async function runLogout() {
|
|
503
|
+
const store = createFileCredentialsStore();
|
|
504
|
+
const existing = await store.load();
|
|
505
|
+
if (!existing) {
|
|
506
|
+
process.stderr.write("No credentials to clear.\n");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const config = resolveConfig({ storedUserAuthUrl: existing.userAuthUrl });
|
|
510
|
+
try {
|
|
511
|
+
await fetch(`${config.userAuthUrl}/api/auth/logout`, {
|
|
512
|
+
method: "POST",
|
|
513
|
+
headers: {
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
Authorization: `Bearer ${existing.accessToken}`
|
|
516
|
+
},
|
|
517
|
+
body: JSON.stringify({ refreshToken: existing.refreshToken }),
|
|
518
|
+
signal: AbortSignal.timeout(5e3)
|
|
519
|
+
});
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
await store.clear();
|
|
523
|
+
process.stderr.write("Signed out and cleared local credentials.\n");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/auth/token-manager.ts
|
|
527
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
528
|
+
var TokenManager = class {
|
|
529
|
+
store;
|
|
530
|
+
fetchImpl;
|
|
531
|
+
now;
|
|
532
|
+
envFallback;
|
|
533
|
+
cached = null;
|
|
534
|
+
refreshInFlight = null;
|
|
535
|
+
constructor(deps) {
|
|
536
|
+
this.store = deps.store;
|
|
537
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
538
|
+
this.now = deps.now ?? Date.now;
|
|
539
|
+
this.envFallback = deps.envFallback ?? null;
|
|
540
|
+
}
|
|
541
|
+
async getValidAccessToken() {
|
|
542
|
+
const creds = await this.ensureLoaded();
|
|
543
|
+
if (creds.expiresAt > this.now() + EXPIRY_SKEW_MS) {
|
|
544
|
+
return creds.accessToken;
|
|
545
|
+
}
|
|
546
|
+
const refreshed = await this.refresh(creds);
|
|
547
|
+
return refreshed.accessToken;
|
|
548
|
+
}
|
|
549
|
+
async getCredentials() {
|
|
550
|
+
return this.ensureLoaded();
|
|
551
|
+
}
|
|
552
|
+
async forceRefresh() {
|
|
553
|
+
const creds = await this.ensureLoaded();
|
|
554
|
+
const refreshed = await this.refresh(creds);
|
|
555
|
+
return refreshed.accessToken;
|
|
556
|
+
}
|
|
557
|
+
async clear() {
|
|
558
|
+
this.cached = null;
|
|
559
|
+
await this.store.clear();
|
|
560
|
+
}
|
|
561
|
+
async ensureLoaded() {
|
|
562
|
+
if (this.cached) {
|
|
563
|
+
return this.cached;
|
|
564
|
+
}
|
|
565
|
+
const loaded = await this.store.load();
|
|
566
|
+
if (!loaded) {
|
|
567
|
+
throw new NeedsLoginError();
|
|
568
|
+
}
|
|
569
|
+
this.cached = loaded;
|
|
570
|
+
return loaded;
|
|
571
|
+
}
|
|
572
|
+
async refresh(creds) {
|
|
573
|
+
if (this.refreshInFlight) {
|
|
574
|
+
return this.refreshInFlight;
|
|
575
|
+
}
|
|
576
|
+
this.refreshInFlight = this.doRefresh(creds).finally(() => {
|
|
577
|
+
this.refreshInFlight = null;
|
|
578
|
+
});
|
|
579
|
+
return this.refreshInFlight;
|
|
580
|
+
}
|
|
581
|
+
async doRefresh(creds) {
|
|
582
|
+
try {
|
|
583
|
+
return await this.performRefresh(creds);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
if (err instanceof RefreshRejectedError && this.envFallback && this.envFallback.refreshToken !== creds.refreshToken) {
|
|
586
|
+
const bootstrap = {
|
|
587
|
+
accessToken: "",
|
|
588
|
+
refreshToken: this.envFallback.refreshToken,
|
|
589
|
+
expiresAt: 0,
|
|
590
|
+
userAuthUrl: this.envFallback.userAuthUrl,
|
|
591
|
+
platformUrl: creds.platformUrl,
|
|
592
|
+
user: creds.user
|
|
593
|
+
};
|
|
594
|
+
try {
|
|
595
|
+
return await this.performRefresh(bootstrap);
|
|
596
|
+
} catch (retryErr) {
|
|
597
|
+
await this.invalidateOnAuthFailure(retryErr);
|
|
598
|
+
throw retryErr;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
await this.invalidateOnAuthFailure(err);
|
|
602
|
+
throw err;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async invalidateOnAuthFailure(err) {
|
|
606
|
+
if (err instanceof NeedsLoginError) {
|
|
607
|
+
this.cached = null;
|
|
608
|
+
await this.store.clear();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async performRefresh(creds) {
|
|
612
|
+
const url = `${creds.userAuthUrl}/api/auth/token/refresh`;
|
|
613
|
+
const response = await this.fetchImpl(url, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: { "Content-Type": "application/json" },
|
|
616
|
+
body: JSON.stringify({ refreshToken: creds.refreshToken })
|
|
617
|
+
});
|
|
618
|
+
if (response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422) {
|
|
619
|
+
throw new RefreshRejectedError(
|
|
620
|
+
"Refresh token rejected. Run `npx @myspec/mcp-server login` to sign in again."
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (!response.ok) {
|
|
624
|
+
const body = await response.text().catch(() => "");
|
|
625
|
+
throw new HttpStatusError(response.status, body, `Token refresh failed: HTTP ${String(response.status)}`);
|
|
626
|
+
}
|
|
627
|
+
const payload = await response.json().catch(() => null);
|
|
628
|
+
if (!payload || typeof payload.accessToken !== "string" || typeof payload.refreshToken !== "string" || typeof payload.expiresIn !== "number") {
|
|
629
|
+
throw new NeedsLoginError(
|
|
630
|
+
"Token refresh response was malformed. Run `npx @myspec/mcp-server login` to sign in again."
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const next = {
|
|
634
|
+
...creds,
|
|
635
|
+
accessToken: payload.accessToken,
|
|
636
|
+
refreshToken: payload.refreshToken,
|
|
637
|
+
expiresAt: this.now() + payload.expiresIn * 1e3
|
|
638
|
+
};
|
|
639
|
+
this.cached = next;
|
|
640
|
+
await this.store.save(next);
|
|
641
|
+
return next;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
var RefreshRejectedError = class extends NeedsLoginError {
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// ../../packages/platform-client/shared/types.ts
|
|
648
|
+
var RETRYABLE_STATUS_CODES = [408, 429, 502, 503, 504];
|
|
649
|
+
|
|
650
|
+
// ../../packages/platform-client/shared/retry.ts
|
|
651
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
652
|
+
initialDelayMs: 100,
|
|
653
|
+
multiplier: 2,
|
|
654
|
+
maxDelayMs: 1e4,
|
|
655
|
+
maxAttempts: 3,
|
|
656
|
+
jitterFactor: 0.1
|
|
657
|
+
};
|
|
658
|
+
var defaultShouldRetry = () => true;
|
|
659
|
+
function calculateDelay(attempt, config) {
|
|
660
|
+
const baseDelay = config.initialDelayMs * Math.pow(config.multiplier, attempt);
|
|
661
|
+
const cappedDelay = Math.min(baseDelay, config.maxDelayMs);
|
|
662
|
+
const jitterMultiplier = 1 + (Math.random() * 2 - 1) * config.jitterFactor;
|
|
663
|
+
return Math.round(cappedDelay * jitterMultiplier);
|
|
664
|
+
}
|
|
665
|
+
function sleep(ms) {
|
|
666
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
667
|
+
}
|
|
668
|
+
async function withRetry(fn, config = DEFAULT_RETRY_CONFIG, shouldRetry = defaultShouldRetry) {
|
|
669
|
+
const startTime = Date.now();
|
|
670
|
+
let lastError;
|
|
671
|
+
let attempt = 0;
|
|
672
|
+
while (attempt < config.maxAttempts) {
|
|
673
|
+
try {
|
|
674
|
+
const value = await fn();
|
|
675
|
+
return {
|
|
676
|
+
success: true,
|
|
677
|
+
value,
|
|
678
|
+
attempts: attempt + 1,
|
|
679
|
+
totalTimeMs: Date.now() - startTime
|
|
680
|
+
};
|
|
681
|
+
} catch (err) {
|
|
682
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
683
|
+
attempt++;
|
|
684
|
+
if (attempt >= config.maxAttempts || !shouldRetry(lastError, attempt - 1)) {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
const delay = calculateDelay(attempt - 1, config);
|
|
688
|
+
await sleep(delay);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
success: false,
|
|
693
|
+
error: lastError,
|
|
694
|
+
attempts: attempt,
|
|
695
|
+
totalTimeMs: Date.now() - startTime
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ../../packages/shared/logger.ts
|
|
700
|
+
var LOG_LEVEL_PRIORITY = {
|
|
701
|
+
debug: 0,
|
|
702
|
+
info: 1,
|
|
703
|
+
warn: 2,
|
|
704
|
+
error: 3
|
|
705
|
+
};
|
|
706
|
+
var ConsoleLogger = class {
|
|
707
|
+
minLevel;
|
|
708
|
+
constructor(minLevel = "info") {
|
|
709
|
+
this.minLevel = minLevel;
|
|
710
|
+
}
|
|
711
|
+
debug(message, meta) {
|
|
712
|
+
this.log("debug", message, meta);
|
|
713
|
+
}
|
|
714
|
+
info(message, meta) {
|
|
715
|
+
this.log("info", message, meta);
|
|
716
|
+
}
|
|
717
|
+
warn(message, meta) {
|
|
718
|
+
this.log("warn", message, meta);
|
|
719
|
+
}
|
|
720
|
+
error(message, error, meta) {
|
|
721
|
+
const errorDetails = error ? {
|
|
722
|
+
error: {
|
|
723
|
+
message: error.message,
|
|
724
|
+
stack: error.stack
|
|
725
|
+
}
|
|
726
|
+
} : void 0;
|
|
727
|
+
this.log("error", message, { ...errorDetails, ...meta });
|
|
728
|
+
}
|
|
729
|
+
log(level, message, meta) {
|
|
730
|
+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.minLevel]) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const entry = {
|
|
734
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
735
|
+
level,
|
|
736
|
+
message,
|
|
737
|
+
...meta
|
|
738
|
+
};
|
|
739
|
+
const output = JSON.stringify(entry);
|
|
740
|
+
if (level === "error") {
|
|
741
|
+
console.error(output);
|
|
742
|
+
} else if (level === "warn") {
|
|
743
|
+
console.warn(output);
|
|
744
|
+
} else {
|
|
745
|
+
console.log(output);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// ../../packages/platform-client/shared/http-base.ts
|
|
751
|
+
var DEFAULT_HTTP_CONFIG = {
|
|
752
|
+
timeout: 3e4,
|
|
753
|
+
retryConfig: DEFAULT_RETRY_CONFIG
|
|
754
|
+
};
|
|
755
|
+
var STANDARD_HEADERS = {
|
|
756
|
+
"Content-Type": "application/json",
|
|
757
|
+
Accept: "application/json"
|
|
758
|
+
};
|
|
759
|
+
var BaseHttpClient = class {
|
|
760
|
+
config;
|
|
761
|
+
logger;
|
|
762
|
+
constructor(config) {
|
|
763
|
+
this.logger = config.logger ?? new ConsoleLogger();
|
|
764
|
+
this.config = {
|
|
765
|
+
...DEFAULT_HTTP_CONFIG,
|
|
766
|
+
...config,
|
|
767
|
+
retryConfig: config.retryConfig ?? DEFAULT_HTTP_CONFIG.retryConfig
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Makes a GET request.
|
|
772
|
+
*/
|
|
773
|
+
async get(path4, jwt, options) {
|
|
774
|
+
return this.request("GET", path4, void 0, jwt, options);
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Makes a POST request.
|
|
778
|
+
*/
|
|
779
|
+
async post(path4, body, jwt, options) {
|
|
780
|
+
return this.request("POST", path4, JSON.stringify(body), jwt, options);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Makes a PUT request.
|
|
784
|
+
*/
|
|
785
|
+
async put(path4, body, jwt, options) {
|
|
786
|
+
return this.request("PUT", path4, JSON.stringify(body), jwt, options);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Makes a PATCH request.
|
|
790
|
+
*/
|
|
791
|
+
async patch(path4, body, jwt, options) {
|
|
792
|
+
return this.request("PATCH", path4, JSON.stringify(body), jwt, options);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Makes a DELETE request.
|
|
796
|
+
*/
|
|
797
|
+
async delete(path4, jwt, options) {
|
|
798
|
+
return this.request("DELETE", path4, void 0, jwt, options);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Checks if the client is configured and ready.
|
|
802
|
+
*/
|
|
803
|
+
isReady() {
|
|
804
|
+
return !!this.config.baseUrl;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Gets the base URL.
|
|
808
|
+
*/
|
|
809
|
+
getBaseUrl() {
|
|
810
|
+
return this.config.baseUrl;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Makes an HTTP request with retry logic.
|
|
814
|
+
*/
|
|
815
|
+
async request(method, path4, body, jwt, options) {
|
|
816
|
+
const result = await withRetry(
|
|
817
|
+
() => this.executeRequest(method, path4, body, jwt, options),
|
|
818
|
+
this.config.retryConfig,
|
|
819
|
+
this.shouldRetry.bind(this)
|
|
820
|
+
);
|
|
821
|
+
if (!result.success) {
|
|
822
|
+
const error = result.error ?? new Error("Unknown error");
|
|
823
|
+
this.logger.error(`${method} ${path4} failed after ${result.attempts} attempts`, error, {
|
|
824
|
+
method,
|
|
825
|
+
path: path4,
|
|
826
|
+
attempts: result.attempts
|
|
827
|
+
});
|
|
828
|
+
const wrapped = new Error(
|
|
829
|
+
`${method} ${path4} failed after ${result.attempts} attempts: ${error.message}`
|
|
830
|
+
);
|
|
831
|
+
const originalStatus = error.status;
|
|
832
|
+
if (originalStatus !== void 0) {
|
|
833
|
+
wrapped.status = originalStatus;
|
|
834
|
+
}
|
|
835
|
+
throw wrapped;
|
|
836
|
+
}
|
|
837
|
+
return result.value;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Executes a single HTTP request.
|
|
841
|
+
*/
|
|
842
|
+
async executeRequest(method, path4, body, jwt, options) {
|
|
843
|
+
const url = this.buildUrl(path4);
|
|
844
|
+
const headers = this.buildHeaders(jwt, options);
|
|
845
|
+
const timeout = options.timeout ?? this.config.timeout;
|
|
846
|
+
const controller = new AbortController();
|
|
847
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
848
|
+
try {
|
|
849
|
+
const response = await fetch(url, {
|
|
850
|
+
method,
|
|
851
|
+
headers,
|
|
852
|
+
body,
|
|
853
|
+
signal: controller.signal
|
|
854
|
+
});
|
|
855
|
+
if (!response.ok) {
|
|
856
|
+
let errorMessage2;
|
|
857
|
+
const rawBody = await response.text().catch(() => "");
|
|
858
|
+
if (rawBody) {
|
|
859
|
+
try {
|
|
860
|
+
const errorBody = JSON.parse(rawBody);
|
|
861
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
862
|
+
errorMessage2 = (errorBody.message ?? JSON.stringify(errorBody)) + details;
|
|
863
|
+
} catch {
|
|
864
|
+
const MAX_ERROR_LENGTH = 500;
|
|
865
|
+
errorMessage2 = rawBody.length > MAX_ERROR_LENGTH ? rawBody.slice(0, MAX_ERROR_LENGTH) + "... [truncated]" : rawBody;
|
|
866
|
+
}
|
|
867
|
+
} else {
|
|
868
|
+
errorMessage2 = "Unknown error";
|
|
869
|
+
}
|
|
870
|
+
const error = new Error(`HTTP ${response.status}: ${errorMessage2}`);
|
|
871
|
+
error.status = response.status;
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
const contentLength = response.headers.get("Content-Length");
|
|
875
|
+
if (contentLength === "0" || response.status === 204) {
|
|
876
|
+
return {};
|
|
877
|
+
}
|
|
878
|
+
return await response.json();
|
|
879
|
+
} finally {
|
|
880
|
+
clearTimeout(timeoutId);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Builds the full URL for a request.
|
|
885
|
+
*/
|
|
886
|
+
buildUrl(path4) {
|
|
887
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
888
|
+
const normalizedPath = path4.startsWith("/") ? path4 : `/${path4}`;
|
|
889
|
+
return `${baseUrl}${normalizedPath}`;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Builds headers for a request including JWT auth.
|
|
893
|
+
*/
|
|
894
|
+
buildHeaders(jwt, options) {
|
|
895
|
+
const headers = new Headers({
|
|
896
|
+
...STANDARD_HEADERS,
|
|
897
|
+
Authorization: `Bearer ${jwt}`
|
|
898
|
+
});
|
|
899
|
+
if (options.headers) {
|
|
900
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
901
|
+
headers.set(key, value);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return headers;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Determines if a failed request should be retried.
|
|
908
|
+
*/
|
|
909
|
+
shouldRetry(error) {
|
|
910
|
+
if (error.name === "AbortError") {
|
|
911
|
+
this.logger.debug("Retrying after abort/timeout", { error: error.message });
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
const httpError = error;
|
|
915
|
+
if (httpError.status !== void 0) {
|
|
916
|
+
const shouldRetry = RETRYABLE_STATUS_CODES.includes(httpError.status);
|
|
917
|
+
if (shouldRetry) {
|
|
918
|
+
this.logger.debug("Retrying after HTTP error", { status: httpError.status });
|
|
919
|
+
}
|
|
920
|
+
return shouldRetry;
|
|
921
|
+
}
|
|
922
|
+
if (error.message.includes("fetch") || error.message.includes("network")) {
|
|
923
|
+
this.logger.debug("Retrying after network error", { error: error.message });
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// ../../packages/platform-client/project/types/attachment.types.ts
|
|
931
|
+
var MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
932
|
+
function transformAttachmentResponse(raw) {
|
|
933
|
+
return {
|
|
934
|
+
id: raw.id,
|
|
935
|
+
projectId: raw.project_id,
|
|
936
|
+
org: raw.org,
|
|
937
|
+
uploadedBy: raw.uploaded_by,
|
|
938
|
+
name: raw.name,
|
|
939
|
+
mimeType: raw.mime_type,
|
|
940
|
+
fileSizeBytes: raw.file_size_bytes,
|
|
941
|
+
checksum: raw.checksum,
|
|
942
|
+
createdAt: new Date(raw.created_at),
|
|
943
|
+
deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ../../packages/platform-client/project/types/file.types.ts
|
|
948
|
+
function normalizeFileType(raw) {
|
|
949
|
+
return raw === "design" ? "solution" : raw;
|
|
950
|
+
}
|
|
951
|
+
function transformSpecFileResponse(raw) {
|
|
952
|
+
return {
|
|
953
|
+
id: raw.id,
|
|
954
|
+
projectId: raw.project_id,
|
|
955
|
+
sessionId: raw.session_id,
|
|
956
|
+
org: raw.org,
|
|
957
|
+
fileType: normalizeFileType(raw.file_type),
|
|
958
|
+
filePath: raw.file_path,
|
|
959
|
+
fileUri: raw.file_uri,
|
|
960
|
+
fileSizeBytes: raw.file_size_bytes,
|
|
961
|
+
checksum: raw.checksum,
|
|
962
|
+
revisionCount: raw.revision_count,
|
|
963
|
+
createdAt: new Date(raw.created_at),
|
|
964
|
+
updatedAt: new Date(raw.updated_at),
|
|
965
|
+
deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
function transformFileRevisionResponse(raw) {
|
|
969
|
+
return {
|
|
970
|
+
id: raw.id,
|
|
971
|
+
specFileId: raw.spec_file_id,
|
|
972
|
+
org: raw.org,
|
|
973
|
+
revisionNumber: raw.revision_number,
|
|
974
|
+
fileUri: raw.file_uri,
|
|
975
|
+
fileSizeBytes: raw.file_size_bytes,
|
|
976
|
+
checksum: raw.checksum,
|
|
977
|
+
changedBy: raw.changed_by,
|
|
978
|
+
createdAt: new Date(raw.created_at)
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
function transformFileListResult(raw) {
|
|
982
|
+
return {
|
|
983
|
+
files: raw.files.map(transformSpecFileResponse),
|
|
984
|
+
total: raw.total,
|
|
985
|
+
limit: raw.limit,
|
|
986
|
+
offset: raw.offset
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ../../packages/platform-client/project/types/project.types.ts
|
|
991
|
+
function transformProjectResponse(raw) {
|
|
992
|
+
return {
|
|
993
|
+
id: raw.id,
|
|
994
|
+
org: raw.org,
|
|
995
|
+
ownerId: raw.owner_id,
|
|
996
|
+
name: raw.name,
|
|
997
|
+
description: raw.description,
|
|
998
|
+
status: raw.status,
|
|
999
|
+
metadata: raw.metadata,
|
|
1000
|
+
createdAt: new Date(raw.created_at),
|
|
1001
|
+
updatedAt: new Date(raw.updated_at),
|
|
1002
|
+
deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ../../packages/platform-client/project/types/session.types.ts
|
|
1007
|
+
function transformSpecSessionResponse(raw) {
|
|
1008
|
+
return {
|
|
1009
|
+
id: raw.id,
|
|
1010
|
+
projectId: raw.project_id,
|
|
1011
|
+
projectName: raw.project_name,
|
|
1012
|
+
org: raw.org,
|
|
1013
|
+
userId: raw.user_id,
|
|
1014
|
+
status: raw.status,
|
|
1015
|
+
messageCount: raw.message_count,
|
|
1016
|
+
tokenCount: raw.token_count,
|
|
1017
|
+
context: raw.context,
|
|
1018
|
+
startedAt: new Date(raw.started_at),
|
|
1019
|
+
completedAt: raw.completed_at ? new Date(raw.completed_at) : void 0,
|
|
1020
|
+
archivedAt: raw.archived_at ? new Date(raw.archived_at) : void 0,
|
|
1021
|
+
createdAt: new Date(raw.created_at),
|
|
1022
|
+
updatedAt: new Date(raw.updated_at),
|
|
1023
|
+
deletedAt: raw.deleted_at ? new Date(raw.deleted_at) : void 0
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ../../packages/platform-client/project/http/attachment.http-client.ts
|
|
1028
|
+
var UPLOAD_TIMEOUT_MS = 3e4;
|
|
1029
|
+
var HttpAttachmentClient = class {
|
|
1030
|
+
httpClient;
|
|
1031
|
+
constructor(httpClient) {
|
|
1032
|
+
this.httpClient = httpClient;
|
|
1033
|
+
}
|
|
1034
|
+
async listByProject(projectId, jwtToken) {
|
|
1035
|
+
const path4 = `/project/v1/attachments?project_id=${encodeURIComponent(projectId)}`;
|
|
1036
|
+
const response = await this.httpClient.get(
|
|
1037
|
+
path4,
|
|
1038
|
+
jwtToken,
|
|
1039
|
+
{}
|
|
1040
|
+
);
|
|
1041
|
+
return response.attachments.map(transformAttachmentResponse);
|
|
1042
|
+
}
|
|
1043
|
+
async getById(attachmentId, jwtToken) {
|
|
1044
|
+
const raw = await this.httpClient.get(
|
|
1045
|
+
`/project/v1/attachments/${attachmentId}`,
|
|
1046
|
+
jwtToken,
|
|
1047
|
+
{}
|
|
1048
|
+
);
|
|
1049
|
+
return transformAttachmentResponse(raw);
|
|
1050
|
+
}
|
|
1051
|
+
async getDownloadUrl(attachmentId, jwtToken) {
|
|
1052
|
+
const response = await this.httpClient.get(
|
|
1053
|
+
`/project/v1/attachments/${attachmentId}/content`,
|
|
1054
|
+
jwtToken,
|
|
1055
|
+
{ headers: { Accept: "application/json" } }
|
|
1056
|
+
);
|
|
1057
|
+
return {
|
|
1058
|
+
signedUrl: response.signed_url,
|
|
1059
|
+
expiresAt: new Date(response.expires_at)
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
async initiateUpload(request, jwtToken) {
|
|
1063
|
+
const body = {
|
|
1064
|
+
project_id: request.projectId,
|
|
1065
|
+
file_name: request.fileName,
|
|
1066
|
+
mime_type: request.mimeType,
|
|
1067
|
+
file_size_bytes: request.fileSizeBytes,
|
|
1068
|
+
checksum: request.checksum
|
|
1069
|
+
};
|
|
1070
|
+
const response = await this.httpClient.post(
|
|
1071
|
+
"/project/v1/attachments/initiate",
|
|
1072
|
+
body,
|
|
1073
|
+
jwtToken,
|
|
1074
|
+
{}
|
|
1075
|
+
);
|
|
1076
|
+
return { attachmentId: response.attachment_id };
|
|
1077
|
+
}
|
|
1078
|
+
async uploadContent(attachmentId, content, jwtToken) {
|
|
1079
|
+
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1080
|
+
const url = `${baseUrl}/project/v1/attachments/${attachmentId}/content`;
|
|
1081
|
+
const attempt = async () => {
|
|
1082
|
+
const blob = new Blob([content], { type: "application/octet-stream" });
|
|
1083
|
+
const response = await fetch(url, {
|
|
1084
|
+
method: "PUT",
|
|
1085
|
+
headers: {
|
|
1086
|
+
"Content-Type": "application/octet-stream",
|
|
1087
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1088
|
+
},
|
|
1089
|
+
body: blob,
|
|
1090
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
|
|
1091
|
+
});
|
|
1092
|
+
if (!response.ok) {
|
|
1093
|
+
const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
|
|
1094
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1095
|
+
const err = new Error(
|
|
1096
|
+
`HTTP ${String(response.status)}: ${errorBody.message ?? "Upload failed"}${details}`
|
|
1097
|
+
);
|
|
1098
|
+
err.status = response.status;
|
|
1099
|
+
throw err;
|
|
1100
|
+
}
|
|
1101
|
+
const result = await response.json();
|
|
1102
|
+
return {
|
|
1103
|
+
attachmentId: result.attachment_id,
|
|
1104
|
+
fileUri: result.file_uri
|
|
1105
|
+
};
|
|
1106
|
+
};
|
|
1107
|
+
const retryResult = await withRetry(attempt, DEFAULT_RETRY_CONFIG, (error) => {
|
|
1108
|
+
const status = error.status;
|
|
1109
|
+
if (status !== void 0) {
|
|
1110
|
+
return RETRYABLE_STATUS_CODES.includes(status);
|
|
1111
|
+
}
|
|
1112
|
+
return true;
|
|
1113
|
+
});
|
|
1114
|
+
if (!retryResult.success) {
|
|
1115
|
+
throw retryResult.error ?? new Error("Upload failed");
|
|
1116
|
+
}
|
|
1117
|
+
return retryResult.value;
|
|
1118
|
+
}
|
|
1119
|
+
async deleteById(attachmentId, jwtToken) {
|
|
1120
|
+
await this.httpClient.delete(
|
|
1121
|
+
`/project/v1/attachments/${attachmentId}`,
|
|
1122
|
+
jwtToken,
|
|
1123
|
+
{}
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
async downloadContent(attachmentId, jwtToken) {
|
|
1127
|
+
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1128
|
+
const url = `${baseUrl}/project/v1/attachments/${attachmentId}/content`;
|
|
1129
|
+
const response = await fetch(url, {
|
|
1130
|
+
method: "GET",
|
|
1131
|
+
headers: {
|
|
1132
|
+
Accept: "application/octet-stream",
|
|
1133
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
if (!response.ok) {
|
|
1137
|
+
const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
|
|
1138
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1139
|
+
const err = new Error(
|
|
1140
|
+
`HTTP ${String(response.status)}: ${errorBody.message ?? "Download failed"}${details}`
|
|
1141
|
+
);
|
|
1142
|
+
err.status = response.status;
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
const buffer = await response.arrayBuffer();
|
|
1146
|
+
return new Uint8Array(buffer);
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
// ../../packages/platform-client/project/http/file.http-client.ts
|
|
1151
|
+
var HttpFileClient = class {
|
|
1152
|
+
httpClient;
|
|
1153
|
+
constructor(httpClient) {
|
|
1154
|
+
this.httpClient = httpClient;
|
|
1155
|
+
}
|
|
1156
|
+
async initiateUpload(request, jwtToken) {
|
|
1157
|
+
const body = {
|
|
1158
|
+
project_id: request.projectId,
|
|
1159
|
+
session_id: request.sessionId,
|
|
1160
|
+
file_type: request.fileType,
|
|
1161
|
+
file_path: request.filePath,
|
|
1162
|
+
file_size_bytes: request.fileSizeBytes,
|
|
1163
|
+
checksum: request.checksum
|
|
1164
|
+
};
|
|
1165
|
+
const response = await this.httpClient.post(
|
|
1166
|
+
"/project/v1/files/initiate",
|
|
1167
|
+
body,
|
|
1168
|
+
jwtToken,
|
|
1169
|
+
{}
|
|
1170
|
+
);
|
|
1171
|
+
return { fileId: response.file_id };
|
|
1172
|
+
}
|
|
1173
|
+
async uploadContent(fileId, content, jwtToken) {
|
|
1174
|
+
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1175
|
+
const url = `${baseUrl}/project/v1/files/${fileId}/content`;
|
|
1176
|
+
const blob = new Blob([content], { type: "application/octet-stream" });
|
|
1177
|
+
const response = await fetch(url, {
|
|
1178
|
+
method: "PUT",
|
|
1179
|
+
headers: {
|
|
1180
|
+
"Content-Type": "application/octet-stream",
|
|
1181
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1182
|
+
},
|
|
1183
|
+
body: blob
|
|
1184
|
+
});
|
|
1185
|
+
if (!response.ok) {
|
|
1186
|
+
const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
|
|
1187
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
`HTTP ${response.status}: ${errorBody.message ?? "Upload failed"}${details}`
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
const result = await response.json();
|
|
1193
|
+
return {
|
|
1194
|
+
fileId: result.file_id,
|
|
1195
|
+
fileRevisionId: result.file_revision_id,
|
|
1196
|
+
fileUri: result.file_uri,
|
|
1197
|
+
revisionNumber: result.revision_number
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
async saveManualRevision(fileId, content, jwtToken) {
|
|
1201
|
+
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1202
|
+
const url = `${baseUrl}/project/v1/files/${fileId}/revisions`;
|
|
1203
|
+
const blob = new Blob([content], { type: "application/octet-stream" });
|
|
1204
|
+
const response = await fetch(url, {
|
|
1205
|
+
method: "POST",
|
|
1206
|
+
headers: {
|
|
1207
|
+
"Content-Type": "application/octet-stream",
|
|
1208
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1209
|
+
},
|
|
1210
|
+
body: blob
|
|
1211
|
+
});
|
|
1212
|
+
if (!response.ok) {
|
|
1213
|
+
const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
|
|
1214
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
`HTTP ${response.status}: ${errorBody.message ?? "Save failed"}${details}`
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
const result = await response.json();
|
|
1220
|
+
return {
|
|
1221
|
+
fileId: result.file_id,
|
|
1222
|
+
fileRevisionId: result.file_revision_id,
|
|
1223
|
+
// The manual-revision endpoint does not return a current file URI (the FE only
|
|
1224
|
+
// needs the revision id + number); keep the response shape stable with ''.
|
|
1225
|
+
fileUri: "",
|
|
1226
|
+
revisionNumber: result.revision_number
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
async getFile(fileId, jwtToken) {
|
|
1230
|
+
const raw = await this.httpClient.get(
|
|
1231
|
+
`/project/v1/files/${fileId}/metadata`,
|
|
1232
|
+
jwtToken,
|
|
1233
|
+
{}
|
|
1234
|
+
);
|
|
1235
|
+
return transformSpecFileResponse(raw);
|
|
1236
|
+
}
|
|
1237
|
+
async getDownloadUrl(fileId, jwtToken) {
|
|
1238
|
+
const response = await this.httpClient.get(
|
|
1239
|
+
`/project/v1/files/${fileId}`,
|
|
1240
|
+
jwtToken,
|
|
1241
|
+
{ headers: { Accept: "application/json" } }
|
|
1242
|
+
);
|
|
1243
|
+
return {
|
|
1244
|
+
signedUrl: response.signed_url,
|
|
1245
|
+
expiresAt: new Date(response.expires_at)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
async downloadContent(fileId, jwtToken) {
|
|
1249
|
+
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1250
|
+
const url = `${baseUrl}/project/v1/files/${fileId}`;
|
|
1251
|
+
const response = await fetch(url, {
|
|
1252
|
+
method: "GET",
|
|
1253
|
+
headers: {
|
|
1254
|
+
Accept: "application/octet-stream",
|
|
1255
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
if (!response.ok) {
|
|
1259
|
+
const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
|
|
1260
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1261
|
+
throw new Error(
|
|
1262
|
+
`HTTP ${response.status}: ${errorBody.message ?? "Download failed"}${details}`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
const buffer = await response.arrayBuffer();
|
|
1266
|
+
return new Uint8Array(buffer);
|
|
1267
|
+
}
|
|
1268
|
+
async listFiles(projectId, jwtToken, opts) {
|
|
1269
|
+
const queryParams = new URLSearchParams();
|
|
1270
|
+
queryParams.set("project_id", projectId);
|
|
1271
|
+
if (opts?.fileType) queryParams.set("file_type", opts.fileType);
|
|
1272
|
+
if (opts?.query) queryParams.set("q", opts.query);
|
|
1273
|
+
if (opts?.limit !== void 0) queryParams.set("limit", String(opts.limit));
|
|
1274
|
+
if (opts?.offset !== void 0) queryParams.set("offset", String(opts.offset));
|
|
1275
|
+
const query = queryParams.toString();
|
|
1276
|
+
const path4 = `/project/v1/files?${query}`;
|
|
1277
|
+
const raw = await this.httpClient.get(path4, jwtToken, {});
|
|
1278
|
+
return transformFileListResult(raw);
|
|
1279
|
+
}
|
|
1280
|
+
async deleteFile(fileId, jwtToken) {
|
|
1281
|
+
await this.httpClient.delete(
|
|
1282
|
+
`/project/v1/files/${fileId}`,
|
|
1283
|
+
jwtToken,
|
|
1284
|
+
{}
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
async listRevisions(fileId, jwtToken) {
|
|
1288
|
+
const response = await this.httpClient.get(
|
|
1289
|
+
`/project/v1/files/${fileId}/revisions`,
|
|
1290
|
+
jwtToken,
|
|
1291
|
+
{}
|
|
1292
|
+
);
|
|
1293
|
+
return response.revisions.map(transformFileRevisionResponse);
|
|
1294
|
+
}
|
|
1295
|
+
async getRevision(fileId, revisionNumber, jwtToken) {
|
|
1296
|
+
const revisions = await this.listRevisions(fileId, jwtToken);
|
|
1297
|
+
const revision = revisions.find((r) => r.revisionNumber === revisionNumber);
|
|
1298
|
+
if (!revision) {
|
|
1299
|
+
throw new Error(`Revision ${revisionNumber} not found for file ${fileId}`);
|
|
1300
|
+
}
|
|
1301
|
+
return revision;
|
|
1302
|
+
}
|
|
1303
|
+
async downloadRevision(fileId, revisionNumber, jwtToken) {
|
|
1304
|
+
const baseUrl = this.httpClient.getBaseUrl().replace(/\/$/, "");
|
|
1305
|
+
const url = `${baseUrl}/project/v1/files/${fileId}/revisions/${revisionNumber}`;
|
|
1306
|
+
const response = await fetch(url, {
|
|
1307
|
+
method: "GET",
|
|
1308
|
+
headers: {
|
|
1309
|
+
Accept: "application/octet-stream",
|
|
1310
|
+
Authorization: `Bearer ${jwtToken}`
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
if (!response.ok) {
|
|
1314
|
+
const errorBody = await response.json().catch(() => ({ message: "Unknown error" }));
|
|
1315
|
+
const details = errorBody.details ? ` [details: ${JSON.stringify(errorBody.details)}]` : "";
|
|
1316
|
+
throw new Error(
|
|
1317
|
+
`HTTP ${response.status}: ${errorBody.message ?? "Download failed"}${details}`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
const buffer = await response.arrayBuffer();
|
|
1321
|
+
return new Uint8Array(buffer);
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
// ../../packages/platform-client/project/http/project.http-client.ts
|
|
1326
|
+
var HttpProjectClient = class {
|
|
1327
|
+
httpClient;
|
|
1328
|
+
constructor(httpClient) {
|
|
1329
|
+
this.httpClient = httpClient;
|
|
1330
|
+
}
|
|
1331
|
+
async createProject(request, jwtToken) {
|
|
1332
|
+
const raw = await this.httpClient.post(
|
|
1333
|
+
"/project/v1/projects",
|
|
1334
|
+
request,
|
|
1335
|
+
jwtToken,
|
|
1336
|
+
{}
|
|
1337
|
+
);
|
|
1338
|
+
return transformProjectResponse(raw);
|
|
1339
|
+
}
|
|
1340
|
+
async getProject(projectId, jwtToken) {
|
|
1341
|
+
const raw = await this.httpClient.get(
|
|
1342
|
+
`/project/v1/projects/${projectId}`,
|
|
1343
|
+
jwtToken,
|
|
1344
|
+
{}
|
|
1345
|
+
);
|
|
1346
|
+
return transformProjectResponse(raw);
|
|
1347
|
+
}
|
|
1348
|
+
async listProjects(jwtToken, opts) {
|
|
1349
|
+
const queryParams = new URLSearchParams();
|
|
1350
|
+
if (opts?.status) queryParams.set("status", opts.status);
|
|
1351
|
+
if (opts?.limit) queryParams.set("limit", String(opts.limit));
|
|
1352
|
+
if (opts?.offset !== void 0) queryParams.set("offset", String(opts.offset));
|
|
1353
|
+
if (opts?.query) queryParams.set("q", opts.query);
|
|
1354
|
+
const query = queryParams.toString();
|
|
1355
|
+
const path4 = query ? `/project/v1/projects?${query}` : "/project/v1/projects";
|
|
1356
|
+
const response = await this.httpClient.get(path4, jwtToken, {});
|
|
1357
|
+
return {
|
|
1358
|
+
projects: response.projects.map(transformProjectResponse),
|
|
1359
|
+
total: response.total,
|
|
1360
|
+
limit: response.limit,
|
|
1361
|
+
offset: response.offset
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
async updateProject(projectId, updates, jwtToken) {
|
|
1365
|
+
const raw = await this.httpClient.put(
|
|
1366
|
+
`/project/v1/projects/${projectId}`,
|
|
1367
|
+
updates,
|
|
1368
|
+
jwtToken,
|
|
1369
|
+
{}
|
|
1370
|
+
);
|
|
1371
|
+
return transformProjectResponse(raw);
|
|
1372
|
+
}
|
|
1373
|
+
async deleteProject(projectId, jwtToken) {
|
|
1374
|
+
await this.httpClient.delete(
|
|
1375
|
+
`/project/v1/projects/${projectId}`,
|
|
1376
|
+
jwtToken,
|
|
1377
|
+
{}
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
async getDashboardSummary(jwtToken) {
|
|
1381
|
+
const raw = await this.httpClient.get("/project/v1/summary", jwtToken, {});
|
|
1382
|
+
return {
|
|
1383
|
+
totalProjects: raw.total_projects,
|
|
1384
|
+
totalSessions: raw.total_sessions,
|
|
1385
|
+
activeSessions: raw.active_sessions,
|
|
1386
|
+
totalSpecFiles: raw.total_spec_files,
|
|
1387
|
+
projectSessionCounts: raw.project_session_counts ?? {},
|
|
1388
|
+
projectFileCounts: raw.project_file_counts ?? {}
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
// ../../packages/platform-client/project/http/session.http-client.ts
|
|
1394
|
+
var HttpSpecSessionClient = class {
|
|
1395
|
+
httpClient;
|
|
1396
|
+
constructor(httpClient) {
|
|
1397
|
+
this.httpClient = httpClient;
|
|
1398
|
+
}
|
|
1399
|
+
async createSpecSession(request, jwtToken) {
|
|
1400
|
+
const body = {
|
|
1401
|
+
project_id: request.projectId,
|
|
1402
|
+
context: request.context ?? void 0
|
|
1403
|
+
};
|
|
1404
|
+
const raw = await this.httpClient.post(
|
|
1405
|
+
`/project/v1/projects/${request.projectId}/sessions`,
|
|
1406
|
+
body,
|
|
1407
|
+
jwtToken,
|
|
1408
|
+
{}
|
|
1409
|
+
);
|
|
1410
|
+
return transformSpecSessionResponse(raw);
|
|
1411
|
+
}
|
|
1412
|
+
async getSpecSession(sessionId, jwtToken) {
|
|
1413
|
+
const raw = await this.httpClient.get(
|
|
1414
|
+
`/project/v1/sessions/${sessionId}`,
|
|
1415
|
+
jwtToken,
|
|
1416
|
+
{}
|
|
1417
|
+
);
|
|
1418
|
+
return transformSpecSessionResponse(raw);
|
|
1419
|
+
}
|
|
1420
|
+
async listSpecSessions(projectId, jwtToken, opts) {
|
|
1421
|
+
const params = new URLSearchParams();
|
|
1422
|
+
params.set("filter", opts?.archiveFilter ?? "active");
|
|
1423
|
+
if (opts?.status) params.set("status", opts.status);
|
|
1424
|
+
if (opts?.query) params.set("q", opts.query);
|
|
1425
|
+
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
1426
|
+
if (opts?.offset !== void 0) params.set("offset", String(opts.offset));
|
|
1427
|
+
const response = await this.httpClient.get(
|
|
1428
|
+
`/project/v1/projects/${projectId}/sessions?${params.toString()}`,
|
|
1429
|
+
jwtToken,
|
|
1430
|
+
{}
|
|
1431
|
+
);
|
|
1432
|
+
return {
|
|
1433
|
+
sessions: response.sessions.map(transformSpecSessionResponse),
|
|
1434
|
+
total: response.total,
|
|
1435
|
+
limit: response.limit,
|
|
1436
|
+
offset: response.offset
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
async updateSpecSession(sessionId, updates, jwtToken) {
|
|
1440
|
+
const body = {};
|
|
1441
|
+
if (updates.status !== void 0) body.status = updates.status;
|
|
1442
|
+
if (updates.messageCount !== void 0) body.message_count = updates.messageCount;
|
|
1443
|
+
if (updates.tokenCount !== void 0) body.token_count = updates.tokenCount;
|
|
1444
|
+
if (updates.context !== void 0) body.context = updates.context;
|
|
1445
|
+
const raw = await this.httpClient.put(
|
|
1446
|
+
`/project/v1/sessions/${sessionId}`,
|
|
1447
|
+
body,
|
|
1448
|
+
jwtToken,
|
|
1449
|
+
{}
|
|
1450
|
+
);
|
|
1451
|
+
return transformSpecSessionResponse(raw);
|
|
1452
|
+
}
|
|
1453
|
+
async archiveSpecSession(sessionId, jwtToken) {
|
|
1454
|
+
const raw = await this.httpClient.post(
|
|
1455
|
+
`/project/v1/sessions/${sessionId}/archive`,
|
|
1456
|
+
{},
|
|
1457
|
+
jwtToken,
|
|
1458
|
+
{}
|
|
1459
|
+
);
|
|
1460
|
+
return transformSpecSessionResponse(raw);
|
|
1461
|
+
}
|
|
1462
|
+
async unarchiveSpecSession(sessionId, jwtToken) {
|
|
1463
|
+
const raw = await this.httpClient.post(
|
|
1464
|
+
`/project/v1/sessions/${sessionId}/unarchive`,
|
|
1465
|
+
{},
|
|
1466
|
+
jwtToken,
|
|
1467
|
+
{}
|
|
1468
|
+
);
|
|
1469
|
+
return transformSpecSessionResponse(raw);
|
|
1470
|
+
}
|
|
1471
|
+
async deleteSpecSession(sessionId, jwtToken) {
|
|
1472
|
+
await this.httpClient.delete(`/project/v1/sessions/${sessionId}`, jwtToken, {});
|
|
1473
|
+
}
|
|
1474
|
+
async listUserSessions(opts, jwtToken) {
|
|
1475
|
+
const limit = opts.limit ?? 20;
|
|
1476
|
+
const response = await this.httpClient.get(`/project/v1/sessions?limit=${String(limit)}`, jwtToken, {});
|
|
1477
|
+
return {
|
|
1478
|
+
sessions: response.sessions.map(transformSpecSessionResponse),
|
|
1479
|
+
fileCounts: response.file_counts ?? {}
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
async getSessionSpecLinks(sessionId, jwtToken) {
|
|
1483
|
+
return this.httpClient.get(
|
|
1484
|
+
`/project/v1/sessions/${sessionId}/spec-links`,
|
|
1485
|
+
jwtToken,
|
|
1486
|
+
{}
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1491
|
+
// src/platform/client.ts
|
|
1492
|
+
var silentLogger = {
|
|
1493
|
+
debug: () => void 0,
|
|
1494
|
+
info: () => void 0,
|
|
1495
|
+
warn: () => void 0,
|
|
1496
|
+
error: () => void 0
|
|
1497
|
+
};
|
|
1498
|
+
var PlatformClient = class {
|
|
1499
|
+
tokenManager;
|
|
1500
|
+
project;
|
|
1501
|
+
session;
|
|
1502
|
+
file;
|
|
1503
|
+
attachment;
|
|
1504
|
+
baseUrl;
|
|
1505
|
+
constructor(deps) {
|
|
1506
|
+
this.tokenManager = deps.tokenManager;
|
|
1507
|
+
this.baseUrl = deps.baseUrl.replace(/\/$/, "");
|
|
1508
|
+
const httpClient = new BaseHttpClient({ baseUrl: this.baseUrl, logger: silentLogger });
|
|
1509
|
+
this.project = new HttpProjectClient(httpClient);
|
|
1510
|
+
this.session = new HttpSpecSessionClient(httpClient);
|
|
1511
|
+
this.file = new HttpFileClient(httpClient);
|
|
1512
|
+
this.attachment = new HttpAttachmentClient(httpClient);
|
|
1513
|
+
}
|
|
1514
|
+
async listProjects(opts = {}) {
|
|
1515
|
+
return this.withTokenRetry((jwt) => this.project.listProjects(jwt, opts));
|
|
1516
|
+
}
|
|
1517
|
+
async getProject(projectId) {
|
|
1518
|
+
return this.withTokenRetry((jwt) => this.project.getProject(projectId, jwt));
|
|
1519
|
+
}
|
|
1520
|
+
async listProjectSessions(projectId, opts = {}) {
|
|
1521
|
+
const merged = {
|
|
1522
|
+
archiveFilter: opts.archiveFilter ?? "active",
|
|
1523
|
+
...opts
|
|
1524
|
+
};
|
|
1525
|
+
return this.withTokenRetry((jwt) => this.session.listSpecSessions(projectId, jwt, merged));
|
|
1526
|
+
}
|
|
1527
|
+
async listFiles(projectId, opts = {}) {
|
|
1528
|
+
return this.withTokenRetry((jwt) => this.file.listFiles(projectId, jwt, opts));
|
|
1529
|
+
}
|
|
1530
|
+
async getFileMetadata(fileId) {
|
|
1531
|
+
return this.withTokenRetry((jwt) => this.file.getFile(fileId, jwt));
|
|
1532
|
+
}
|
|
1533
|
+
async getAccessTokenWithExpiry() {
|
|
1534
|
+
const accessToken = await this.tokenManager.getValidAccessToken();
|
|
1535
|
+
const creds = await this.tokenManager.getCredentials();
|
|
1536
|
+
return { accessToken, expiresAt: creds.expiresAt };
|
|
1537
|
+
}
|
|
1538
|
+
async getFileDownloadUrl(fileId) {
|
|
1539
|
+
return this.withTokenRetry((jwt) => this.file.getDownloadUrl(fileId, jwt));
|
|
1540
|
+
}
|
|
1541
|
+
async getAttachmentDownloadUrl(attachmentId) {
|
|
1542
|
+
return this.withTokenRetry((jwt) => this.attachment.getDownloadUrl(attachmentId, jwt));
|
|
1543
|
+
}
|
|
1544
|
+
async downloadFile(fileId, revision) {
|
|
1545
|
+
if (revision !== void 0) {
|
|
1546
|
+
const bytes2 = await this.withTokenRetry(
|
|
1547
|
+
(jwt) => this.file.downloadRevision(fileId, revision, jwt)
|
|
1548
|
+
);
|
|
1549
|
+
return { bytes: bytes2, revisionNumber: revision };
|
|
1550
|
+
}
|
|
1551
|
+
const bytes = await this.withTokenRetry((jwt) => this.file.downloadContent(fileId, jwt));
|
|
1552
|
+
const meta = await this.getFileMetadata(fileId);
|
|
1553
|
+
return { bytes, revisionNumber: meta.revisionCount };
|
|
1554
|
+
}
|
|
1555
|
+
async listAttachments(projectId) {
|
|
1556
|
+
return this.withTokenRetry((jwt) => this.attachment.listByProject(projectId, jwt));
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Fetch a single attachment's metadata by id. Returns null on a 404 so callers
|
|
1560
|
+
* can report a clean "not found" instead of surfacing a raw HTTP error. Any
|
|
1561
|
+
* other status — including a 405 (Method Not Allowed) or 501 (Not Implemented)
|
|
1562
|
+
* from a verb mismatch — is thrown so an endpoint regression surfaces instead
|
|
1563
|
+
* of masquerading as a missing attachment.
|
|
1564
|
+
*/
|
|
1565
|
+
async getAttachmentMetadata(attachmentId) {
|
|
1566
|
+
try {
|
|
1567
|
+
return await this.withTokenRetry((jwt) => this.attachment.getById(attachmentId, jwt));
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
if (statusOf(err) === 404) {
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
throw err;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
async downloadAttachment(attachmentId) {
|
|
1576
|
+
return this.withTokenRetry((jwt) => this.attachment.downloadContent(attachmentId, jwt));
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Soft-deletes an attachment by id. The server hides it from subsequent
|
|
1580
|
+
* list/get responses and frees its filename so a new upload can take it
|
|
1581
|
+
* without dedup-renaming. Used by `upload_attachment` when `override=true`
|
|
1582
|
+
* is set and an attachment with the same filename already exists.
|
|
1583
|
+
*/
|
|
1584
|
+
async deleteAttachment(attachmentId) {
|
|
1585
|
+
await this.withTokenRetry((jwt) => this.attachment.deleteById(attachmentId, jwt));
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Two-phase attachment upload. Reserves an attachment id (Phase 1), then
|
|
1589
|
+
* PUTs the binary content (Phase 2). Each HTTP call is independently
|
|
1590
|
+
* wrapped in withTokenRetry so a 401 on either phase triggers refresh.
|
|
1591
|
+
* Returns the final attachment id (echoed by the server) and storage URI.
|
|
1592
|
+
*/
|
|
1593
|
+
async uploadAttachment(args) {
|
|
1594
|
+
const initiated = await this.withTokenRetry(
|
|
1595
|
+
(jwt) => this.attachment.initiateUpload(
|
|
1596
|
+
{
|
|
1597
|
+
projectId: args.projectId,
|
|
1598
|
+
fileName: args.fileName,
|
|
1599
|
+
mimeType: args.mimeType,
|
|
1600
|
+
fileSizeBytes: args.fileSizeBytes,
|
|
1601
|
+
checksum: args.checksum
|
|
1602
|
+
},
|
|
1603
|
+
jwt
|
|
1604
|
+
)
|
|
1605
|
+
);
|
|
1606
|
+
const uploaded = await this.withTokenRetry(
|
|
1607
|
+
(jwt) => this.attachment.uploadContent(initiated.attachmentId, args.content, jwt)
|
|
1608
|
+
);
|
|
1609
|
+
return {
|
|
1610
|
+
attachmentId: uploaded.attachmentId,
|
|
1611
|
+
fileUri: uploaded.fileUri
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
async withTokenRetry(call) {
|
|
1615
|
+
const jwt = await this.tokenManager.getValidAccessToken();
|
|
1616
|
+
try {
|
|
1617
|
+
return await call(jwt);
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
if (statusOf(err) === 401) {
|
|
1620
|
+
const refreshed = await this.tokenManager.forceRefresh();
|
|
1621
|
+
return await call(refreshed);
|
|
1622
|
+
}
|
|
1623
|
+
throw err;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
function statusOf(err) {
|
|
1628
|
+
if (typeof err !== "object" || err === null) {
|
|
1629
|
+
return void 0;
|
|
1630
|
+
}
|
|
1631
|
+
const candidate = err.status;
|
|
1632
|
+
if (typeof candidate === "number") {
|
|
1633
|
+
return candidate;
|
|
1634
|
+
}
|
|
1635
|
+
const message = err instanceof Error ? err.message : "";
|
|
1636
|
+
const match = /HTTP (\d{3}):/.exec(message);
|
|
1637
|
+
return match ? Number(match[1]) : void 0;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// src/server/server.ts
|
|
1641
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1642
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1643
|
+
import "zod";
|
|
1644
|
+
|
|
1645
|
+
// src/server/tool-types.ts
|
|
1646
|
+
function jsonResult(value) {
|
|
1647
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
1648
|
+
}
|
|
1649
|
+
function errorResult(message) {
|
|
1650
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// src/server/tools/get_attachment.ts
|
|
1654
|
+
import { z } from "zod";
|
|
1655
|
+
var inputSchema = {
|
|
1656
|
+
attachment_id: z.string().min(1).describe("UUID of the attachment"),
|
|
1657
|
+
include_download_url: z.number().int().nonnegative().optional().describe("Set to any positive value to include a signed download URL. Omit or 0 to skip.")
|
|
1658
|
+
};
|
|
1659
|
+
var getAttachmentTool = {
|
|
1660
|
+
name: "get_attachment",
|
|
1661
|
+
description: "Get attachment metadata. With `include_download_url>0`, also returns a signed `download_url`.",
|
|
1662
|
+
inputSchema,
|
|
1663
|
+
handler: async (args, ctx) => {
|
|
1664
|
+
const attachment = await ctx.client.getAttachmentMetadata(args.attachment_id);
|
|
1665
|
+
if (!attachment) {
|
|
1666
|
+
return errorResult(`Attachment ${args.attachment_id} not found.`);
|
|
1667
|
+
}
|
|
1668
|
+
const base = {
|
|
1669
|
+
id: attachment.id,
|
|
1670
|
+
project_id: attachment.projectId,
|
|
1671
|
+
name: attachment.name,
|
|
1672
|
+
mime_type: attachment.mimeType,
|
|
1673
|
+
file_size_bytes: attachment.fileSizeBytes,
|
|
1674
|
+
checksum: attachment.checksum,
|
|
1675
|
+
created_at: attachment.createdAt
|
|
1676
|
+
};
|
|
1677
|
+
const requested = args.include_download_url ?? 0;
|
|
1678
|
+
if (requested <= 0) {
|
|
1679
|
+
return jsonResult(base);
|
|
1680
|
+
}
|
|
1681
|
+
const signed = await ctx.client.getAttachmentDownloadUrl(attachment.id);
|
|
1682
|
+
return jsonResult({
|
|
1683
|
+
...base,
|
|
1684
|
+
download_url: signed.signedUrl,
|
|
1685
|
+
expires_at: signed.expiresAt.toISOString()
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
// src/server/tools/get_file.ts
|
|
1691
|
+
import { z as z2 } from "zod";
|
|
1692
|
+
var inputSchema2 = {
|
|
1693
|
+
file_id: z2.string().min(1).describe("UUID of the file"),
|
|
1694
|
+
include_download_url: z2.number().int().nonnegative().optional().describe(
|
|
1695
|
+
"Revision number to include a download URL for. Pass `revision_count` for the latest (signed URL); pass an older revision to get a URL plus `download_headers` carrying a short-lived bearer token. Omit or 0 to skip."
|
|
1696
|
+
)
|
|
1697
|
+
};
|
|
1698
|
+
var getFileTool = {
|
|
1699
|
+
name: "get_file",
|
|
1700
|
+
description: "Get file metadata. With `include_download_url=N`, also returns a download URL for revision N: latest returns a signed URL; older revisions return a URL plus `download_headers` containing a short-lived bearer token (treat as sensitive).",
|
|
1701
|
+
inputSchema: inputSchema2,
|
|
1702
|
+
handler: async (args, ctx) => {
|
|
1703
|
+
const meta = await ctx.client.getFileMetadata(args.file_id);
|
|
1704
|
+
const base = {
|
|
1705
|
+
id: meta.id,
|
|
1706
|
+
project_id: meta.projectId,
|
|
1707
|
+
session_id: meta.sessionId,
|
|
1708
|
+
file_type: meta.fileType,
|
|
1709
|
+
file_path: meta.filePath,
|
|
1710
|
+
file_size_bytes: meta.fileSizeBytes,
|
|
1711
|
+
checksum: meta.checksum,
|
|
1712
|
+
revision_count: meta.revisionCount,
|
|
1713
|
+
created_at: meta.createdAt,
|
|
1714
|
+
updated_at: meta.updatedAt
|
|
1715
|
+
};
|
|
1716
|
+
const requested = args.include_download_url ?? 0;
|
|
1717
|
+
if (requested <= 0) {
|
|
1718
|
+
return jsonResult(base);
|
|
1719
|
+
}
|
|
1720
|
+
if (requested > meta.revisionCount) {
|
|
1721
|
+
return errorResult(
|
|
1722
|
+
`include_download_url=${String(requested)} is greater than revision_count=${String(meta.revisionCount)} for file ${meta.id}.`
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
const isLatest = requested === meta.revisionCount;
|
|
1726
|
+
let downloadBlock;
|
|
1727
|
+
if (isLatest) {
|
|
1728
|
+
const signed = await ctx.client.getFileDownloadUrl(meta.id);
|
|
1729
|
+
downloadBlock = {
|
|
1730
|
+
download_url: signed.signedUrl,
|
|
1731
|
+
download_revision: meta.revisionCount,
|
|
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
|
+
}
|
|
1744
|
+
return jsonResult({ ...base, ...downloadBlock });
|
|
1745
|
+
}
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
// src/server/tools/get_project.ts
|
|
1749
|
+
import { z as z3 } from "zod";
|
|
1750
|
+
var inputSchema3 = {
|
|
1751
|
+
project_id: z3.string().min(1).describe("UUID of the project to fetch")
|
|
1752
|
+
};
|
|
1753
|
+
var getProjectTool = {
|
|
1754
|
+
name: "get_project",
|
|
1755
|
+
description: "Get details for a project including all active spec sessions, files (id + metadata), and attachments (id + metadata).",
|
|
1756
|
+
inputSchema: inputSchema3,
|
|
1757
|
+
handler: async (args, ctx) => {
|
|
1758
|
+
const [project, sessions, files, attachments] = await Promise.all([
|
|
1759
|
+
ctx.client.getProject(args.project_id),
|
|
1760
|
+
ctx.client.listProjectSessions(args.project_id, { archiveFilter: "active", limit: 100 }).catch(() => ({ sessions: [], total: 0, limit: 0, offset: 0 })),
|
|
1761
|
+
ctx.client.listFiles(args.project_id, { limit: 200 }).catch(() => ({ files: [], total: 0, limit: 0, offset: 0 })),
|
|
1762
|
+
ctx.client.listAttachments(args.project_id).catch(() => [])
|
|
1763
|
+
]);
|
|
1764
|
+
return jsonResult({
|
|
1765
|
+
project: {
|
|
1766
|
+
id: project.id,
|
|
1767
|
+
name: project.name,
|
|
1768
|
+
description: project.description,
|
|
1769
|
+
status: project.status,
|
|
1770
|
+
created_at: project.createdAt,
|
|
1771
|
+
updated_at: project.updatedAt
|
|
1772
|
+
},
|
|
1773
|
+
active_sessions: sessions.sessions.map((s) => ({
|
|
1774
|
+
id: s.id,
|
|
1775
|
+
status: s.status,
|
|
1776
|
+
message_count: s.messageCount,
|
|
1777
|
+
token_count: s.tokenCount,
|
|
1778
|
+
started_at: s.startedAt,
|
|
1779
|
+
updated_at: s.updatedAt
|
|
1780
|
+
})),
|
|
1781
|
+
files: files.files.map((f) => ({
|
|
1782
|
+
id: f.id,
|
|
1783
|
+
file_path: f.filePath,
|
|
1784
|
+
file_type: f.fileType,
|
|
1785
|
+
file_size_bytes: f.fileSizeBytes,
|
|
1786
|
+
revision_count: f.revisionCount,
|
|
1787
|
+
session_id: f.sessionId,
|
|
1788
|
+
updated_at: f.updatedAt
|
|
1789
|
+
})),
|
|
1790
|
+
attachments: attachments.map((a) => ({
|
|
1791
|
+
id: a.id,
|
|
1792
|
+
name: a.name,
|
|
1793
|
+
mime_type: a.mimeType,
|
|
1794
|
+
file_size_bytes: a.fileSizeBytes,
|
|
1795
|
+
created_at: a.createdAt
|
|
1796
|
+
}))
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
// src/server/tools/list_projects.ts
|
|
1802
|
+
import { z as z4 } from "zod";
|
|
1803
|
+
var inputSchema4 = {
|
|
1804
|
+
query: z4.string().optional().describe(
|
|
1805
|
+
"Search term to filter projects by name. Omit to list all projects."
|
|
1806
|
+
),
|
|
1807
|
+
limit: z4.number().int().positive().max(100).optional().describe("Max projects to return (default 20)"),
|
|
1808
|
+
offset: z4.number().int().nonnegative().optional().describe("Pagination offset (default 0)")
|
|
1809
|
+
};
|
|
1810
|
+
var listProjectsTool = {
|
|
1811
|
+
name: "list_projects",
|
|
1812
|
+
description: "List or search MySpec projects accessible to the authenticated user.",
|
|
1813
|
+
inputSchema: inputSchema4,
|
|
1814
|
+
handler: async (args, ctx) => {
|
|
1815
|
+
const result = await ctx.client.listProjects({
|
|
1816
|
+
query: args.query,
|
|
1817
|
+
limit: args.limit ?? 20,
|
|
1818
|
+
offset: args.offset ?? 0
|
|
1819
|
+
});
|
|
1820
|
+
return jsonResult({
|
|
1821
|
+
projects: result.projects.map((p) => ({
|
|
1822
|
+
id: p.id,
|
|
1823
|
+
name: p.name,
|
|
1824
|
+
description: p.description,
|
|
1825
|
+
status: p.status,
|
|
1826
|
+
updated_at: p.updatedAt
|
|
1827
|
+
})),
|
|
1828
|
+
total: result.total,
|
|
1829
|
+
limit: result.limit,
|
|
1830
|
+
offset: result.offset
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
// src/server/tools/read_file.ts
|
|
1836
|
+
import { promises as fs3 } from "fs";
|
|
1837
|
+
import os3 from "os";
|
|
1838
|
+
import path3 from "path";
|
|
1839
|
+
import { z as z5 } from "zod";
|
|
1840
|
+
|
|
1841
|
+
// src/server/download-path.ts
|
|
1842
|
+
import { promises as fs2 } from "fs";
|
|
1843
|
+
import os2 from "os";
|
|
1844
|
+
import path2 from "path";
|
|
1845
|
+
function resolveDownloadRoot(env = process.env) {
|
|
1846
|
+
const configured = env.MYSPEC_DOWNLOAD_ROOT;
|
|
1847
|
+
if (configured) {
|
|
1848
|
+
if (!path2.isAbsolute(configured)) {
|
|
1849
|
+
throw new Error(`MYSPEC_DOWNLOAD_ROOT must be an absolute path (got: ${configured})`);
|
|
1850
|
+
}
|
|
1851
|
+
return path2.resolve(configured);
|
|
1852
|
+
}
|
|
1853
|
+
return path2.join(os2.homedir(), ".myspec");
|
|
1854
|
+
}
|
|
1855
|
+
function assertWithinRoot(target, root) {
|
|
1856
|
+
const resolvedTarget = path2.resolve(target);
|
|
1857
|
+
const resolvedRoot = path2.resolve(root);
|
|
1858
|
+
const rel = path2.relative(resolvedRoot, resolvedTarget);
|
|
1859
|
+
if (rel.startsWith("..") || path2.isAbsolute(rel)) {
|
|
1860
|
+
return `path resolves outside the configured root (${resolvedRoot}): ${resolvedTarget}`;
|
|
1861
|
+
}
|
|
1862
|
+
return null;
|
|
1863
|
+
}
|
|
1864
|
+
function computeFileCachePath(args) {
|
|
1865
|
+
const basename4 = path2.basename(args.filePath) || "content";
|
|
1866
|
+
return path2.join(
|
|
1867
|
+
args.cacheRoot,
|
|
1868
|
+
"project",
|
|
1869
|
+
args.projectId,
|
|
1870
|
+
"file",
|
|
1871
|
+
args.fileId,
|
|
1872
|
+
"rev",
|
|
1873
|
+
String(args.revision),
|
|
1874
|
+
basename4
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
async function pathExists(p) {
|
|
1878
|
+
try {
|
|
1879
|
+
await fs2.stat(p);
|
|
1880
|
+
return true;
|
|
1881
|
+
} catch (err) {
|
|
1882
|
+
if (isFsNotFound2(err)) {
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
throw err;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
function isFsNotFound2(err) {
|
|
1889
|
+
return typeof err === "object" && err !== null && err.code === "ENOENT";
|
|
1890
|
+
}
|
|
1891
|
+
async function writeBytesAtomically(target, bytes) {
|
|
1892
|
+
const dir = path2.dirname(target);
|
|
1893
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
1894
|
+
const tmp = path2.join(dir, `.${path2.basename(target)}.${process.pid.toString()}.tmp`);
|
|
1895
|
+
const handle = await fs2.open(tmp, "wx", 384);
|
|
1896
|
+
try {
|
|
1897
|
+
await handle.write(bytes);
|
|
1898
|
+
} finally {
|
|
1899
|
+
await handle.close();
|
|
1900
|
+
}
|
|
1901
|
+
await fs2.rename(tmp, target);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// src/server/tools/read_file.ts
|
|
1905
|
+
var MAX_READ_BYTES = 1 * 1024 * 1024;
|
|
1906
|
+
var inputSchema5 = {
|
|
1907
|
+
file_id: z5.string().min(1).describe("UUID of the file"),
|
|
1908
|
+
revision: z5.number().int().positive().optional().describe("Specific revision number to read (defaults to the latest revision).")
|
|
1909
|
+
};
|
|
1910
|
+
var readFileTool = {
|
|
1911
|
+
name: "read_file",
|
|
1912
|
+
description: "Return a file's UTF-8 content. Bytes are cached on disk under the configured cache root (default ~/.myspec) at `project/<projectId>/file/<fileId>/rev/<rev>/<basename>` and reused on subsequent reads of the same revision. Fails for binary files or files larger than 1 MiB \u2014 use get_file with `include_download_url` to obtain a download URL for those.",
|
|
1913
|
+
inputSchema: inputSchema5,
|
|
1914
|
+
handler: async (args, ctx) => {
|
|
1915
|
+
const meta = await ctx.client.getFileMetadata(args.file_id);
|
|
1916
|
+
const targetRevision = args.revision ?? meta.revisionCount;
|
|
1917
|
+
if (targetRevision === meta.revisionCount && meta.fileSizeBytes > MAX_READ_BYTES) {
|
|
1918
|
+
return errorResult(buildOversizeMessage(args.file_id, meta.fileSizeBytes));
|
|
1919
|
+
}
|
|
1920
|
+
const cacheRoot = resolveDownloadRoot();
|
|
1921
|
+
const cachePath = computeFileCachePath({
|
|
1922
|
+
cacheRoot,
|
|
1923
|
+
projectId: meta.projectId,
|
|
1924
|
+
fileId: meta.id,
|
|
1925
|
+
revision: targetRevision,
|
|
1926
|
+
filePath: meta.filePath
|
|
1927
|
+
});
|
|
1928
|
+
const escape = assertWithinRoot(cachePath, cacheRoot);
|
|
1929
|
+
if (escape) {
|
|
1930
|
+
return errorResult(
|
|
1931
|
+
`read_file refused to write cache: ${escape}. This indicates an unexpected platform response shape (projectId/fileId/filePath).`
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
let bytes;
|
|
1935
|
+
let cacheHit = false;
|
|
1936
|
+
if (await pathExists(cachePath)) {
|
|
1937
|
+
bytes = await fs3.readFile(cachePath);
|
|
1938
|
+
cacheHit = true;
|
|
1939
|
+
} else {
|
|
1940
|
+
const downloaded = await ctx.client.downloadFile(args.file_id, args.revision);
|
|
1941
|
+
bytes = downloaded.bytes;
|
|
1942
|
+
if (bytes.byteLength > MAX_READ_BYTES) {
|
|
1943
|
+
return errorResult(buildOversizeMessage(args.file_id, bytes.byteLength));
|
|
1944
|
+
}
|
|
1945
|
+
await writeBytesAtomically(cachePath, bytes);
|
|
1946
|
+
}
|
|
1947
|
+
if (bytes.byteLength > MAX_READ_BYTES) {
|
|
1948
|
+
return errorResult(buildOversizeMessage(args.file_id, bytes.byteLength));
|
|
1949
|
+
}
|
|
1950
|
+
if (isBinary(bytes)) {
|
|
1951
|
+
return errorResult(
|
|
1952
|
+
`File ${args.file_id} appears to be binary (file_type=${meta.fileType}, size=${String(bytes.byteLength)} bytes). Use get_file with \`include_download_url\` to obtain a download URL.`
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
let text;
|
|
1956
|
+
try {
|
|
1957
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1958
|
+
} catch {
|
|
1959
|
+
return errorResult(
|
|
1960
|
+
`File ${args.file_id} is not valid UTF-8 text. Use get_file with \`include_download_url\` to obtain a download URL.`
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
return jsonResult({
|
|
1964
|
+
file_id: meta.id,
|
|
1965
|
+
revision_number: targetRevision,
|
|
1966
|
+
file_path: meta.filePath,
|
|
1967
|
+
file_type: meta.fileType,
|
|
1968
|
+
cache_path: maskHomedir(cachePath),
|
|
1969
|
+
cache_hit: cacheHit,
|
|
1970
|
+
content: text
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
function buildOversizeMessage(fileId, byteLength) {
|
|
1975
|
+
return `File ${fileId} is ${String(byteLength)} bytes which exceeds the read cap of ${String(MAX_READ_BYTES)} bytes. Use get_file with \`include_download_url\` to obtain a download URL and fetch it directly.`;
|
|
1976
|
+
}
|
|
1977
|
+
function maskHomedir(p, home = os3.homedir()) {
|
|
1978
|
+
if (!home) {
|
|
1979
|
+
return p;
|
|
1980
|
+
}
|
|
1981
|
+
if (p === home) {
|
|
1982
|
+
return "~";
|
|
1983
|
+
}
|
|
1984
|
+
if (p.startsWith(home + path3.sep)) {
|
|
1985
|
+
return "~" + p.slice(home.length);
|
|
1986
|
+
}
|
|
1987
|
+
return p;
|
|
1988
|
+
}
|
|
1989
|
+
function isBinary(bytes) {
|
|
1990
|
+
for (const byte of bytes) {
|
|
1991
|
+
if (byte === 0) {
|
|
1992
|
+
return true;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return false;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/server/tools/upload_attachment.ts
|
|
1999
|
+
import { createHash } from "crypto";
|
|
2000
|
+
import { lstat, readFile } from "fs/promises";
|
|
2001
|
+
import { basename, extname, isAbsolute } from "path";
|
|
2002
|
+
import { z as z6 } from "zod";
|
|
2003
|
+
var DEFAULT_MIME = "application/octet-stream";
|
|
2004
|
+
var MAX_ATTACHMENT_MB = MAX_ATTACHMENT_BYTES / (1024 * 1024);
|
|
2005
|
+
var STRUCTURED_EXTENSION_MIME = {
|
|
2006
|
+
".pdf": "application/pdf",
|
|
2007
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
2008
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
2009
|
+
};
|
|
2010
|
+
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2011
|
+
// Plain prose / docs
|
|
2012
|
+
".txt",
|
|
2013
|
+
".md",
|
|
2014
|
+
".markdown",
|
|
2015
|
+
".rst",
|
|
2016
|
+
".log",
|
|
2017
|
+
".tex",
|
|
2018
|
+
// Markup
|
|
2019
|
+
".xml",
|
|
2020
|
+
".html",
|
|
2021
|
+
".htm",
|
|
2022
|
+
".svg",
|
|
2023
|
+
".xhtml",
|
|
2024
|
+
// Data / config
|
|
2025
|
+
".json",
|
|
2026
|
+
".jsonl",
|
|
2027
|
+
".ndjson",
|
|
2028
|
+
".yaml",
|
|
2029
|
+
".yml",
|
|
2030
|
+
".toml",
|
|
2031
|
+
".ini",
|
|
2032
|
+
".env",
|
|
2033
|
+
".csv",
|
|
2034
|
+
".tsv",
|
|
2035
|
+
".properties",
|
|
2036
|
+
// Scripts / source code
|
|
2037
|
+
".js",
|
|
2038
|
+
".jsx",
|
|
2039
|
+
".mjs",
|
|
2040
|
+
".cjs",
|
|
2041
|
+
".ts",
|
|
2042
|
+
".tsx",
|
|
2043
|
+
".mts",
|
|
2044
|
+
".cts",
|
|
2045
|
+
".py",
|
|
2046
|
+
".go",
|
|
2047
|
+
".rb",
|
|
2048
|
+
".rs",
|
|
2049
|
+
".java",
|
|
2050
|
+
".scala",
|
|
2051
|
+
".c",
|
|
2052
|
+
".cc",
|
|
2053
|
+
".cpp",
|
|
2054
|
+
".cxx",
|
|
2055
|
+
".h",
|
|
2056
|
+
".hpp",
|
|
2057
|
+
".hxx",
|
|
2058
|
+
".cs",
|
|
2059
|
+
".swift",
|
|
2060
|
+
".kt",
|
|
2061
|
+
".kts",
|
|
2062
|
+
".php",
|
|
2063
|
+
".lua",
|
|
2064
|
+
".r",
|
|
2065
|
+
".dart",
|
|
2066
|
+
".clj",
|
|
2067
|
+
".cljs",
|
|
2068
|
+
".ex",
|
|
2069
|
+
".exs",
|
|
2070
|
+
".erl",
|
|
2071
|
+
".hs",
|
|
2072
|
+
".ml",
|
|
2073
|
+
".fs",
|
|
2074
|
+
// Shell / build
|
|
2075
|
+
".sh",
|
|
2076
|
+
".bash",
|
|
2077
|
+
".zsh",
|
|
2078
|
+
".fish",
|
|
2079
|
+
".ps1",
|
|
2080
|
+
".bat",
|
|
2081
|
+
".cmd",
|
|
2082
|
+
".sql"
|
|
2083
|
+
]);
|
|
2084
|
+
function effectiveFileNameFromUri(fileUri, requested) {
|
|
2085
|
+
const lastSlash = fileUri.lastIndexOf("/");
|
|
2086
|
+
if (lastSlash === -1 || lastSlash === fileUri.length - 1) {
|
|
2087
|
+
return requested;
|
|
2088
|
+
}
|
|
2089
|
+
return fileUri.slice(lastSlash + 1);
|
|
2090
|
+
}
|
|
2091
|
+
function inferMimeFromName(name) {
|
|
2092
|
+
const ext = extname(name).toLowerCase();
|
|
2093
|
+
const structured = STRUCTURED_EXTENSION_MIME[ext];
|
|
2094
|
+
if (structured !== void 0) {
|
|
2095
|
+
return structured;
|
|
2096
|
+
}
|
|
2097
|
+
if (TEXT_EXTENSIONS.has(ext)) {
|
|
2098
|
+
return "text/plain";
|
|
2099
|
+
}
|
|
2100
|
+
return DEFAULT_MIME;
|
|
2101
|
+
}
|
|
2102
|
+
var inputSchema6 = {
|
|
2103
|
+
project_id: z6.string().min(1).describe("UUID of the project to attach the file to."),
|
|
2104
|
+
file_path: z6.string().min(1).describe(
|
|
2105
|
+
"Absolute path to the local file to upload. The MCP server reads this path from its own host."
|
|
2106
|
+
),
|
|
2107
|
+
file_name: z6.string().min(1).optional().describe("Override the filename recorded on the attachment. Defaults to the path basename."),
|
|
2108
|
+
mime_type: z6.string().min(1).optional().describe(
|
|
2109
|
+
"Override the MIME type sent to the platform. The platform re-detects from content, so this is only a hint. If omitted, inferred from the file extension \u2014 structured (.pdf, .docx, .xlsx) keep their canonical MIME; common UTF-8 text formats (.xml, .json, .yaml, .html, .csv, .md, source code, ...) are sent as text/plain."
|
|
2110
|
+
),
|
|
2111
|
+
override: z6.boolean().optional().describe(
|
|
2112
|
+
"When true, any existing (non-deleted) attachment on the project whose recorded filename exactly matches `file_name` (or the path basename) is soft-deleted before the new upload, so the new attachment keeps the original name instead of getting an auto-dedup `(1)` suffix. The platform has no in-place content-replace API, so the new upload always gets a new `attachment_id`; any replaced ids are returned as `overridden_attachment_ids` (array, possibly empty) so callers can refresh stored references. Defaults to false (let the platform auto-dedup)."
|
|
2113
|
+
)
|
|
2114
|
+
};
|
|
2115
|
+
var uploadAttachmentTool = {
|
|
2116
|
+
name: "upload_attachment",
|
|
2117
|
+
description: `Upload a local file as a new attachment on a project. Reads \`file_path\` from the host running the MCP server, computes its SHA256, then performs the two-phase platform upload. Max ${String(MAX_ATTACHMENT_MB)} MB. Supported content: PDF, DOCX, XLSX, and any UTF-8 text file (XML, JSON, YAML, HTML, Markdown, CSV, source code, ...). Binary files outside the structured types will be rejected by the platform.`,
|
|
2118
|
+
inputSchema: inputSchema6,
|
|
2119
|
+
handler: async (args, ctx) => {
|
|
2120
|
+
if (!isAbsolute(args.file_path)) {
|
|
2121
|
+
return errorResult(`file_path must be an absolute path; got "${args.file_path}".`);
|
|
2122
|
+
}
|
|
2123
|
+
let stats;
|
|
2124
|
+
try {
|
|
2125
|
+
stats = await lstat(args.file_path);
|
|
2126
|
+
} catch (err) {
|
|
2127
|
+
const code = err.code;
|
|
2128
|
+
if (code === "ENOENT") {
|
|
2129
|
+
return errorResult(`File not found: ${args.file_path}`);
|
|
2130
|
+
}
|
|
2131
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
2132
|
+
return errorResult(`Permission denied reading ${args.file_path}`);
|
|
2133
|
+
}
|
|
2134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2135
|
+
return errorResult(`Failed to stat ${args.file_path}: ${message}`);
|
|
2136
|
+
}
|
|
2137
|
+
if (stats.isSymbolicLink()) {
|
|
2138
|
+
return errorResult(`Refusing to follow symlink: ${args.file_path}`);
|
|
2139
|
+
}
|
|
2140
|
+
if (stats.isDirectory()) {
|
|
2141
|
+
return errorResult(`Path is a directory, not a file: ${args.file_path}`);
|
|
2142
|
+
}
|
|
2143
|
+
if (!stats.isFile()) {
|
|
2144
|
+
return errorResult(`Path is not a regular file: ${args.file_path}`);
|
|
2145
|
+
}
|
|
2146
|
+
if (stats.size === 0) {
|
|
2147
|
+
return errorResult(`File is empty: ${args.file_path}`);
|
|
2148
|
+
}
|
|
2149
|
+
if (stats.size > MAX_ATTACHMENT_BYTES) {
|
|
2150
|
+
return errorResult(
|
|
2151
|
+
`File size ${String(stats.size)} bytes exceeds the ${String(MAX_ATTACHMENT_MB)} MB attachment limit.`
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
let buf;
|
|
2155
|
+
try {
|
|
2156
|
+
buf = await readFile(args.file_path);
|
|
2157
|
+
} catch (err) {
|
|
2158
|
+
const code = err.code;
|
|
2159
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
2160
|
+
return errorResult(`Permission denied reading ${args.file_path}`);
|
|
2161
|
+
}
|
|
2162
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2163
|
+
return errorResult(`Failed to read ${args.file_path}: ${message}`);
|
|
2164
|
+
}
|
|
2165
|
+
if (buf.byteLength === 0) {
|
|
2166
|
+
return errorResult(`File is empty: ${args.file_path}`);
|
|
2167
|
+
}
|
|
2168
|
+
if (buf.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
2169
|
+
return errorResult(
|
|
2170
|
+
`File size ${String(buf.byteLength)} bytes exceeds the ${String(MAX_ATTACHMENT_MB)} MB attachment limit.`
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
const fileName = args.file_name ?? basename(args.file_path);
|
|
2174
|
+
const mimeType = args.mime_type ?? inferMimeFromName(fileName);
|
|
2175
|
+
const checksum = `sha256:${createHash("sha256").update(buf).digest("hex")}`;
|
|
2176
|
+
const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
2177
|
+
const existing = await ctx.client.listAttachments(args.project_id);
|
|
2178
|
+
const nameMatches = existing.filter((a) => a.name === fileName);
|
|
2179
|
+
const checksumMatch = nameMatches.find((a) => a.checksum === checksum);
|
|
2180
|
+
if (checksumMatch) {
|
|
2181
|
+
return jsonResult({
|
|
2182
|
+
attachment_id: checksumMatch.id,
|
|
2183
|
+
project_id: args.project_id,
|
|
2184
|
+
file_name: checksumMatch.name,
|
|
2185
|
+
// No new attachment created → no name was dedup'd this call.
|
|
2186
|
+
has_file_name_conflict: false,
|
|
2187
|
+
mime_type: checksumMatch.mimeType,
|
|
2188
|
+
file_size_bytes: checksumMatch.fileSizeBytes,
|
|
2189
|
+
checksum: checksumMatch.checksum,
|
|
2190
|
+
// True when the response describes a pre-existing attachment we
|
|
2191
|
+
// returned in lieu of uploading. `file_uri` is omitted here because
|
|
2192
|
+
// the platform's list endpoint does not surface it; use
|
|
2193
|
+
// `get_attachment` with `include_download_url=1` to fetch a signed URL.
|
|
2194
|
+
reused_existing_attachment: true
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
const overriddenIds = [];
|
|
2198
|
+
if (args.override === true) {
|
|
2199
|
+
for (const match of nameMatches) {
|
|
2200
|
+
await ctx.client.deleteAttachment(match.id);
|
|
2201
|
+
overriddenIds.push(match.id);
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
const uploaded = await ctx.client.uploadAttachment({
|
|
2205
|
+
projectId: args.project_id,
|
|
2206
|
+
fileName,
|
|
2207
|
+
mimeType,
|
|
2208
|
+
fileSizeBytes: buf.byteLength,
|
|
2209
|
+
checksum,
|
|
2210
|
+
content: bytes
|
|
2211
|
+
});
|
|
2212
|
+
const effectiveFileName = effectiveFileNameFromUri(uploaded.fileUri, fileName);
|
|
2213
|
+
const hasFileNameConflict = effectiveFileName !== fileName;
|
|
2214
|
+
return jsonResult({
|
|
2215
|
+
attachment_id: uploaded.attachmentId,
|
|
2216
|
+
project_id: args.project_id,
|
|
2217
|
+
// Reflects the name the platform actually stored, which can differ from
|
|
2218
|
+
// the requested name when auto-dedup adds a `(N)` suffix. Callers
|
|
2219
|
+
// looking the attachment back up by name should use this value.
|
|
2220
|
+
file_name: effectiveFileName,
|
|
2221
|
+
// True when the platform stored a different name than requested (e.g.
|
|
2222
|
+
// dedup'd `notes.txt` → `notes (1).txt`). Always present so callers can
|
|
2223
|
+
// branch without conditional-presence checks.
|
|
2224
|
+
has_file_name_conflict: hasFileNameConflict,
|
|
2225
|
+
mime_type: mimeType,
|
|
2226
|
+
file_size_bytes: buf.byteLength,
|
|
2227
|
+
checksum,
|
|
2228
|
+
file_uri: uploaded.fileUri,
|
|
2229
|
+
// False when we actually uploaded (always the case in this branch).
|
|
2230
|
+
// Kept on every response so callers don't have to special-case its
|
|
2231
|
+
// absence vs. its `true` value.
|
|
2232
|
+
reused_existing_attachment: false,
|
|
2233
|
+
// Only present when override=true AND a name-matching attachment was
|
|
2234
|
+
// soft-deleted. Empty array (`[]`) means override was requested but
|
|
2235
|
+
// there was nothing to replace; field is omitted entirely otherwise.
|
|
2236
|
+
...args.override === true ? { overridden_attachment_ids: overriddenIds } : {}
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
// src/server/server.ts
|
|
2242
|
+
function buildServer(deps) {
|
|
2243
|
+
const server = new McpServer({
|
|
2244
|
+
name: "myspec-mcp-server",
|
|
2245
|
+
version: deps.version ?? "0.1.0"
|
|
2246
|
+
});
|
|
2247
|
+
registerTool(server, listProjectsTool, deps.client);
|
|
2248
|
+
registerTool(server, getProjectTool, deps.client);
|
|
2249
|
+
registerTool(server, getFileTool, deps.client);
|
|
2250
|
+
registerTool(server, readFileTool, deps.client);
|
|
2251
|
+
registerTool(server, getAttachmentTool, deps.client);
|
|
2252
|
+
registerTool(server, uploadAttachmentTool, deps.client);
|
|
2253
|
+
return server;
|
|
2254
|
+
}
|
|
2255
|
+
async function startStdioServer(deps) {
|
|
2256
|
+
const server = buildServer(deps);
|
|
2257
|
+
const transport = new StdioServerTransport();
|
|
2258
|
+
await server.connect(transport);
|
|
2259
|
+
}
|
|
2260
|
+
function wrapToolHandler(tool, client) {
|
|
2261
|
+
return async (args) => {
|
|
2262
|
+
try {
|
|
2263
|
+
return await tool.handler(args, { client });
|
|
2264
|
+
} catch (err) {
|
|
2265
|
+
if (err instanceof NeedsLoginError) {
|
|
2266
|
+
return errorResult(err.message);
|
|
2267
|
+
}
|
|
2268
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2269
|
+
return errorResult(`${tool.name} failed: ${message}`);
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
function registerTool(server, tool, client) {
|
|
2274
|
+
server.registerTool(
|
|
2275
|
+
tool.name,
|
|
2276
|
+
{
|
|
2277
|
+
description: tool.description,
|
|
2278
|
+
inputSchema: tool.inputSchema
|
|
2279
|
+
},
|
|
2280
|
+
wrapToolHandler(tool, client)
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// src/reverse/run.ts
|
|
2285
|
+
import WebSocket from "ws";
|
|
2286
|
+
import { basename as basename3 } from "path";
|
|
2287
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2288
|
+
|
|
2289
|
+
// src/reverse/ws-transport.ts
|
|
2290
|
+
var WebSocketClientTransport = class {
|
|
2291
|
+
ws;
|
|
2292
|
+
pending = [];
|
|
2293
|
+
started = false;
|
|
2294
|
+
onclose;
|
|
2295
|
+
onerror;
|
|
2296
|
+
onmessage;
|
|
2297
|
+
constructor(ws) {
|
|
2298
|
+
this.ws = ws;
|
|
2299
|
+
this.ws.on("message", (raw) => {
|
|
2300
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
2301
|
+
this.handleFrame(text);
|
|
2302
|
+
});
|
|
2303
|
+
this.ws.on("error", (err) => {
|
|
2304
|
+
this.onerror?.(err);
|
|
2305
|
+
});
|
|
2306
|
+
this.ws.on("close", () => {
|
|
2307
|
+
this.onclose?.();
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
start() {
|
|
2311
|
+
this.started = true;
|
|
2312
|
+
const buffered = this.pending;
|
|
2313
|
+
this.pending = [];
|
|
2314
|
+
for (const msg of buffered) {
|
|
2315
|
+
this.onmessage?.(msg);
|
|
2316
|
+
}
|
|
2317
|
+
return Promise.resolve();
|
|
2318
|
+
}
|
|
2319
|
+
send(message) {
|
|
2320
|
+
return new Promise((resolve2, reject) => {
|
|
2321
|
+
this.ws.send(JSON.stringify(message), (err) => {
|
|
2322
|
+
if (err) {
|
|
2323
|
+
reject(err);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
resolve2();
|
|
2327
|
+
});
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
close() {
|
|
2331
|
+
try {
|
|
2332
|
+
this.ws.close();
|
|
2333
|
+
} catch {
|
|
2334
|
+
}
|
|
2335
|
+
return Promise.resolve();
|
|
2336
|
+
}
|
|
2337
|
+
handleFrame(raw) {
|
|
2338
|
+
let parsed;
|
|
2339
|
+
try {
|
|
2340
|
+
parsed = JSON.parse(raw);
|
|
2341
|
+
} catch (err) {
|
|
2342
|
+
this.onerror?.(err instanceof Error ? err : new Error(String(err)));
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const msg = parsed;
|
|
2346
|
+
if (!this.started) {
|
|
2347
|
+
this.pending.push(msg);
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
this.onmessage?.(msg);
|
|
2352
|
+
} catch (err) {
|
|
2353
|
+
this.onerror?.(err instanceof Error ? err : new Error(String(err)));
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
|
|
2358
|
+
// src/reverse/safe-path.ts
|
|
2359
|
+
import { realpath } from "fs/promises";
|
|
2360
|
+
import { isAbsolute as isAbsolute2, resolve, sep } from "path";
|
|
2361
|
+
var PathOutsideRootError = class extends Error {
|
|
2362
|
+
constructor(userPath) {
|
|
2363
|
+
super(`path_outside_root: ${userPath}`);
|
|
2364
|
+
this.userPath = userPath;
|
|
2365
|
+
this.name = "PathOutsideRootError";
|
|
2366
|
+
}
|
|
2367
|
+
userPath;
|
|
2368
|
+
};
|
|
2369
|
+
var AbsolutePathError = class extends Error {
|
|
2370
|
+
constructor(userPath) {
|
|
2371
|
+
super(`absolute_path_rejected: ${userPath}`);
|
|
2372
|
+
this.userPath = userPath;
|
|
2373
|
+
this.name = "AbsolutePathError";
|
|
2374
|
+
}
|
|
2375
|
+
userPath;
|
|
2376
|
+
};
|
|
2377
|
+
async function resolveSafePath(realRoot, userPath, opts = {}) {
|
|
2378
|
+
if (isAbsolute2(userPath)) {
|
|
2379
|
+
throw new AbsolutePathError(userPath);
|
|
2380
|
+
}
|
|
2381
|
+
const joined = resolve(realRoot, userPath);
|
|
2382
|
+
let resolved;
|
|
2383
|
+
try {
|
|
2384
|
+
resolved = await realpath(joined);
|
|
2385
|
+
} catch (err) {
|
|
2386
|
+
if (opts.allowMissing && err.code === "ENOENT") {
|
|
2387
|
+
resolved = joined;
|
|
2388
|
+
} else {
|
|
2389
|
+
throw err;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
if (resolved !== realRoot && !resolved.startsWith(realRoot + sep)) {
|
|
2393
|
+
throw new PathOutsideRootError(userPath);
|
|
2394
|
+
}
|
|
2395
|
+
return resolved;
|
|
2396
|
+
}
|
|
2397
|
+
async function canonicaliseRoot(root) {
|
|
2398
|
+
const real = await realpath(root);
|
|
2399
|
+
return real;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// src/reverse/local-fs-tools.ts
|
|
2403
|
+
import { promises as fs4 } from "fs";
|
|
2404
|
+
import { join as join2, relative } from "path";
|
|
2405
|
+
import { spawn } from "child_process";
|
|
2406
|
+
import { z as z7 } from "zod";
|
|
2407
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2408
|
+
|
|
2409
|
+
// src/reverse/ignore.ts
|
|
2410
|
+
import { basename as basename2 } from "path";
|
|
2411
|
+
var SEGMENT_IGNORES = [
|
|
2412
|
+
".git",
|
|
2413
|
+
".hg",
|
|
2414
|
+
".svn",
|
|
2415
|
+
"node_modules",
|
|
2416
|
+
"dist",
|
|
2417
|
+
"build",
|
|
2418
|
+
".next",
|
|
2419
|
+
".aws",
|
|
2420
|
+
".ssh",
|
|
2421
|
+
".gnupg",
|
|
2422
|
+
".kube",
|
|
2423
|
+
".docker",
|
|
2424
|
+
".gem",
|
|
2425
|
+
".cargo"
|
|
2426
|
+
];
|
|
2427
|
+
var PREFIX_IGNORES = [
|
|
2428
|
+
".env",
|
|
2429
|
+
".netrc",
|
|
2430
|
+
".npmrc",
|
|
2431
|
+
".pypirc",
|
|
2432
|
+
"kubeconfig",
|
|
2433
|
+
"credentials",
|
|
2434
|
+
"id_rsa",
|
|
2435
|
+
"id_ed25519",
|
|
2436
|
+
"id_ecdsa"
|
|
2437
|
+
];
|
|
2438
|
+
var SUFFIX_IGNORES = [
|
|
2439
|
+
".pem",
|
|
2440
|
+
".key",
|
|
2441
|
+
".p12",
|
|
2442
|
+
".pfx",
|
|
2443
|
+
".jks",
|
|
2444
|
+
".crt",
|
|
2445
|
+
".cer"
|
|
2446
|
+
];
|
|
2447
|
+
var SEGMENT_SET = new Set(SEGMENT_IGNORES);
|
|
2448
|
+
function isIgnored(pathRelativeToRoot) {
|
|
2449
|
+
const segments = pathRelativeToRoot.split(/[\\/]+/).filter((s) => s.length > 0);
|
|
2450
|
+
for (const seg of segments) {
|
|
2451
|
+
if (SEGMENT_SET.has(seg)) {
|
|
2452
|
+
return { ignored: true, rule: `segment:${seg}` };
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
const name = basename2(pathRelativeToRoot);
|
|
2456
|
+
for (const prefix of PREFIX_IGNORES) {
|
|
2457
|
+
if (name === prefix || name.startsWith(prefix)) {
|
|
2458
|
+
return { ignored: true, rule: `prefix:${prefix}` };
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
for (const suffix of SUFFIX_IGNORES) {
|
|
2462
|
+
if (name.endsWith(suffix)) {
|
|
2463
|
+
return { ignored: true, rule: `suffix:${suffix}` };
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
return { ignored: false };
|
|
2467
|
+
}
|
|
2468
|
+
function ripgrepIgnoreGlobs() {
|
|
2469
|
+
const argv = [];
|
|
2470
|
+
for (const seg of SEGMENT_IGNORES) {
|
|
2471
|
+
argv.push("--glob", `!${seg}`);
|
|
2472
|
+
argv.push("--glob", `!**/${seg}/**`);
|
|
2473
|
+
}
|
|
2474
|
+
for (const prefix of PREFIX_IGNORES) {
|
|
2475
|
+
argv.push("--glob", `!${prefix}*`);
|
|
2476
|
+
argv.push("--glob", `!**/${prefix}*`);
|
|
2477
|
+
}
|
|
2478
|
+
for (const suffix of SUFFIX_IGNORES) {
|
|
2479
|
+
argv.push("--glob", `!*${suffix}`);
|
|
2480
|
+
}
|
|
2481
|
+
return argv;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// src/reverse/pack-codebase.ts
|
|
2485
|
+
import { createHash as createHash2 } from "crypto";
|
|
2486
|
+
import { mkdtemp, readFile as readFile2, rm } from "fs/promises";
|
|
2487
|
+
import { tmpdir } from "os";
|
|
2488
|
+
import { join } from "path";
|
|
2489
|
+
import { runCli } from "repomix";
|
|
2490
|
+
var PAGE_SIZE_LINES = 5e3;
|
|
2491
|
+
var REPOMIX_TIMEOUT_MS = 12e4;
|
|
2492
|
+
var CACHE_TTL_MS = 30 * 60 * 1e3;
|
|
2493
|
+
var CACHE_MAX_ENTRIES = 5;
|
|
2494
|
+
var PackCache = class {
|
|
2495
|
+
entries = /* @__PURE__ */ new Map();
|
|
2496
|
+
set(outputId, content) {
|
|
2497
|
+
this.evictExpired();
|
|
2498
|
+
while (this.entries.size >= CACHE_MAX_ENTRIES) {
|
|
2499
|
+
const oldestKey = this.entries.keys().next().value;
|
|
2500
|
+
if (oldestKey === void 0) break;
|
|
2501
|
+
this.entries.delete(oldestKey);
|
|
2502
|
+
}
|
|
2503
|
+
const lines = splitIntoLines(content);
|
|
2504
|
+
const totalPages = Math.max(1, Math.ceil(lines.length / PAGE_SIZE_LINES));
|
|
2505
|
+
const entry = { outputId, lines, createdAt: Date.now(), totalPages };
|
|
2506
|
+
this.entries.set(outputId, entry);
|
|
2507
|
+
return entry;
|
|
2508
|
+
}
|
|
2509
|
+
get(outputId) {
|
|
2510
|
+
this.evictExpired();
|
|
2511
|
+
return this.entries.get(outputId);
|
|
2512
|
+
}
|
|
2513
|
+
size() {
|
|
2514
|
+
return this.entries.size;
|
|
2515
|
+
}
|
|
2516
|
+
evictExpired() {
|
|
2517
|
+
const now = Date.now();
|
|
2518
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
2519
|
+
if (now - entry.createdAt > CACHE_TTL_MS) {
|
|
2520
|
+
this.entries.delete(key);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
2525
|
+
function splitIntoLines(text) {
|
|
2526
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2527
|
+
const parts = normalized.split("\n");
|
|
2528
|
+
if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
|
|
2529
|
+
return parts;
|
|
2530
|
+
}
|
|
2531
|
+
async function runPack(opts) {
|
|
2532
|
+
const subpath = opts.args.subpath ?? ".";
|
|
2533
|
+
const targetDir = await resolveSafePath(opts.realRoot, subpath);
|
|
2534
|
+
const outputId = computeOutputId({
|
|
2535
|
+
realRoot: opts.realRoot,
|
|
2536
|
+
subpath,
|
|
2537
|
+
includePatterns: opts.args.includePatterns,
|
|
2538
|
+
ignorePatterns: opts.args.ignorePatterns
|
|
2539
|
+
});
|
|
2540
|
+
const cached = opts.cache.get(outputId);
|
|
2541
|
+
if (cached) {
|
|
2542
|
+
return buildPage(cached, 1);
|
|
2543
|
+
}
|
|
2544
|
+
const exec = opts._runCli ?? runCli;
|
|
2545
|
+
const content = await invokeRepomix(exec, targetDir, opts.args, REPOMIX_TIMEOUT_MS);
|
|
2546
|
+
const entry = opts.cache.set(outputId, content);
|
|
2547
|
+
return buildPage(entry, 1);
|
|
2548
|
+
}
|
|
2549
|
+
function computeOutputId(inputs) {
|
|
2550
|
+
const payload = JSON.stringify({
|
|
2551
|
+
r: inputs.realRoot,
|
|
2552
|
+
s: inputs.subpath,
|
|
2553
|
+
i: inputs.includePatterns ?? "",
|
|
2554
|
+
x: inputs.ignorePatterns ?? ""
|
|
2555
|
+
});
|
|
2556
|
+
const digest = createHash2("sha256").update(payload).digest("hex").slice(0, 12);
|
|
2557
|
+
return `pack-${digest}`;
|
|
2558
|
+
}
|
|
2559
|
+
function readPage(cache, outputId, page) {
|
|
2560
|
+
const entry = cache.get(outputId);
|
|
2561
|
+
if (!entry) {
|
|
2562
|
+
throw new PackCacheMissError(outputId);
|
|
2563
|
+
}
|
|
2564
|
+
if (!Number.isInteger(page) || page < 1 || page > entry.totalPages) {
|
|
2565
|
+
throw new InvalidPageError(page, entry.totalPages);
|
|
2566
|
+
}
|
|
2567
|
+
return buildPage(entry, page);
|
|
2568
|
+
}
|
|
2569
|
+
var PackCacheMissError = class extends Error {
|
|
2570
|
+
constructor(outputId) {
|
|
2571
|
+
super(`pack_output_not_found: ${outputId}`);
|
|
2572
|
+
this.outputId = outputId;
|
|
2573
|
+
this.name = "PackCacheMissError";
|
|
2574
|
+
}
|
|
2575
|
+
outputId;
|
|
2576
|
+
};
|
|
2577
|
+
var InvalidPageError = class extends Error {
|
|
2578
|
+
constructor(page, totalPages) {
|
|
2579
|
+
super(`invalid_page: ${String(page)} (must be 1..${String(totalPages)})`);
|
|
2580
|
+
this.page = page;
|
|
2581
|
+
this.totalPages = totalPages;
|
|
2582
|
+
this.name = "InvalidPageError";
|
|
2583
|
+
}
|
|
2584
|
+
page;
|
|
2585
|
+
totalPages;
|
|
2586
|
+
};
|
|
2587
|
+
function buildPage(entry, page) {
|
|
2588
|
+
const start = (page - 1) * PAGE_SIZE_LINES;
|
|
2589
|
+
const end = Math.min(start + PAGE_SIZE_LINES, entry.lines.length);
|
|
2590
|
+
const sliceLines = entry.lines.slice(start, end);
|
|
2591
|
+
const hasMore = page < entry.totalPages;
|
|
2592
|
+
return {
|
|
2593
|
+
outputId: entry.outputId,
|
|
2594
|
+
page,
|
|
2595
|
+
totalPages: entry.totalPages,
|
|
2596
|
+
totalLines: entry.lines.length,
|
|
2597
|
+
pageLines: sliceLines.length,
|
|
2598
|
+
hasMore,
|
|
2599
|
+
content: sliceLines.join("\n"),
|
|
2600
|
+
...hasMore && {
|
|
2601
|
+
nextPage: {
|
|
2602
|
+
tool: "local_pack_codebase_read_page",
|
|
2603
|
+
args: { outputId: entry.outputId, page: page + 1 }
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
async function invokeRepomix(exec, targetDir, args, timeoutMs) {
|
|
2609
|
+
const workDir = await mkdtemp(join(tmpdir(), "myspec-pack-"));
|
|
2610
|
+
const outputFile = join(workDir, "pack.txt");
|
|
2611
|
+
const options = {
|
|
2612
|
+
compress: true,
|
|
2613
|
+
style: "plain",
|
|
2614
|
+
quiet: true,
|
|
2615
|
+
output: outputFile,
|
|
2616
|
+
securityCheck: true,
|
|
2617
|
+
include: args.includePatterns,
|
|
2618
|
+
ignore: args.ignorePatterns
|
|
2619
|
+
};
|
|
2620
|
+
const execPromise = exec(["."], targetDir, options);
|
|
2621
|
+
try {
|
|
2622
|
+
await withTimeout(execPromise, timeoutMs);
|
|
2623
|
+
const content = await readFile2(outputFile, "utf-8");
|
|
2624
|
+
await rm(workDir, { recursive: true, force: true });
|
|
2625
|
+
return content;
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
void execPromise.catch(() => void 0).finally(() => {
|
|
2628
|
+
return rm(workDir, { recursive: true, force: true }).catch(() => void 0);
|
|
2629
|
+
});
|
|
2630
|
+
throw err;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
async function withTimeout(promise, timeoutMs) {
|
|
2634
|
+
let timer;
|
|
2635
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
2636
|
+
timer = setTimeout(() => {
|
|
2637
|
+
reject(new Error(`repomix timed out after ${String(timeoutMs)}ms`));
|
|
2638
|
+
}, timeoutMs);
|
|
2639
|
+
});
|
|
2640
|
+
try {
|
|
2641
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
2642
|
+
} finally {
|
|
2643
|
+
if (timer !== void 0) {
|
|
2644
|
+
clearTimeout(timer);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/reverse/local-fs-tools.ts
|
|
2650
|
+
var READ_FILE_MAX_BYTES = 5 * 1024 * 1024;
|
|
2651
|
+
var READ_FILE_BINARY_PROBE_BYTES = 8192;
|
|
2652
|
+
var READ_FILE_MAX_LINES = 2e3;
|
|
2653
|
+
var LIST_DIR_HARD_CAP = 5e3;
|
|
2654
|
+
var LIST_DIR_DEFAULT_CAP = 1e3;
|
|
2655
|
+
var GREP_MAX_MATCHES_DEFAULT = 200;
|
|
2656
|
+
var listDirInput = z7.object({
|
|
2657
|
+
path: z7.string(),
|
|
2658
|
+
recursive: z7.boolean().optional(),
|
|
2659
|
+
maxEntries: z7.number().int().positive().optional(),
|
|
2660
|
+
includeIgnored: z7.boolean().optional()
|
|
2661
|
+
});
|
|
2662
|
+
var readFileInput = z7.object({
|
|
2663
|
+
path: z7.string(),
|
|
2664
|
+
offset: z7.number().int().nonnegative().optional(),
|
|
2665
|
+
limit: z7.number().int().positive().optional(),
|
|
2666
|
+
includeIgnored: z7.boolean().optional()
|
|
2667
|
+
});
|
|
2668
|
+
var grepInput = z7.object({
|
|
2669
|
+
pattern: z7.string(),
|
|
2670
|
+
path: z7.string().optional(),
|
|
2671
|
+
isRegex: z7.boolean().optional(),
|
|
2672
|
+
caseSensitive: z7.boolean().optional(),
|
|
2673
|
+
maxMatches: z7.number().int().positive().optional(),
|
|
2674
|
+
contextLines: z7.number().int().nonnegative().optional(),
|
|
2675
|
+
includeIgnored: z7.boolean().optional()
|
|
2676
|
+
});
|
|
2677
|
+
var packCodebaseInput = z7.object({
|
|
2678
|
+
subpath: z7.string().optional(),
|
|
2679
|
+
includePatterns: z7.string().optional(),
|
|
2680
|
+
ignorePatterns: z7.string().optional()
|
|
2681
|
+
});
|
|
2682
|
+
var packCodebaseReadPageInput = z7.object({
|
|
2683
|
+
outputId: z7.string(),
|
|
2684
|
+
page: z7.number().int().positive()
|
|
2685
|
+
});
|
|
2686
|
+
function structuredError(error, message, extra) {
|
|
2687
|
+
const payload = { error, message, ...extra ?? {} };
|
|
2688
|
+
return {
|
|
2689
|
+
isError: true,
|
|
2690
|
+
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
function structuredOk(payload) {
|
|
2694
|
+
return {
|
|
2695
|
+
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
async function handleList(args, opts) {
|
|
2699
|
+
let realPath;
|
|
2700
|
+
try {
|
|
2701
|
+
realPath = await resolveSafePath(opts.realRoot, args.path);
|
|
2702
|
+
} catch (err) {
|
|
2703
|
+
return mapPathError(err);
|
|
2704
|
+
}
|
|
2705
|
+
if (!args.includeIgnored) {
|
|
2706
|
+
const check = isIgnored(args.path);
|
|
2707
|
+
if (check.ignored) {
|
|
2708
|
+
return structuredError("path_ignored", `Path matches ignore rule ${check.rule ?? ""}`);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
const cap = Math.min(args.maxEntries ?? LIST_DIR_DEFAULT_CAP, LIST_DIR_HARD_CAP);
|
|
2712
|
+
const entries = [];
|
|
2713
|
+
let truncated = false;
|
|
2714
|
+
async function walk(dir, prefix) {
|
|
2715
|
+
if (entries.length >= cap) {
|
|
2716
|
+
truncated = true;
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const items = await fs4.readdir(dir, { withFileTypes: true });
|
|
2720
|
+
for (const item of items) {
|
|
2721
|
+
if (entries.length >= cap) {
|
|
2722
|
+
truncated = true;
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
2726
|
+
if (!args.includeIgnored) {
|
|
2727
|
+
const c = isIgnored(rel);
|
|
2728
|
+
if (c.ignored) continue;
|
|
2729
|
+
}
|
|
2730
|
+
const full = join2(dir, item.name);
|
|
2731
|
+
let type = "file";
|
|
2732
|
+
if (item.isDirectory()) {
|
|
2733
|
+
type = "dir";
|
|
2734
|
+
} else if (item.isSymbolicLink()) {
|
|
2735
|
+
type = "symlink";
|
|
2736
|
+
}
|
|
2737
|
+
let size;
|
|
2738
|
+
let mtimeMs;
|
|
2739
|
+
try {
|
|
2740
|
+
const st = await fs4.stat(full);
|
|
2741
|
+
size = st.size;
|
|
2742
|
+
mtimeMs = st.mtimeMs;
|
|
2743
|
+
} catch {
|
|
2744
|
+
}
|
|
2745
|
+
entries.push({ name: rel, type, size, mtimeMs });
|
|
2746
|
+
if (args.recursive && type === "dir") {
|
|
2747
|
+
await walk(full, rel);
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
try {
|
|
2752
|
+
await walk(realPath, "");
|
|
2753
|
+
} catch (err) {
|
|
2754
|
+
return structuredError("list_dir_failed", errorMessage(err));
|
|
2755
|
+
}
|
|
2756
|
+
return structuredOk({ entries, truncated });
|
|
2757
|
+
}
|
|
2758
|
+
async function handleRead(args, opts) {
|
|
2759
|
+
let realPath;
|
|
2760
|
+
try {
|
|
2761
|
+
realPath = await resolveSafePath(opts.realRoot, args.path);
|
|
2762
|
+
} catch (err) {
|
|
2763
|
+
return mapPathError(err);
|
|
2764
|
+
}
|
|
2765
|
+
if (!args.includeIgnored) {
|
|
2766
|
+
const check = isIgnored(args.path);
|
|
2767
|
+
if (check.ignored) {
|
|
2768
|
+
return structuredError("path_ignored", `Path matches ignore rule ${check.rule ?? ""}`);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
let stat;
|
|
2772
|
+
try {
|
|
2773
|
+
stat = await fs4.stat(realPath);
|
|
2774
|
+
} catch (err) {
|
|
2775
|
+
return structuredError("stat_failed", errorMessage(err));
|
|
2776
|
+
}
|
|
2777
|
+
if (!stat.isFile()) {
|
|
2778
|
+
return structuredError("not_a_file", `Not a regular file: ${args.path}`);
|
|
2779
|
+
}
|
|
2780
|
+
if (stat.size > READ_FILE_MAX_BYTES) {
|
|
2781
|
+
return structuredError(
|
|
2782
|
+
"file_too_large",
|
|
2783
|
+
`File is ${String(stat.size)} bytes; the read cap is ${String(READ_FILE_MAX_BYTES)} bytes. Use local_fs_grep to find the lines you need, or local_pack_codebase to pull the whole tree.`,
|
|
2784
|
+
{ sizeBytes: stat.size, maxBytes: READ_FILE_MAX_BYTES }
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
let raw;
|
|
2788
|
+
try {
|
|
2789
|
+
raw = await fs4.readFile(realPath);
|
|
2790
|
+
} catch (err) {
|
|
2791
|
+
return structuredError("read_failed", errorMessage(err));
|
|
2792
|
+
}
|
|
2793
|
+
const probe = raw.subarray(0, Math.min(READ_FILE_BINARY_PROBE_BYTES, raw.length));
|
|
2794
|
+
if (probe.includes(0)) {
|
|
2795
|
+
return structuredError(
|
|
2796
|
+
"binary_file_not_supported",
|
|
2797
|
+
`Binary content detected in ${args.path}; line-based pagination doesn't apply. Skip this file or use local_fs_grep on neighbouring text files.`
|
|
2798
|
+
);
|
|
2799
|
+
}
|
|
2800
|
+
const text = normalizeNewlines(raw.toString("utf-8"));
|
|
2801
|
+
const lines = splitIntoLines2(text);
|
|
2802
|
+
const page = paginateLines(lines, args.offset, args.limit);
|
|
2803
|
+
if ("error" in page) {
|
|
2804
|
+
return structuredError("invalid_offset", page.error);
|
|
2805
|
+
}
|
|
2806
|
+
return structuredOk(page);
|
|
2807
|
+
}
|
|
2808
|
+
function normalizeNewlines(s) {
|
|
2809
|
+
const noBom = s.charCodeAt(0) === 65279 ? s.slice(1) : s;
|
|
2810
|
+
return noBom.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
2811
|
+
}
|
|
2812
|
+
function splitIntoLines2(text) {
|
|
2813
|
+
const parts = text.split("\n");
|
|
2814
|
+
if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop();
|
|
2815
|
+
return parts;
|
|
2816
|
+
}
|
|
2817
|
+
function paginateLines(lines, offsetArg, limitArg) {
|
|
2818
|
+
const totalLines = lines.length;
|
|
2819
|
+
const rawOffset = offsetArg ?? 1;
|
|
2820
|
+
const offset = rawOffset === 0 ? 1 : rawOffset;
|
|
2821
|
+
if (offset < 1) {
|
|
2822
|
+
return { error: `Invalid offset ${String(rawOffset)}; offset is 1-based and must be >= 1.` };
|
|
2823
|
+
}
|
|
2824
|
+
if (totalLines === 0) {
|
|
2825
|
+
return {
|
|
2826
|
+
content: "",
|
|
2827
|
+
startLine: 1,
|
|
2828
|
+
endLine: 0,
|
|
2829
|
+
readLines: 0,
|
|
2830
|
+
totalLines: 0,
|
|
2831
|
+
truncated: false
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
if (offset > totalLines) {
|
|
2835
|
+
return {
|
|
2836
|
+
error: `Offset ${String(rawOffset)} is past the end of the file (total ${String(totalLines)} lines).`
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
const effectiveLimit = Math.min(Math.max(1, limitArg ?? READ_FILE_MAX_LINES), READ_FILE_MAX_LINES);
|
|
2840
|
+
const startIdx = offset - 1;
|
|
2841
|
+
const endIdx = Math.min(startIdx + effectiveLimit, totalLines);
|
|
2842
|
+
const slice = lines.slice(startIdx, endIdx);
|
|
2843
|
+
const truncated = endIdx < totalLines;
|
|
2844
|
+
return {
|
|
2845
|
+
content: slice.join("\n"),
|
|
2846
|
+
startLine: offset,
|
|
2847
|
+
endLine: endIdx,
|
|
2848
|
+
readLines: slice.length,
|
|
2849
|
+
totalLines,
|
|
2850
|
+
truncated,
|
|
2851
|
+
...truncated ? { nextOffset: endIdx + 1 } : {}
|
|
2852
|
+
};
|
|
2853
|
+
}
|
|
2854
|
+
async function handleGrep(args, opts) {
|
|
2855
|
+
const subpath = args.path ?? ".";
|
|
2856
|
+
let realPath;
|
|
2857
|
+
try {
|
|
2858
|
+
realPath = await resolveSafePath(opts.realRoot, subpath);
|
|
2859
|
+
} catch (err) {
|
|
2860
|
+
return mapPathError(err);
|
|
2861
|
+
}
|
|
2862
|
+
const maxMatches = args.maxMatches ?? GREP_MAX_MATCHES_DEFAULT;
|
|
2863
|
+
const contextLines = args.contextLines ?? 0;
|
|
2864
|
+
const isRegex = args.isRegex ?? false;
|
|
2865
|
+
const caseSensitive = args.caseSensitive ?? false;
|
|
2866
|
+
const ripgrep = await tryRipgrep(realPath, opts.realRoot, args.pattern, {
|
|
2867
|
+
isRegex,
|
|
2868
|
+
caseSensitive,
|
|
2869
|
+
maxMatches,
|
|
2870
|
+
contextLines,
|
|
2871
|
+
includeIgnored: args.includeIgnored ?? false
|
|
2872
|
+
});
|
|
2873
|
+
if (ripgrep.ok) {
|
|
2874
|
+
return structuredOk(ripgrep.value);
|
|
2875
|
+
}
|
|
2876
|
+
if (ripgrep.fatal) {
|
|
2877
|
+
return structuredError("grep_failed", ripgrep.error);
|
|
2878
|
+
}
|
|
2879
|
+
return structuredOk(
|
|
2880
|
+
await nodeGrep(realPath, opts.realRoot, args.pattern, {
|
|
2881
|
+
isRegex,
|
|
2882
|
+
caseSensitive,
|
|
2883
|
+
maxMatches,
|
|
2884
|
+
includeIgnored: args.includeIgnored ?? false
|
|
2885
|
+
})
|
|
2886
|
+
);
|
|
2887
|
+
}
|
|
2888
|
+
async function tryRipgrep(searchRoot, realRoot, pattern, opts) {
|
|
2889
|
+
return new Promise((resolveP) => {
|
|
2890
|
+
const argv = ["--json"];
|
|
2891
|
+
if (!opts.isRegex) argv.push("--fixed-strings");
|
|
2892
|
+
if (!opts.caseSensitive) argv.push("--ignore-case");
|
|
2893
|
+
if (opts.contextLines > 0) argv.push(`--context=${String(opts.contextLines)}`);
|
|
2894
|
+
if (!opts.includeIgnored) {
|
|
2895
|
+
for (const a of ripgrepIgnoreGlobs()) argv.push(a);
|
|
2896
|
+
}
|
|
2897
|
+
argv.push("--", pattern, searchRoot);
|
|
2898
|
+
const child = spawn("rg", argv);
|
|
2899
|
+
let killed = false;
|
|
2900
|
+
let stdoutBuf = "";
|
|
2901
|
+
let stderr = "";
|
|
2902
|
+
const matches = [];
|
|
2903
|
+
const tryEmitFromBuffer = () => {
|
|
2904
|
+
let nl;
|
|
2905
|
+
while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
|
|
2906
|
+
const line = stdoutBuf.slice(0, nl);
|
|
2907
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
2908
|
+
const m = parseRipgrepLine(line, realRoot);
|
|
2909
|
+
if (!m) continue;
|
|
2910
|
+
if (!opts.includeIgnored && isIgnored(m.file).ignored) continue;
|
|
2911
|
+
matches.push(m);
|
|
2912
|
+
if (matches.length >= opts.maxMatches && !killed) {
|
|
2913
|
+
killed = true;
|
|
2914
|
+
try {
|
|
2915
|
+
child.kill("SIGTERM");
|
|
2916
|
+
} catch {
|
|
2917
|
+
}
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
};
|
|
2922
|
+
child.stdout.on("data", (b) => {
|
|
2923
|
+
stdoutBuf += b.toString("utf-8");
|
|
2924
|
+
tryEmitFromBuffer();
|
|
2925
|
+
});
|
|
2926
|
+
child.stderr.on("data", (b) => {
|
|
2927
|
+
stderr += b.toString("utf-8");
|
|
2928
|
+
});
|
|
2929
|
+
child.on("error", (err) => {
|
|
2930
|
+
if (err.code === "ENOENT") {
|
|
2931
|
+
resolveP({ ok: false, fatal: false });
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
resolveP({ ok: false, fatal: true, error: err.message });
|
|
2935
|
+
});
|
|
2936
|
+
child.on("close", (code) => {
|
|
2937
|
+
if (stdoutBuf.length > 0) tryEmitFromBuffer();
|
|
2938
|
+
if (code === null && !killed) {
|
|
2939
|
+
resolveP({ ok: false, fatal: true, error: "rg killed" });
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
if (code === 2) {
|
|
2943
|
+
resolveP({ ok: false, fatal: true, error: stderr || "rg failed" });
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
resolveP({
|
|
2947
|
+
ok: true,
|
|
2948
|
+
value: { matches, truncated: killed }
|
|
2949
|
+
});
|
|
2950
|
+
});
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
function parseRipgrepLine(rawLine, realRoot) {
|
|
2954
|
+
if (rawLine.length === 0) return null;
|
|
2955
|
+
let parsed;
|
|
2956
|
+
try {
|
|
2957
|
+
parsed = JSON.parse(rawLine);
|
|
2958
|
+
} catch {
|
|
2959
|
+
return null;
|
|
2960
|
+
}
|
|
2961
|
+
const obj = parsed;
|
|
2962
|
+
if (obj.type !== "match" || !obj.data) return null;
|
|
2963
|
+
const pathText = obj.data.path?.text;
|
|
2964
|
+
const line = obj.data.line_number;
|
|
2965
|
+
const text = obj.data.lines?.text ?? "";
|
|
2966
|
+
if (!pathText || typeof line !== "number") return null;
|
|
2967
|
+
return {
|
|
2968
|
+
file: relative(realRoot, pathText) || pathText,
|
|
2969
|
+
line,
|
|
2970
|
+
text: text.replace(/\n$/, "")
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
var NODE_GREP_FILE_SIZE_CAP = 10 * 1024 * 1024;
|
|
2974
|
+
async function nodeGrep(searchRoot, realRoot, pattern, opts) {
|
|
2975
|
+
const regex = opts.isRegex ? new RegExp(pattern, opts.caseSensitive ? "" : "i") : new RegExp(escapeRegExp(pattern), opts.caseSensitive ? "" : "i");
|
|
2976
|
+
const matches = [];
|
|
2977
|
+
let truncated = false;
|
|
2978
|
+
async function walk(dir, prefix) {
|
|
2979
|
+
if (matches.length >= opts.maxMatches) {
|
|
2980
|
+
truncated = true;
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
let items;
|
|
2984
|
+
try {
|
|
2985
|
+
items = await fs4.readdir(dir, { withFileTypes: true });
|
|
2986
|
+
} catch {
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
for (const item of items) {
|
|
2990
|
+
if (matches.length >= opts.maxMatches) {
|
|
2991
|
+
truncated = true;
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
2995
|
+
if (!opts.includeIgnored && isIgnored(rel).ignored) continue;
|
|
2996
|
+
const full = join2(dir, item.name);
|
|
2997
|
+
if (item.isDirectory()) {
|
|
2998
|
+
await walk(full, rel);
|
|
2999
|
+
continue;
|
|
3000
|
+
}
|
|
3001
|
+
if (!item.isFile()) continue;
|
|
3002
|
+
try {
|
|
3003
|
+
const st = await fs4.stat(full);
|
|
3004
|
+
if (st.size > NODE_GREP_FILE_SIZE_CAP) continue;
|
|
3005
|
+
const content = await fs4.readFile(full, "utf-8");
|
|
3006
|
+
const lines = content.split("\n");
|
|
3007
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
3008
|
+
if (matches.length >= opts.maxMatches) {
|
|
3009
|
+
truncated = true;
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
const line = lines[i];
|
|
3013
|
+
if (line === void 0) continue;
|
|
3014
|
+
if (regex.test(line)) {
|
|
3015
|
+
matches.push({
|
|
3016
|
+
file: relative(realRoot, full) || full,
|
|
3017
|
+
line: i + 1,
|
|
3018
|
+
text: line
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
} catch {
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
await walk(searchRoot, relative(realRoot, searchRoot));
|
|
3027
|
+
return { matches, truncated };
|
|
3028
|
+
}
|
|
3029
|
+
function escapeRegExp(s) {
|
|
3030
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3031
|
+
}
|
|
3032
|
+
function errorMessage(err) {
|
|
3033
|
+
return err instanceof Error ? err.message : String(err);
|
|
3034
|
+
}
|
|
3035
|
+
function mapPathError(err) {
|
|
3036
|
+
if (err instanceof AbsolutePathError) {
|
|
3037
|
+
return structuredError("absolute_path_rejected", err.message, { path: err.userPath });
|
|
3038
|
+
}
|
|
3039
|
+
if (err instanceof PathOutsideRootError) {
|
|
3040
|
+
return structuredError("path_outside_root", err.message, { path: err.userPath });
|
|
3041
|
+
}
|
|
3042
|
+
if (err.code === "ENOENT") {
|
|
3043
|
+
return structuredError("not_found", "Path does not exist");
|
|
3044
|
+
}
|
|
3045
|
+
return structuredError("resolve_failed", errorMessage(err));
|
|
3046
|
+
}
|
|
3047
|
+
function mapPackError(err) {
|
|
3048
|
+
if (err instanceof PackCacheMissError) {
|
|
3049
|
+
return structuredError("pack_output_not_found", err.message, { outputId: err.outputId });
|
|
3050
|
+
}
|
|
3051
|
+
if (err instanceof InvalidPageError) {
|
|
3052
|
+
return structuredError("invalid_page", err.message, {
|
|
3053
|
+
page: err.page,
|
|
3054
|
+
totalPages: err.totalPages
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
if (err instanceof AbsolutePathError || err instanceof PathOutsideRootError) {
|
|
3058
|
+
return mapPathError(err);
|
|
3059
|
+
}
|
|
3060
|
+
return structuredError("pack_failed", errorMessage(err));
|
|
3061
|
+
}
|
|
3062
|
+
function registerLocalFsTools(server, opts) {
|
|
3063
|
+
const packCache = new PackCache();
|
|
3064
|
+
const tools = [
|
|
3065
|
+
{
|
|
3066
|
+
name: "local_fs_list_dir",
|
|
3067
|
+
description: "List directory entries under the granted --root.",
|
|
3068
|
+
inputSchema: jsonSchemaFromZod(listDirInput),
|
|
3069
|
+
handler: (args) => handleList(args, opts)
|
|
3070
|
+
},
|
|
3071
|
+
{
|
|
3072
|
+
name: "local_fs_read_file",
|
|
3073
|
+
description: "Read a file under the granted --root. Hard cap 1 MiB per call.",
|
|
3074
|
+
inputSchema: jsonSchemaFromZod(readFileInput),
|
|
3075
|
+
handler: (args) => handleRead(args, opts)
|
|
3076
|
+
},
|
|
3077
|
+
{
|
|
3078
|
+
name: "local_fs_grep",
|
|
3079
|
+
description: "Search for a pattern under the granted --root (ripgrep with Node fallback).",
|
|
3080
|
+
inputSchema: jsonSchemaFromZod(grepInput),
|
|
3081
|
+
handler: (args) => handleGrep(args, opts)
|
|
3082
|
+
},
|
|
3083
|
+
{
|
|
3084
|
+
name: "local_pack_codebase",
|
|
3085
|
+
description: "Pack the entire local codebase (under the granted --root) into a single plain-text bundle using `npx repomix@latest --style plain --compress`. Prefer this over many `local_fs_read_file` calls when you need broad codebase context. Output is paginated at 5000 lines per page; the response includes `outputId`, `page`, `totalPages`, `totalLines`, `pageLines`, `hasMore`, and a `nextPage` hint. Use `local_pack_codebase_read_page` to fetch subsequent pages by `outputId`. Repeat calls with the same `subpath`/`includePatterns`/`ignorePatterns` return the same `outputId` and are served instantly from the 30-minute cache without re-running repomix.",
|
|
3086
|
+
inputSchema: jsonSchemaFromZod(packCodebaseInput),
|
|
3087
|
+
handler: async (args) => {
|
|
3088
|
+
try {
|
|
3089
|
+
const page = await runPack({ realRoot: opts.realRoot, cache: packCache, args });
|
|
3090
|
+
return structuredOk(page);
|
|
3091
|
+
} catch (err) {
|
|
3092
|
+
return mapPackError(err);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
},
|
|
3096
|
+
{
|
|
3097
|
+
name: "local_pack_codebase_read_page",
|
|
3098
|
+
description: "Read a specific page of a previously packed codebase output. Provide the `outputId` returned by `local_pack_codebase` and the 1-indexed `page` to fetch. Pages are 5000 lines of plain text. Cache entries expire after 30 minutes; on cache miss, re-run `local_pack_codebase` (same args return the same outputId \u2014 re-pack is cheap, served from cache).",
|
|
3099
|
+
inputSchema: jsonSchemaFromZod(packCodebaseReadPageInput),
|
|
3100
|
+
handler: (args) => {
|
|
3101
|
+
try {
|
|
3102
|
+
const page = readPage(packCache, args.outputId, args.page);
|
|
3103
|
+
return Promise.resolve(structuredOk(page));
|
|
3104
|
+
} catch (err) {
|
|
3105
|
+
return Promise.resolve(mapPackError(err));
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
];
|
|
3110
|
+
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
3111
|
+
return Promise.resolve({
|
|
3112
|
+
tools: tools.map((t) => ({
|
|
3113
|
+
name: t.name,
|
|
3114
|
+
description: t.description,
|
|
3115
|
+
inputSchema: t.inputSchema
|
|
3116
|
+
}))
|
|
3117
|
+
});
|
|
3118
|
+
});
|
|
3119
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3120
|
+
const name = request.params.name;
|
|
3121
|
+
const found = tools.find((t) => t.name === name);
|
|
3122
|
+
const started = performance.now();
|
|
3123
|
+
const argsSummary = summarizeArgs(request.params.arguments ?? {});
|
|
3124
|
+
process.stdout.write(`[tool] ${name} ${argsSummary}
|
|
3125
|
+
`);
|
|
3126
|
+
if (!found) {
|
|
3127
|
+
process.stdout.write(`[tool] ${name} -> unknown_tool
|
|
3128
|
+
`);
|
|
3129
|
+
return structuredError("unknown_tool", `Unknown tool: ${name}`);
|
|
3130
|
+
}
|
|
3131
|
+
try {
|
|
3132
|
+
const result = await found.handler(request.params.arguments ?? {});
|
|
3133
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
3134
|
+
const status = isErrorResult(result) ? "error" : "ok";
|
|
3135
|
+
process.stdout.write(`[tool] ${name} -> ${status} ${String(elapsedMs)}ms
|
|
3136
|
+
`);
|
|
3137
|
+
return result;
|
|
3138
|
+
} catch (err) {
|
|
3139
|
+
const msg = errorMessage(err);
|
|
3140
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
3141
|
+
process.stdout.write(`[tool] ${name} -> threw ${String(elapsedMs)}ms ${msg}
|
|
3142
|
+
`);
|
|
3143
|
+
opts.log?.(`local-fs: tool ${name} threw: ${msg}
|
|
3144
|
+
`);
|
|
3145
|
+
return structuredError("handler_threw", msg);
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
function summarizeArgs(args) {
|
|
3150
|
+
if (typeof args !== "object" || args === null) return "";
|
|
3151
|
+
const obj = args;
|
|
3152
|
+
const parts = [];
|
|
3153
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
3154
|
+
if (typeof v === "string") {
|
|
3155
|
+
parts.push(`${k}=${v.length > 60 ? v.slice(0, 60) + "\u2026" : v}`);
|
|
3156
|
+
} else if (typeof v === "number" || typeof v === "boolean") {
|
|
3157
|
+
parts.push(`${k}=${String(v)}`);
|
|
3158
|
+
} else {
|
|
3159
|
+
parts.push(`${k}=<${typeof v}>`);
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return parts.join(" ");
|
|
3163
|
+
}
|
|
3164
|
+
function isErrorResult(result) {
|
|
3165
|
+
if (typeof result !== "object" || result === null) return false;
|
|
3166
|
+
return result.isError === true;
|
|
3167
|
+
}
|
|
3168
|
+
function jsonSchemaFromZod(schema) {
|
|
3169
|
+
return zodToJson(schema);
|
|
3170
|
+
}
|
|
3171
|
+
function zodToJson(schema) {
|
|
3172
|
+
if (schema instanceof z7.ZodObject) {
|
|
3173
|
+
const shape = schema.shape;
|
|
3174
|
+
const properties = {};
|
|
3175
|
+
const required = [];
|
|
3176
|
+
for (const [key, val] of Object.entries(shape)) {
|
|
3177
|
+
properties[key] = zodToJson(val);
|
|
3178
|
+
if (!val.safeParse(void 0).success) required.push(key);
|
|
3179
|
+
}
|
|
3180
|
+
const out = {
|
|
3181
|
+
type: "object",
|
|
3182
|
+
properties
|
|
3183
|
+
};
|
|
3184
|
+
if (required.length > 0) out.required = required;
|
|
3185
|
+
return out;
|
|
3186
|
+
}
|
|
3187
|
+
if (schema instanceof z7.ZodOptional) {
|
|
3188
|
+
return zodToJson(schema.unwrap());
|
|
3189
|
+
}
|
|
3190
|
+
if (schema instanceof z7.ZodString) {
|
|
3191
|
+
return { type: "string" };
|
|
3192
|
+
}
|
|
3193
|
+
if (schema instanceof z7.ZodNumber) {
|
|
3194
|
+
return { type: "number" };
|
|
3195
|
+
}
|
|
3196
|
+
if (schema instanceof z7.ZodBoolean) {
|
|
3197
|
+
return { type: "boolean" };
|
|
3198
|
+
}
|
|
3199
|
+
if (schema instanceof z7.ZodEnum) {
|
|
3200
|
+
return { type: "string", enum: schema.options };
|
|
3201
|
+
}
|
|
3202
|
+
return { type: "string" };
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
// src/reverse/run.ts
|
|
3206
|
+
var PING_INTERVAL_MS = 3e4;
|
|
3207
|
+
var PONG_TIMEOUT_MS = 1e4;
|
|
3208
|
+
var RECONNECT_BACKOFF_INITIAL_MS = 1e3;
|
|
3209
|
+
var RECONNECT_BACKOFF_MAX_MS = 3e4;
|
|
3210
|
+
var TERMINAL_CLOSE_CODES = /* @__PURE__ */ new Set([
|
|
3211
|
+
4002
|
|
3212
|
+
// superseded — a newer connection from the same user took over
|
|
3213
|
+
]);
|
|
3214
|
+
async function runReverse(opts) {
|
|
3215
|
+
const realRoot = await canonicaliseRoot(opts.root);
|
|
3216
|
+
const rootLabel = basename3(realRoot);
|
|
3217
|
+
process.stderr.write(
|
|
3218
|
+
`myspec-mcp reverse: granting read access to ${realRoot}
|
|
3219
|
+
myspec-mcp reverse: connecting to ${opts.agentUrl} (label=${rootLabel})
|
|
3220
|
+
Press Ctrl-C to stop. Auto-reconnect enabled.
|
|
3221
|
+
`
|
|
3222
|
+
);
|
|
3223
|
+
let backoffMs = RECONNECT_BACKOFF_INITIAL_MS;
|
|
3224
|
+
let attempt = 0;
|
|
3225
|
+
let authRetried = false;
|
|
3226
|
+
for (; ; ) {
|
|
3227
|
+
attempt += 1;
|
|
3228
|
+
let outcome;
|
|
3229
|
+
let accessToken;
|
|
3230
|
+
try {
|
|
3231
|
+
accessToken = await opts.getAccessToken();
|
|
3232
|
+
} catch (err) {
|
|
3233
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3234
|
+
process.stderr.write(
|
|
3235
|
+
`myspec-mcp reverse: cannot obtain access token: ${message}
|
|
3236
|
+
Run \`npx @myspec/mcp-server login\` (or set MYSPEC_REFRESH_TOKEN) and try again.
|
|
3237
|
+
`
|
|
3238
|
+
);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
try {
|
|
3242
|
+
outcome = await runOneConnection({
|
|
3243
|
+
realRoot,
|
|
3244
|
+
rootLabel,
|
|
3245
|
+
agentUrl: opts.agentUrl,
|
|
3246
|
+
accessToken,
|
|
3247
|
+
version: opts.version
|
|
3248
|
+
});
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3251
|
+
process.stderr.write(`myspec-mcp reverse: connect attempt ${String(attempt)} failed: ${message}
|
|
3252
|
+
`);
|
|
3253
|
+
outcome = { terminal: false, reason: `connect_error: ${message}`, upMs: 0, code: 0 };
|
|
3254
|
+
}
|
|
3255
|
+
if (outcome.upMs >= HEALTHY_CONNECTION_THRESHOLD_MS) {
|
|
3256
|
+
backoffMs = RECONNECT_BACKOFF_INITIAL_MS;
|
|
3257
|
+
authRetried = false;
|
|
3258
|
+
}
|
|
3259
|
+
if (outcome.code === 4001) {
|
|
3260
|
+
if (opts.onAuthFailed && !authRetried) {
|
|
3261
|
+
process.stderr.write(
|
|
3262
|
+
"myspec-mcp reverse: server rejected token (4001); force-refreshing and retrying once...\n"
|
|
3263
|
+
);
|
|
3264
|
+
try {
|
|
3265
|
+
await opts.onAuthFailed();
|
|
3266
|
+
authRetried = true;
|
|
3267
|
+
continue;
|
|
3268
|
+
} catch (err) {
|
|
3269
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3270
|
+
process.stderr.write(
|
|
3271
|
+
`myspec-mcp reverse: token refresh failed: ${message}; not reconnecting.
|
|
3272
|
+
`
|
|
3273
|
+
);
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
process.stderr.write(
|
|
3278
|
+
"myspec-mcp reverse: token rejected after refresh attempt; run `npx @myspec/mcp-server login` and retry.\n"
|
|
3279
|
+
);
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
if (outcome.terminal) {
|
|
3283
|
+
process.stderr.write(
|
|
3284
|
+
`myspec-mcp reverse: terminal close (${outcome.reason}); not reconnecting.
|
|
3285
|
+
`
|
|
3286
|
+
);
|
|
3287
|
+
return;
|
|
3288
|
+
}
|
|
3289
|
+
process.stderr.write(
|
|
3290
|
+
`myspec-mcp reverse: reconnecting in ${String(Math.round(backoffMs / 1e3))}s...
|
|
3291
|
+
`
|
|
3292
|
+
);
|
|
3293
|
+
await sleep2(backoffMs);
|
|
3294
|
+
backoffMs = Math.min(backoffMs * 2, RECONNECT_BACKOFF_MAX_MS);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
var HEALTHY_CONNECTION_THRESHOLD_MS = 5e3;
|
|
3298
|
+
async function runOneConnection(opts) {
|
|
3299
|
+
const ws = new WebSocket(opts.agentUrl, {
|
|
3300
|
+
headers: {
|
|
3301
|
+
Authorization: `Bearer ${opts.accessToken}`,
|
|
3302
|
+
"X-Mcp-Client": `myspec-mcp-server/${opts.version}`,
|
|
3303
|
+
"X-Mcp-Root-Label": opts.rootLabel
|
|
3304
|
+
}
|
|
3305
|
+
});
|
|
3306
|
+
await waitForOpen(ws);
|
|
3307
|
+
const transport = new WebSocketClientTransport(ws);
|
|
3308
|
+
const server = new Server(
|
|
3309
|
+
{ name: "myspec-mcp-server-reverse", version: opts.version },
|
|
3310
|
+
{ capabilities: { tools: {} } }
|
|
3311
|
+
);
|
|
3312
|
+
registerLocalFsTools(server, {
|
|
3313
|
+
realRoot: opts.realRoot,
|
|
3314
|
+
log: (line) => process.stderr.write(line)
|
|
3315
|
+
});
|
|
3316
|
+
await server.connect(transport);
|
|
3317
|
+
const connectedAt = Date.now();
|
|
3318
|
+
process.stderr.write("myspec-mcp reverse: connected; awaiting tool calls.\n");
|
|
3319
|
+
let awaitingPong = false;
|
|
3320
|
+
let pongTimer = null;
|
|
3321
|
+
const pingTimer = setInterval(() => {
|
|
3322
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
3323
|
+
if (awaitingPong) {
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
awaitingPong = true;
|
|
3327
|
+
pongTimer = setTimeout(() => {
|
|
3328
|
+
process.stderr.write("myspec-mcp reverse: pong timeout; terminating socket.\n");
|
|
3329
|
+
try {
|
|
3330
|
+
ws.terminate();
|
|
3331
|
+
} catch {
|
|
3332
|
+
}
|
|
3333
|
+
}, PONG_TIMEOUT_MS);
|
|
3334
|
+
try {
|
|
3335
|
+
ws.ping();
|
|
3336
|
+
} catch {
|
|
3337
|
+
try {
|
|
3338
|
+
ws.terminate();
|
|
3339
|
+
} catch {
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
}, PING_INTERVAL_MS);
|
|
3343
|
+
ws.on("pong", () => {
|
|
3344
|
+
awaitingPong = false;
|
|
3345
|
+
if (pongTimer) {
|
|
3346
|
+
clearTimeout(pongTimer);
|
|
3347
|
+
pongTimer = null;
|
|
3348
|
+
}
|
|
3349
|
+
});
|
|
3350
|
+
return new Promise((resolve2) => {
|
|
3351
|
+
ws.once("close", (code, reasonBuf) => {
|
|
3352
|
+
clearInterval(pingTimer);
|
|
3353
|
+
if (pongTimer) clearTimeout(pongTimer);
|
|
3354
|
+
const reasonStr = typeof reasonBuf === "string" ? reasonBuf : Buffer.from(reasonBuf).toString("utf-8");
|
|
3355
|
+
process.stderr.write(
|
|
3356
|
+
`myspec-mcp reverse: connection closed (code=${String(code)}, reason=${reasonStr || "-"})
|
|
3357
|
+
`
|
|
3358
|
+
);
|
|
3359
|
+
const terminal = TERMINAL_CLOSE_CODES.has(code);
|
|
3360
|
+
resolve2({
|
|
3361
|
+
terminal,
|
|
3362
|
+
reason: `code=${String(code)} ${reasonStr}`,
|
|
3363
|
+
upMs: Date.now() - connectedAt,
|
|
3364
|
+
code
|
|
3365
|
+
});
|
|
3366
|
+
});
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
function waitForOpen(ws) {
|
|
3370
|
+
return new Promise((resolve2, reject) => {
|
|
3371
|
+
const onOpen = () => {
|
|
3372
|
+
ws.removeListener("error", onError);
|
|
3373
|
+
resolve2();
|
|
3374
|
+
};
|
|
3375
|
+
const onError = (err) => {
|
|
3376
|
+
ws.removeListener("open", onOpen);
|
|
3377
|
+
reject(err);
|
|
3378
|
+
};
|
|
3379
|
+
ws.once("open", onOpen);
|
|
3380
|
+
ws.once("error", onError);
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
function sleep2(ms) {
|
|
3384
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
// src/index.ts
|
|
3388
|
+
function parseArgs(argv) {
|
|
3389
|
+
const [first, ...rest] = argv;
|
|
3390
|
+
let command = "serve";
|
|
3391
|
+
let flagArgs = argv;
|
|
3392
|
+
if (first === "login" || first === "logout" || first === "serve" || first === "reverse" || first === "help" || first === "version") {
|
|
3393
|
+
command = first;
|
|
3394
|
+
flagArgs = rest;
|
|
3395
|
+
} else if (first === "--help" || first === "-h") {
|
|
3396
|
+
command = "help";
|
|
3397
|
+
flagArgs = rest;
|
|
3398
|
+
} else if (first === "--version" || first === "-v") {
|
|
3399
|
+
command = "version";
|
|
3400
|
+
flagArgs = rest;
|
|
3401
|
+
}
|
|
3402
|
+
const flags = /* @__PURE__ */ new Map();
|
|
3403
|
+
const unknownPositional = [];
|
|
3404
|
+
for (let i = 0; i < flagArgs.length; i += 1) {
|
|
3405
|
+
const arg = flagArgs[i];
|
|
3406
|
+
if (arg === void 0) {
|
|
3407
|
+
continue;
|
|
3408
|
+
}
|
|
3409
|
+
if (!arg.startsWith("--")) {
|
|
3410
|
+
unknownPositional.push(arg);
|
|
3411
|
+
continue;
|
|
3412
|
+
}
|
|
3413
|
+
const eq = arg.indexOf("=");
|
|
3414
|
+
if (eq > -1) {
|
|
3415
|
+
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
|
|
3416
|
+
continue;
|
|
3417
|
+
}
|
|
3418
|
+
const next = flagArgs[i + 1];
|
|
3419
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
3420
|
+
flags.set(arg.slice(2), next);
|
|
3421
|
+
i += 1;
|
|
3422
|
+
} else {
|
|
3423
|
+
flags.set(arg.slice(2), true);
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
if (unknownPositional.length > 0) {
|
|
3427
|
+
throw new Error(
|
|
3428
|
+
`Unknown positional arguments: ${unknownPositional.join(", ")}. Run \`npx @myspec/mcp-server --help\` for usage.`
|
|
3429
|
+
);
|
|
3430
|
+
}
|
|
3431
|
+
return { command, flags };
|
|
3432
|
+
}
|
|
3433
|
+
function flagString(flags, key) {
|
|
3434
|
+
const value = flags.get(key);
|
|
3435
|
+
return typeof value === "string" ? value : void 0;
|
|
3436
|
+
}
|
|
3437
|
+
function flagBool(flags, key) {
|
|
3438
|
+
return flags.get(key) === true || flags.get(key) === "true";
|
|
3439
|
+
}
|
|
3440
|
+
function printHelp() {
|
|
3441
|
+
const lines = [
|
|
3442
|
+
"myspec-mcp \u2014 MCP server for the MySpec platform",
|
|
3443
|
+
"",
|
|
3444
|
+
"Usage: npx @myspec/mcp-server [command] [flags]",
|
|
3445
|
+
" (or `myspec-mcp [command]` when installed on your PATH)",
|
|
3446
|
+
"",
|
|
3447
|
+
"Commands:",
|
|
3448
|
+
" (default) Start MCP server over stdio",
|
|
3449
|
+
" serve Start MCP server over stdio",
|
|
3450
|
+
" login Sign in via browser (chooser page on the webapp)",
|
|
3451
|
+
" login --paste Sign in by pasting a one-time code",
|
|
3452
|
+
" logout Revoke refresh token and clear local credentials",
|
|
3453
|
+
" reverse --root <dir> Connect to ai-agent and expose local_fs tools (spike).",
|
|
3454
|
+
" Uses the saved refresh_token from `npx @myspec/mcp-server login`",
|
|
3455
|
+
" to obtain & auto-refresh access tokens. Override with",
|
|
3456
|
+
" --access-token <jwt> or MYSPEC_ACCESS_TOKEN for local testing.",
|
|
3457
|
+
" --version Print version",
|
|
3458
|
+
" --help Print this help",
|
|
3459
|
+
"",
|
|
3460
|
+
"Login flags:",
|
|
3461
|
+
" --user-auth-url <url> user-auth base URL (overrides env / defaults)",
|
|
3462
|
+
" --platform-url <url> platform API base URL",
|
|
3463
|
+
" --webapp-url <url> webapp base URL hosting the /auth/cli page",
|
|
3464
|
+
"",
|
|
3465
|
+
"Environment:",
|
|
3466
|
+
" MYSPEC_USER_AUTH_URL user-auth base URL (default https://auth.myspec.dev)",
|
|
3467
|
+
" MYSPEC_PLATFORM_URL platform API base URL (default https://api.myspec.dev)",
|
|
3468
|
+
" MYSPEC_WEBAPP_URL webapp base URL (default derived from user-auth host)",
|
|
3469
|
+
" MYSPEC_REFRESH_TOKEN Skip file-based credentials; the server mints a",
|
|
3470
|
+
" fresh access token on first use via this refresh",
|
|
3471
|
+
" token."
|
|
3472
|
+
];
|
|
3473
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
3474
|
+
}
|
|
3475
|
+
function readVersion() {
|
|
3476
|
+
try {
|
|
3477
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
3478
|
+
const raw = readFileSync(pkgUrl, "utf8");
|
|
3479
|
+
const pkg = JSON.parse(raw);
|
|
3480
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0) {
|
|
3481
|
+
return pkg.version;
|
|
3482
|
+
}
|
|
3483
|
+
return "unknown";
|
|
3484
|
+
} catch (err) {
|
|
3485
|
+
process.stderr.write(`myspec-mcp: could not read version: ${String(err)}
|
|
3486
|
+
`);
|
|
3487
|
+
return "unknown";
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
async function main(argv = process.argv.slice(2)) {
|
|
3491
|
+
const { command, flags } = parseArgs(argv);
|
|
3492
|
+
switch (command) {
|
|
3493
|
+
case "help":
|
|
3494
|
+
printHelp();
|
|
3495
|
+
return;
|
|
3496
|
+
case "version":
|
|
3497
|
+
process.stderr.write(readVersion() + "\n");
|
|
3498
|
+
return;
|
|
3499
|
+
case "login":
|
|
3500
|
+
await runLogin({
|
|
3501
|
+
paste: flagBool(flags, "paste"),
|
|
3502
|
+
userAuthUrl: flagString(flags, "user-auth-url"),
|
|
3503
|
+
platformUrl: flagString(flags, "platform-url"),
|
|
3504
|
+
webappUrl: flagString(flags, "webapp-url")
|
|
3505
|
+
});
|
|
3506
|
+
return;
|
|
3507
|
+
case "logout":
|
|
3508
|
+
await runLogout();
|
|
3509
|
+
return;
|
|
3510
|
+
case "reverse":
|
|
3511
|
+
await runReverseCommand(flags);
|
|
3512
|
+
return;
|
|
3513
|
+
case "serve":
|
|
3514
|
+
default:
|
|
3515
|
+
await runServe(flags);
|
|
3516
|
+
return;
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
async function runReverseCommand(flags) {
|
|
3520
|
+
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
|
+
const inlineToken = flagString(flags, "access-token") ?? process.env.MYSPEC_ACCESS_TOKEN;
|
|
3523
|
+
let getAccessToken;
|
|
3524
|
+
let onAuthFailed;
|
|
3525
|
+
if (inlineToken) {
|
|
3526
|
+
const staticToken = inlineToken;
|
|
3527
|
+
getAccessToken = () => Promise.resolve(staticToken);
|
|
3528
|
+
onAuthFailed = void 0;
|
|
3529
|
+
} else {
|
|
3530
|
+
const envConfig = readEnvRefreshConfig();
|
|
3531
|
+
const store = createCompositeCredentialsStore({
|
|
3532
|
+
fileStore: createFileCredentialsStore(),
|
|
3533
|
+
envConfig
|
|
3534
|
+
});
|
|
3535
|
+
const tokenManager = new TokenManager({ store, envFallback: envConfig });
|
|
3536
|
+
getAccessToken = () => tokenManager.getValidAccessToken();
|
|
3537
|
+
onAuthFailed = async () => {
|
|
3538
|
+
await tokenManager.forceRefresh();
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
await runReverse({
|
|
3542
|
+
root,
|
|
3543
|
+
agentUrl,
|
|
3544
|
+
getAccessToken,
|
|
3545
|
+
onAuthFailed,
|
|
3546
|
+
version: readVersion()
|
|
3547
|
+
});
|
|
3548
|
+
}
|
|
3549
|
+
async function runServe(flags) {
|
|
3550
|
+
const envConfig = readEnvRefreshConfig();
|
|
3551
|
+
const store = createCompositeCredentialsStore({
|
|
3552
|
+
fileStore: createFileCredentialsStore(),
|
|
3553
|
+
envConfig
|
|
3554
|
+
});
|
|
3555
|
+
const initial = await store.load();
|
|
3556
|
+
if (!initial) {
|
|
3557
|
+
process.stderr.write(
|
|
3558
|
+
"myspec-mcp: starting unauthenticated. Run `npx @myspec/mcp-server login` (or set MYSPEC_REFRESH_TOKEN) to enable tools.\n"
|
|
3559
|
+
);
|
|
3560
|
+
}
|
|
3561
|
+
const config = resolveConfig({
|
|
3562
|
+
cliUserAuthUrl: flagString(flags, "user-auth-url"),
|
|
3563
|
+
cliPlatformUrl: flagString(flags, "platform-url"),
|
|
3564
|
+
storedUserAuthUrl: initial?.userAuthUrl,
|
|
3565
|
+
storedPlatformUrl: initial?.platformUrl
|
|
3566
|
+
});
|
|
3567
|
+
const tokenManager = new TokenManager({
|
|
3568
|
+
store,
|
|
3569
|
+
envFallback: envConfig
|
|
3570
|
+
});
|
|
3571
|
+
const client = new PlatformClient({ baseUrl: config.platformUrl, tokenManager });
|
|
3572
|
+
await startStdioServer({ client, version: readVersion() });
|
|
3573
|
+
}
|
|
3574
|
+
function isCliEntry() {
|
|
3575
|
+
const entry = process.argv[1];
|
|
3576
|
+
if (!entry) {
|
|
3577
|
+
return false;
|
|
3578
|
+
}
|
|
3579
|
+
try {
|
|
3580
|
+
let resolved = entry;
|
|
3581
|
+
try {
|
|
3582
|
+
resolved = realpathSync(entry);
|
|
3583
|
+
} catch {
|
|
3584
|
+
}
|
|
3585
|
+
return import.meta.url === pathToFileURL(resolved).href;
|
|
3586
|
+
} catch {
|
|
3587
|
+
return false;
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
if (isCliEntry()) {
|
|
3591
|
+
main().catch((err) => {
|
|
3592
|
+
if (err instanceof NeedsLoginError || err instanceof ConfigError) {
|
|
3593
|
+
process.stderr.write(err.message + "\n");
|
|
3594
|
+
process.exit(2);
|
|
3595
|
+
}
|
|
3596
|
+
const message = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
3597
|
+
process.stderr.write(message + "\n");
|
|
3598
|
+
process.exit(1);
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3601
|
+
export {
|
|
3602
|
+
main,
|
|
3603
|
+
runServe
|
|
3604
|
+
};
|