premanmcp 1.0.2 → 1.0.3
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/bin/desktop.js +29 -4
- package/bin/integrations.js +102 -44
- package/bin/shared.js +42 -0
- package/package.json +1 -1
- package/dist/auth_flow_ui.d.ts +0 -38
- package/dist/auth_flow_ui.js +0 -170
- package/dist/mcp-preview-panel.d.ts +0 -30
- package/dist/mcp-preview-panel.js +0 -166
- package/dist/user_auth_flow.d.ts +0 -11
- package/dist/user_auth_flow.js +0 -279
package/bin/desktop.js
CHANGED
|
@@ -133,9 +133,16 @@ export function desktopAppRunning(destination = "/Applications") {
|
|
|
133
133
|
* installed. A copy that ignores the request keeps running and the caller says
|
|
134
134
|
* so rather than escalating.
|
|
135
135
|
*/
|
|
136
|
+
// Longer than the app allows its own shutdown, which is fifteen seconds of
|
|
137
|
+
// waiting for the runner's last calls to PreMan. Giving up first is not a
|
|
138
|
+
// neutral act: what follows is a launch, and a launch that lands while the old
|
|
139
|
+
// instance still holds the single-instance lock is delivered to that instance
|
|
140
|
+
// rather than starting a new one.
|
|
141
|
+
const QUIT_TIMEOUT_MS = 20_000;
|
|
142
|
+
|
|
136
143
|
export async function quitDesktopApp({
|
|
137
144
|
destination = "/Applications",
|
|
138
|
-
timeoutMs =
|
|
145
|
+
timeoutMs = QUIT_TIMEOUT_MS,
|
|
139
146
|
sleep = defaultSleep,
|
|
140
147
|
} = {}) {
|
|
141
148
|
if (!desktopAppRunning(destination)) return "not-running";
|
|
@@ -230,6 +237,7 @@ export async function openDesktopSignedIn(
|
|
|
230
237
|
// exercised without a real app on the machine running the tests.
|
|
231
238
|
isRunning = desktopAppRunning,
|
|
232
239
|
quit = quitDesktopApp,
|
|
240
|
+
launch = defaultLaunch,
|
|
233
241
|
} = {}
|
|
234
242
|
) {
|
|
235
243
|
if (!desktopAppInstalled(destination)) {
|
|
@@ -241,9 +249,18 @@ export async function openDesktopSignedIn(
|
|
|
241
249
|
restarted = (await quit({ destination, sleep })) === "quit";
|
|
242
250
|
}
|
|
243
251
|
const handoff = writeDesktopSession(creds);
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
|
|
252
|
+
|
|
253
|
+
// Asked to go and still here. Launching now does not start anything: the old
|
|
254
|
+
// instance holds the single-instance lock until it actually exits, so the
|
|
255
|
+
// launch reaches it as a second-instance and it re-shows the window it was in
|
|
256
|
+
// the middle of dismantling -- which is how onboarding ended on a black
|
|
257
|
+
// rectangle. Nothing here can hurry it, and the session on disk is what the
|
|
258
|
+
// next launch adopts, so saying so beats making it worse.
|
|
259
|
+
if (wasRunning && restartIfRunning && !restarted) {
|
|
260
|
+
return { state: "opened-already-running", handoff: handoff.state };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
launch(destination);
|
|
247
264
|
if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
|
|
248
265
|
|
|
249
266
|
if (wasRunning && !restarted) {
|
|
@@ -266,6 +283,14 @@ export async function openDesktopSignedIn(
|
|
|
266
283
|
|
|
267
284
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
268
285
|
|
|
286
|
+
// The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
|
|
287
|
+
// which may well pick a different copy than the one just installed.
|
|
288
|
+
const defaultLaunch = (destination) =>
|
|
289
|
+
spawn("open", ["-a", installedAppPath(destination)], {
|
|
290
|
+
stdio: "ignore",
|
|
291
|
+
detached: true,
|
|
292
|
+
}).unref();
|
|
293
|
+
|
|
269
294
|
/**
|
|
270
295
|
* Open a Playground session URL in PreMan.app on macOS when it is installed.
|
|
271
296
|
* Windows/Linux (and Mac without the app) get the website URL printed instead.
|
package/bin/integrations.js
CHANGED
|
@@ -18,6 +18,7 @@ import { describeCheckout, explainNoCheckout, resolveCheckout } from "./repo.js"
|
|
|
18
18
|
import { desktopAppInstalled, writeDesktopSession } from "./desktop.js";
|
|
19
19
|
import {
|
|
20
20
|
DEFAULT_BACKEND,
|
|
21
|
+
askWhileWaiting,
|
|
21
22
|
assertOk,
|
|
22
23
|
backendUrl,
|
|
23
24
|
callBackendJson,
|
|
@@ -116,37 +117,58 @@ export class Unrecoverable extends Error {}
|
|
|
116
117
|
* Returns the truthy value from ``check``, or null on timeout. Ordinary
|
|
117
118
|
* exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
|
|
118
119
|
*/
|
|
120
|
+
const NOT_ANSWERED = Symbol("not answered");
|
|
121
|
+
|
|
119
122
|
async function waitFor(
|
|
120
123
|
label,
|
|
121
124
|
check,
|
|
122
|
-
{ hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS } = {}
|
|
125
|
+
{ hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS, escape = null } = {}
|
|
123
126
|
) {
|
|
124
127
|
const startedAt = Date.now();
|
|
125
128
|
const deadline = startedAt + timeoutMs;
|
|
126
129
|
let hinted = false;
|
|
130
|
+
let asked = null;
|
|
127
131
|
process.stdout.write(`Waiting for ${label}`);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
try {
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
try {
|
|
135
|
+
const done = await check();
|
|
136
|
+
if (done) {
|
|
137
|
+
process.stdout.write(asked ? "\n" : " done.\n");
|
|
138
|
+
return done;
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err instanceof Unrecoverable) {
|
|
142
|
+
process.stdout.write("\n");
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
/* keep waiting; the customer is elsewhere */
|
|
134
146
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
// A minute of dots is indistinguishable from a hang, and the reason this
|
|
148
|
+
// waits forever is usually something the customer can act on.
|
|
149
|
+
if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
|
|
150
|
+
hinted = true;
|
|
151
|
+
process.stdout.write(`\n${hint}\n`);
|
|
152
|
+
// Dots and a question cannot share a line. Once there is something to
|
|
153
|
+
// answer, the answering is the whole of what this is doing.
|
|
154
|
+
if (escape) asked = askWhileWaiting(escape.question);
|
|
155
|
+
else process.stdout.write("Still waiting");
|
|
156
|
+
}
|
|
157
|
+
if (!asked) process.stdout.write(".");
|
|
158
|
+
// Polling carries on underneath the question, so an install that lands
|
|
159
|
+
// while it is on the screen still ends this the way it should.
|
|
160
|
+
const answer = await Promise.race([
|
|
161
|
+
new Promise((resolve) => setTimeout(() => resolve(NOT_ANSWERED), POLL_INTERVAL_MS)),
|
|
162
|
+
asked ? asked.reply : new Promise(() => {}),
|
|
163
|
+
]);
|
|
164
|
+
if (answer !== NOT_ANSWERED) {
|
|
165
|
+
escape.answer = typeof answer === "string" ? answer : "";
|
|
166
|
+
return null;
|
|
139
167
|
}
|
|
140
|
-
/* keep waiting; the customer is elsewhere */
|
|
141
|
-
}
|
|
142
|
-
// A minute of dots is indistinguishable from a hang, and the reason this
|
|
143
|
-
// waits forever is usually something the customer can act on.
|
|
144
|
-
if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
|
|
145
|
-
hinted = true;
|
|
146
|
-
process.stdout.write(`\n${hint}\nStill waiting`);
|
|
147
168
|
}
|
|
148
|
-
|
|
149
|
-
|
|
169
|
+
} finally {
|
|
170
|
+
// Whichever side won, the question goes when the wait does.
|
|
171
|
+
asked?.cancel();
|
|
150
172
|
}
|
|
151
173
|
process.stdout.write("\n");
|
|
152
174
|
return null;
|
|
@@ -300,37 +322,64 @@ export async function githubCommand(args, { ensureDesktopApp = null } = {}) {
|
|
|
300
322
|
// a success carrying no repositories means the App is installed and sharing
|
|
301
323
|
// nothing — two dead ends that are indistinguishable from the repository list.
|
|
302
324
|
let refresh = null;
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
+
const lookForRepos = async () => {
|
|
326
|
+
// Installing the App and having repositories appear are two events: the
|
|
327
|
+
// callback records the installation, and a refresh materialises the repos.
|
|
328
|
+
// Polling the repo list alone waits for something that may never arrive on
|
|
329
|
+
// its own.
|
|
330
|
+
refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
331
|
+
token,
|
|
332
|
+
json: {},
|
|
333
|
+
});
|
|
334
|
+
// Compare against what existed before, so a user who already had repos
|
|
335
|
+
// connected is not told they are done the moment polling starts.
|
|
336
|
+
const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
|
|
337
|
+
return fresh.length ? fresh : null;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Waiting is the right default, because the install usually lands inside it.
|
|
341
|
+
// What it cannot be is the only thing on offer. Somebody who picked no
|
|
342
|
+
// repository, or installed onto an organisation where an owner has to approve
|
|
343
|
+
// it first, is watching dots for an event that is not coming, and the only
|
|
344
|
+
// way out of that was to guess that Ctrl-C here was safe.
|
|
345
|
+
const escape = {
|
|
346
|
+
question:
|
|
347
|
+
"Have you saved the repository selection on GitHub? [y/n, or Enter to keep waiting]: ",
|
|
348
|
+
answer: null,
|
|
349
|
+
};
|
|
350
|
+
let done = await waitFor("the installation", lookForRepos, {
|
|
351
|
+
timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
|
|
352
|
+
hintAfterMs: Number(process.env.PREMAN_GITHUB_HINT_MS) || 20000,
|
|
353
|
+
hint: "Still nothing from GitHub — confirm a repository to come back here.",
|
|
354
|
+
escape,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// A yes buys one more look, not one more minute. The callback lands while the
|
|
358
|
+
// question is still on the screen often enough to be worth asking again, and
|
|
359
|
+
// if it has not by now, more dots will not change that.
|
|
360
|
+
if (!done && affirmative(escape.answer)) {
|
|
361
|
+
done = await lookForRepos().catch(() => null);
|
|
362
|
+
}
|
|
325
363
|
|
|
326
364
|
if (!done) {
|
|
327
|
-
process.stdout.write(githubHandOff(args, refresh));
|
|
365
|
+
process.stdout.write(githubHandOff(args, refresh, escape.answer));
|
|
328
366
|
return;
|
|
329
367
|
}
|
|
330
368
|
connected(`GitHub connected: ${done.length} repository(ies).`);
|
|
331
369
|
for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
|
|
332
370
|
}
|
|
333
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Whether a free-text reply means yes.
|
|
374
|
+
*
|
|
375
|
+
* Silence is not consent anywhere else in this walk and it is not here either:
|
|
376
|
+
* only an explicit yes counts, and everything else is left for later.
|
|
377
|
+
*/
|
|
378
|
+
function affirmative(answer) {
|
|
379
|
+
const said = (answer || "").trim().toLowerCase();
|
|
380
|
+
return said === "y" || said === "yes";
|
|
381
|
+
}
|
|
382
|
+
|
|
334
383
|
/**
|
|
335
384
|
* Stop waiting, and name the dead end instead of the timeout.
|
|
336
385
|
*
|
|
@@ -338,7 +387,16 @@ export async function githubCommand(args, { ensureDesktopApp = null } = {}) {
|
|
|
338
387
|
* finishes in the browser whether this process is watching or not, and the two
|
|
339
388
|
* ways it can complete and still leave PreMan with nothing are both actionable.
|
|
340
389
|
*/
|
|
341
|
-
function githubHandOff(args, refresh) {
|
|
390
|
+
function githubHandOff(args, refresh, answer = null) {
|
|
391
|
+
// A no is a decision, not a dead end. Reporting it as one would be telling
|
|
392
|
+
// somebody that something went wrong immediately after they said they had
|
|
393
|
+
// simply not done it yet.
|
|
394
|
+
if (answer !== null && !affirmative(answer)) {
|
|
395
|
+
return (
|
|
396
|
+
`Left for later, and nothing here is broken.\n` +
|
|
397
|
+
` Connect it whenever you like with '${cliInvocation()} github'.\n`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
342
400
|
const installed = Boolean(refresh?.ok) && Number(refresh.installations_refreshed || 0) > 0;
|
|
343
401
|
if (installed) {
|
|
344
402
|
return (
|
package/bin/shared.js
CHANGED
|
@@ -321,6 +321,48 @@ export async function promptText(question) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Ask a question that whatever we are waiting for may answer first.
|
|
326
|
+
*
|
|
327
|
+
* Returns the reply and a `cancel` for when it arrives too late to matter. A
|
|
328
|
+
* plain promptText cannot be raced: abandoning its promise leaves the readline
|
|
329
|
+
* interface open, an open interface holds stdin, and stdin holds the process
|
|
330
|
+
* open with it -- so a wait that ended on its own would sit there afterwards on
|
|
331
|
+
* a question nobody needed answering.
|
|
332
|
+
*
|
|
333
|
+
* Where there is no terminal there is nobody to ask, so the reply is a promise
|
|
334
|
+
* that never settles. A race is then decided entirely by the other side, which
|
|
335
|
+
* is how an unattended run goes on behaving like one.
|
|
336
|
+
*/
|
|
337
|
+
export function askWhileWaiting(question) {
|
|
338
|
+
const nobodyThere =
|
|
339
|
+
!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed;
|
|
340
|
+
if (nobodyThere) return { reply: new Promise(() => {}), cancel: () => {} };
|
|
341
|
+
|
|
342
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
343
|
+
let cancelled = false;
|
|
344
|
+
let settle = null;
|
|
345
|
+
const reply = new Promise((resolve) => {
|
|
346
|
+
settle = (value) => {
|
|
347
|
+
if (settle.done) return;
|
|
348
|
+
settle.done = true;
|
|
349
|
+
resolve(value);
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
rl.question(question).then(
|
|
353
|
+
(value) => settle(value.trim()),
|
|
354
|
+
() => settle("")
|
|
355
|
+
);
|
|
356
|
+
rl.once("close", () => settle(cancelled ? null : ""));
|
|
357
|
+
return {
|
|
358
|
+
reply,
|
|
359
|
+
cancel: () => {
|
|
360
|
+
cancelled = true;
|
|
361
|
+
rl.close();
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
324
366
|
export async function promptSecret(question) {
|
|
325
367
|
if (!process.stdin.isTTY || !process.stdin.setRawMode) {
|
|
326
368
|
return promptText(question);
|
package/package.json
CHANGED
package/dist/auth_flow_ui.d.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth flow endpoints (signup, verify OTP, login, resend OTP) with JSON schemas
|
|
3
|
-
* for share_endpoints_with_ui / agent-sessions push.
|
|
4
|
-
*/
|
|
5
|
-
export type AuthFlowEndpoint = {
|
|
6
|
-
method: string;
|
|
7
|
-
path_template: string;
|
|
8
|
-
description: string;
|
|
9
|
-
tags: string[];
|
|
10
|
-
source_file: string;
|
|
11
|
-
request_body_schema: Record<string, unknown>;
|
|
12
|
-
response_schema: Record<string, unknown>;
|
|
13
|
-
mcp_tool?: string;
|
|
14
|
-
};
|
|
15
|
-
/** Core auth endpoints aligned with routes/auth/routes.py and user_auth_* MCP tools. */
|
|
16
|
-
export declare function buildAuthFlowEndpoints(): AuthFlowEndpoint[];
|
|
17
|
-
export type ShareAuthFlowResult = {
|
|
18
|
-
session_id: string;
|
|
19
|
-
url: string;
|
|
20
|
-
endpoint_count: number;
|
|
21
|
-
user_id: number | null;
|
|
22
|
-
auto_discoverable: boolean;
|
|
23
|
-
upstream_base_url: string;
|
|
24
|
-
endpoints: AuthFlowEndpoint[];
|
|
25
|
-
ui: {
|
|
26
|
-
url: string;
|
|
27
|
-
note: string;
|
|
28
|
-
};
|
|
29
|
-
related_tools: string[];
|
|
30
|
-
};
|
|
31
|
-
export declare function shareAuthFlowToUi(opts: {
|
|
32
|
-
backendUrl: string;
|
|
33
|
-
frontendUrl: string;
|
|
34
|
-
upstreamBaseUrl?: string;
|
|
35
|
-
sessionId?: string;
|
|
36
|
-
intent?: string;
|
|
37
|
-
apiKey?: string;
|
|
38
|
-
}): Promise<ShareAuthFlowResult>;
|
package/dist/auth_flow_ui.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth flow endpoints (signup, verify OTP, login, resend OTP) with JSON schemas
|
|
3
|
-
* for share_endpoints_with_ui / agent-sessions push.
|
|
4
|
-
*/
|
|
5
|
-
const EMAIL_PROP = { type: "string", format: "email", description: "User email" };
|
|
6
|
-
const PASSWORD_PROP = {
|
|
7
|
-
type: "string",
|
|
8
|
-
minLength: 6,
|
|
9
|
-
description: "Password (min 6 characters on the server)",
|
|
10
|
-
};
|
|
11
|
-
const OTP_PROP = { type: "string", description: "6-digit code from email" };
|
|
12
|
-
const TOKEN_RESPONSE = {
|
|
13
|
-
type: "object",
|
|
14
|
-
properties: {
|
|
15
|
-
access_token: { type: "string", description: "JWT bearer token" },
|
|
16
|
-
token_type: { type: "string", enum: ["bearer"] },
|
|
17
|
-
user: {
|
|
18
|
-
type: "object",
|
|
19
|
-
properties: {
|
|
20
|
-
id: { type: "string" },
|
|
21
|
-
email: { type: "string", format: "email" },
|
|
22
|
-
},
|
|
23
|
-
required: ["id", "email"],
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
required: ["access_token", "token_type", "user"],
|
|
27
|
-
};
|
|
28
|
-
const OTP_SENT_RESPONSE = {
|
|
29
|
-
type: "object",
|
|
30
|
-
properties: {
|
|
31
|
-
message: { type: "string" },
|
|
32
|
-
email_sent: { type: "boolean" },
|
|
33
|
-
},
|
|
34
|
-
required: ["message", "email_sent"],
|
|
35
|
-
};
|
|
36
|
-
/** Core auth endpoints aligned with routes/auth/routes.py and user_auth_* MCP tools. */
|
|
37
|
-
export function buildAuthFlowEndpoints() {
|
|
38
|
-
return [
|
|
39
|
-
{
|
|
40
|
-
method: "POST",
|
|
41
|
-
path_template: "/auth/signup",
|
|
42
|
-
description: "Register with email and password. Sends OTP to email; next: verify-otp.",
|
|
43
|
-
tags: ["auth", "signup"],
|
|
44
|
-
source_file: "routes/auth/routes.py",
|
|
45
|
-
mcp_tool: "user_auth_signup",
|
|
46
|
-
request_body_schema: {
|
|
47
|
-
type: "object",
|
|
48
|
-
properties: { email: EMAIL_PROP, password: PASSWORD_PROP },
|
|
49
|
-
required: ["email", "password"],
|
|
50
|
-
additionalProperties: false,
|
|
51
|
-
},
|
|
52
|
-
response_schema: {
|
|
53
|
-
type: "object",
|
|
54
|
-
properties: {
|
|
55
|
-
message: { type: "string" },
|
|
56
|
-
user_id: { type: "string" },
|
|
57
|
-
email: { type: "string", format: "email" },
|
|
58
|
-
email_sent: { type: "boolean" },
|
|
59
|
-
},
|
|
60
|
-
required: ["message", "user_id", "email", "email_sent"],
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
method: "POST",
|
|
65
|
-
path_template: "/auth/verify-otp",
|
|
66
|
-
description: "Verify email OTP after signup; returns JWT access_token.",
|
|
67
|
-
tags: ["auth", "otp"],
|
|
68
|
-
source_file: "routes/auth/routes.py",
|
|
69
|
-
mcp_tool: "user_auth_verify_otp",
|
|
70
|
-
request_body_schema: {
|
|
71
|
-
type: "object",
|
|
72
|
-
properties: { email: EMAIL_PROP, otp: OTP_PROP },
|
|
73
|
-
required: ["email", "otp"],
|
|
74
|
-
additionalProperties: false,
|
|
75
|
-
},
|
|
76
|
-
response_schema: TOKEN_RESPONSE,
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
method: "POST",
|
|
80
|
-
path_template: "/auth/login",
|
|
81
|
-
description: "Login with email and password. Returns access_token if email is verified.",
|
|
82
|
-
tags: ["auth", "login"],
|
|
83
|
-
source_file: "routes/auth/routes.py",
|
|
84
|
-
mcp_tool: "user_auth_login",
|
|
85
|
-
request_body_schema: {
|
|
86
|
-
type: "object",
|
|
87
|
-
properties: { email: EMAIL_PROP, password: PASSWORD_PROP },
|
|
88
|
-
required: ["email", "password"],
|
|
89
|
-
additionalProperties: false,
|
|
90
|
-
},
|
|
91
|
-
response_schema: TOKEN_RESPONSE,
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
method: "POST",
|
|
95
|
-
path_template: "/auth/resend-otp",
|
|
96
|
-
description: "Send (resend) verification OTP to email.",
|
|
97
|
-
tags: ["auth", "otp"],
|
|
98
|
-
source_file: "routes/auth/routes.py",
|
|
99
|
-
mcp_tool: "user_auth_resend_otp",
|
|
100
|
-
request_body_schema: {
|
|
101
|
-
type: "object",
|
|
102
|
-
properties: { email: EMAIL_PROP },
|
|
103
|
-
required: ["email"],
|
|
104
|
-
additionalProperties: false,
|
|
105
|
-
},
|
|
106
|
-
response_schema: OTP_SENT_RESPONSE,
|
|
107
|
-
},
|
|
108
|
-
];
|
|
109
|
-
}
|
|
110
|
-
export async function shareAuthFlowToUi(opts) {
|
|
111
|
-
const backend = opts.backendUrl.replace(/\/+$/, "");
|
|
112
|
-
const frontend = opts.frontendUrl.replace(/\/+$/, "");
|
|
113
|
-
const apiKey = opts.apiKey?.trim();
|
|
114
|
-
if (!apiKey) {
|
|
115
|
-
throw new Error("PreMan authentication required. Run preman_login first so auth-flow sessions can stream to your dashboard.");
|
|
116
|
-
}
|
|
117
|
-
const upstream = (opts.upstreamBaseUrl || backend).replace(/\/+$/, "") || backend;
|
|
118
|
-
const sessionId = opts.sessionId?.trim() || crypto.randomUUID();
|
|
119
|
-
const endpoints = buildAuthFlowEndpoints().map((ep) => ({
|
|
120
|
-
...ep,
|
|
121
|
-
base_url: upstream,
|
|
122
|
-
}));
|
|
123
|
-
const resp = await fetch(`${backend}/agent-sessions/${encodeURIComponent(sessionId)}/endpoints`, {
|
|
124
|
-
method: "POST",
|
|
125
|
-
headers: {
|
|
126
|
-
"Content-Type": "application/json",
|
|
127
|
-
Accept: "application/json",
|
|
128
|
-
Authorization: `Bearer ${apiKey}`,
|
|
129
|
-
},
|
|
130
|
-
body: JSON.stringify({
|
|
131
|
-
endpoints,
|
|
132
|
-
upstream_base_url: upstream,
|
|
133
|
-
intent: opts.intent || "Auth flow: signup, verify OTP, login, resend OTP",
|
|
134
|
-
client_label: "premanmcp",
|
|
135
|
-
}),
|
|
136
|
-
});
|
|
137
|
-
const text = await resp.text();
|
|
138
|
-
let body = {};
|
|
139
|
-
try {
|
|
140
|
-
body = text ? JSON.parse(text) : {};
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
throw new Error(`Agent session push failed: ${resp.status} ${text.slice(0, 500)}`);
|
|
144
|
-
}
|
|
145
|
-
if (!resp.ok) {
|
|
146
|
-
throw new Error(String(body.detail ?? body.error ?? `Agent session push failed: ${resp.status}`));
|
|
147
|
-
}
|
|
148
|
-
const sid = String(body.id ?? sessionId);
|
|
149
|
-
const url = `${frontend}/try?session=${encodeURIComponent(sid)}`;
|
|
150
|
-
return {
|
|
151
|
-
session_id: sid,
|
|
152
|
-
url,
|
|
153
|
-
endpoint_count: Number(body.endpoint_count ?? endpoints.length),
|
|
154
|
-
user_id: typeof body.user_id === "number" ? body.user_id : null,
|
|
155
|
-
auto_discoverable: Boolean(body.auto_discoverable),
|
|
156
|
-
upstream_base_url: upstream,
|
|
157
|
-
endpoints: buildAuthFlowEndpoints(),
|
|
158
|
-
ui: {
|
|
159
|
-
url,
|
|
160
|
-
note: "Open in Cursor Agent Browser or the Playground session list. Test signup → verify-otp → login, or resend-otp. " +
|
|
161
|
-
"Schemas are prefilled from routes/auth Pydantic models.",
|
|
162
|
-
},
|
|
163
|
-
related_tools: [
|
|
164
|
-
"user_auth_signup",
|
|
165
|
-
"user_auth_verify_otp",
|
|
166
|
-
"user_auth_login",
|
|
167
|
-
"user_auth_resend_otp",
|
|
168
|
-
],
|
|
169
|
-
};
|
|
170
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export declare const MCP_PREVIEW_RESOURCE_URI = "ui://preman/mcp-preview";
|
|
2
|
-
export declare const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
3
|
-
export declare function escapeHtmlAttr(s: string): string;
|
|
4
|
-
export declare function escapeHtmlText(s: string): string;
|
|
5
|
-
export interface PreviewTool {
|
|
6
|
-
name?: string;
|
|
7
|
-
description?: string;
|
|
8
|
-
inputSchema?: unknown;
|
|
9
|
-
_endpoint_ref?: {
|
|
10
|
-
method?: string;
|
|
11
|
-
path_template?: string;
|
|
12
|
-
tags?: string[];
|
|
13
|
-
source?: string;
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
export interface PreviewPayload {
|
|
17
|
-
intent?: string | string[];
|
|
18
|
-
selection_method?: string | string[];
|
|
19
|
-
rationale?: string | string[] | Record<string, string>;
|
|
20
|
-
selected_count?: number;
|
|
21
|
-
spec_preview?: {
|
|
22
|
-
upstream_base_url?: string | string[];
|
|
23
|
-
tools?: PreviewTool[];
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
export declare function buildConversionPanelHtml(data: PreviewPayload): string;
|
|
27
|
-
export declare function writeMcpPreviewFile(panelHtml: string): Promise<{
|
|
28
|
-
absolutePath: string;
|
|
29
|
-
fileUrl: string;
|
|
30
|
-
}>;
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Two-pane HTML for mcp_preview — shared by the MCP stdio server and
|
|
3
|
-
* scripts/emit-mcp-preview.mjs (browser tab fallback when Cursor does not render mcp-app).
|
|
4
|
-
*/
|
|
5
|
-
import fs from "node:fs/promises";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { pathToFileURL } from "node:url";
|
|
8
|
-
export const MCP_PREVIEW_RESOURCE_URI = "ui://preman/mcp-preview";
|
|
9
|
-
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
10
|
-
export function escapeHtmlAttr(s) {
|
|
11
|
-
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
12
|
-
}
|
|
13
|
-
export function escapeHtmlText(s) {
|
|
14
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
15
|
-
}
|
|
16
|
-
export function buildConversionPanelHtml(data) {
|
|
17
|
-
const toStr = (v) => {
|
|
18
|
-
if (v == null)
|
|
19
|
-
return "";
|
|
20
|
-
if (Array.isArray(v))
|
|
21
|
-
return v.filter((x) => x != null).map((x) => String(x)).join("; ");
|
|
22
|
-
if (typeof v === "object") {
|
|
23
|
-
try {
|
|
24
|
-
return Object.entries(v)
|
|
25
|
-
.map(([k, val]) => `${k}: ${val == null ? "" : String(val)}`)
|
|
26
|
-
.join(" · ");
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
try {
|
|
30
|
-
return JSON.stringify(v);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return String(v);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return String(v);
|
|
38
|
-
};
|
|
39
|
-
const intent = toStr(data.intent).trim() || "(unspecified)";
|
|
40
|
-
const method = toStr(data.selection_method).trim() || "unknown";
|
|
41
|
-
const rationale = toStr(data.rationale).trim();
|
|
42
|
-
const upstream = toStr(data.spec_preview?.upstream_base_url).trim();
|
|
43
|
-
const tools = Array.isArray(data.spec_preview?.tools) ? data.spec_preview.tools : [];
|
|
44
|
-
const selectedCount = typeof data.selected_count === "number" ? data.selected_count : tools.length;
|
|
45
|
-
const linkKey = (m, p) => `${(m || "").toUpperCase()} ${p || ""}`.trim();
|
|
46
|
-
const endpointRows = tools
|
|
47
|
-
.map((t) => {
|
|
48
|
-
const ref = t._endpoint_ref ?? {};
|
|
49
|
-
const m = (ref.method ?? "").toUpperCase();
|
|
50
|
-
const p = ref.path_template ?? "";
|
|
51
|
-
const key = linkKey(m, p);
|
|
52
|
-
const tags = Array.isArray(ref.tags) && ref.tags.length > 0 ? ref.tags.join(", ") : "";
|
|
53
|
-
return `
|
|
54
|
-
<div class="row" data-link="${escapeHtmlAttr(key)}">
|
|
55
|
-
<div class="row-head">
|
|
56
|
-
<span class="method ${escapeHtmlAttr(m)}">${escapeHtmlText(m)}</span>
|
|
57
|
-
<span class="path">${escapeHtmlText(p)}</span>
|
|
58
|
-
</div>
|
|
59
|
-
${tags ? `<div class="row-meta">tags: ${escapeHtmlText(tags)}</div>` : ""}
|
|
60
|
-
</div>`;
|
|
61
|
-
})
|
|
62
|
-
.join("");
|
|
63
|
-
const toolRows = tools
|
|
64
|
-
.map((t) => {
|
|
65
|
-
const ref = t._endpoint_ref ?? {};
|
|
66
|
-
const key = linkKey(ref.method, ref.path_template);
|
|
67
|
-
const schemaJson = (() => {
|
|
68
|
-
try {
|
|
69
|
-
return JSON.stringify(t.inputSchema ?? {}, null, 2);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return "{}";
|
|
73
|
-
}
|
|
74
|
-
})();
|
|
75
|
-
return `
|
|
76
|
-
<div class="row" data-link="${escapeHtmlAttr(key)}">
|
|
77
|
-
<div class="tool-name">${escapeHtmlText(t.name ?? "(unnamed)")}</div>
|
|
78
|
-
${t.description ? `<div class="tool-desc">${escapeHtmlText(t.description)}</div>` : ""}
|
|
79
|
-
<pre class="tool-schema">${escapeHtmlText(schemaJson)}</pre>
|
|
80
|
-
</div>`;
|
|
81
|
-
})
|
|
82
|
-
.join("");
|
|
83
|
-
const empty = tools.length === 0
|
|
84
|
-
? `<div class="empty">No matching endpoints. Try a different intent or run <code>verify_endpoints_live</code> first.</div>`
|
|
85
|
-
: "";
|
|
86
|
-
return `<!DOCTYPE html>
|
|
87
|
-
<html lang="en">
|
|
88
|
-
<head>
|
|
89
|
-
<meta charset="UTF-8" />
|
|
90
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
91
|
-
<title>PreMan · MCP Preview</title>
|
|
92
|
-
<style>
|
|
93
|
-
:root { color-scheme: light dark; --bg:#0d1117; --bg2:#161b22; --border:#30363d; --fg:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --green:#2ea043; --orange:#bf8700; --red:#cf222e; --purple:#8957e5; }
|
|
94
|
-
* { margin:0; padding:0; box-sizing:border-box; }
|
|
95
|
-
html, body { height:100%; background:var(--bg); color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,sans-serif; font-size:13px; }
|
|
96
|
-
body { display:flex; flex-direction:column; overflow:hidden; }
|
|
97
|
-
header { padding:10px 14px; border-bottom:1px solid var(--border); background:var(--bg2); flex-shrink:0; }
|
|
98
|
-
header h1 { font-size:13px; font-weight:600; color:var(--accent); }
|
|
99
|
-
header .meta { font-size:11px; color:var(--muted); margin-top:3px; }
|
|
100
|
-
header .meta strong { color:var(--fg); font-weight:600; }
|
|
101
|
-
header .rationale { font-size:11px; color:var(--muted); margin-top:5px; font-style:italic; max-width:900px; }
|
|
102
|
-
.columns { flex:1; display:grid; grid-template-columns:1fr 1fr; gap:1px; background:var(--border); overflow:hidden; min-height:0; }
|
|
103
|
-
.col { background:var(--bg); overflow-y:auto; }
|
|
104
|
-
.col-header { padding:7px 12px; font-size:10px; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:var(--muted); border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg2); z-index:1; }
|
|
105
|
-
.row { padding:9px 12px; border-bottom:1px solid var(--border); transition:background 0.1s; }
|
|
106
|
-
.row:last-child { border-bottom:none; }
|
|
107
|
-
.row:hover, .row.linked { background:rgba(88,166,255,0.08); }
|
|
108
|
-
.row-head { display:flex; align-items:center; gap:8px; }
|
|
109
|
-
.row-meta { font-size:10px; color:var(--muted); margin-top:3px; padding-left:56px; }
|
|
110
|
-
.method { display:inline-block; min-width:48px; text-align:center; padding:2px 6px; border-radius:3px; font-weight:700; font-size:10px; color:white; font-family:SFMono-Regular,Consolas,monospace; }
|
|
111
|
-
.method.GET{background:var(--accent);} .method.POST{background:var(--green);} .method.PATCH{background:var(--orange);} .method.PUT{background:var(--purple);} .method.DELETE{background:var(--red);}
|
|
112
|
-
.path { font-family:SFMono-Regular,Consolas,monospace; font-size:12px; word-break:break-all; }
|
|
113
|
-
.tool-name { font-family:SFMono-Regular,Consolas,monospace; font-size:12px; font-weight:600; color:var(--accent); }
|
|
114
|
-
.tool-desc { font-size:11px; color:var(--muted); margin-top:3px; line-height:1.4; }
|
|
115
|
-
.tool-schema { font-family:SFMono-Regular,Consolas,monospace; font-size:10px; background:var(--bg2); padding:6px 8px; border-radius:3px; margin-top:6px; white-space:pre-wrap; word-break:break-all; max-height:120px; overflow-y:auto; line-height:1.4; color:var(--muted); }
|
|
116
|
-
.empty { padding:24px; text-align:center; color:var(--muted); font-style:italic; }
|
|
117
|
-
.empty code { font-family:SFMono-Regular,Consolas,monospace; color:var(--accent); background:var(--bg2); padding:1px 5px; border-radius:3px; font-style:normal; }
|
|
118
|
-
footer { padding:8px 14px; border-top:1px solid var(--border); background:var(--bg2); display:flex; gap:10px; align-items:center; flex-shrink:0; font-size:11px; color:var(--muted); }
|
|
119
|
-
footer .upstream { font-family:SFMono-Regular,Consolas,monospace; color:var(--accent); }
|
|
120
|
-
footer .deploy-hint { margin-left:auto; }
|
|
121
|
-
footer .deploy-hint code { font-family:SFMono-Regular,Consolas,monospace; color:var(--fg); background:var(--bg); padding:2px 6px; border-radius:3px; }
|
|
122
|
-
</style>
|
|
123
|
-
</head>
|
|
124
|
-
<body>
|
|
125
|
-
<header>
|
|
126
|
-
<h1>API → MCP Conversion preview</h1>
|
|
127
|
-
<div class="meta">
|
|
128
|
-
Intent: <strong>${escapeHtmlText(intent)}</strong> · Selected <strong>${selectedCount}</strong> endpoint${selectedCount === 1 ? "" : "s"} · Method: ${escapeHtmlText(method)}
|
|
129
|
-
</div>
|
|
130
|
-
${rationale ? `<div class="rationale">${escapeHtmlText(rationale)}</div>` : ""}
|
|
131
|
-
</header>
|
|
132
|
-
<main class="columns">
|
|
133
|
-
<div class="col">
|
|
134
|
-
<div class="col-header">Your API endpoints (${tools.length})</div>
|
|
135
|
-
${endpointRows}${tools.length === 0 ? empty : ""}
|
|
136
|
-
</div>
|
|
137
|
-
<div class="col">
|
|
138
|
-
<div class="col-header">Generated MCP tools (${tools.length})</div>
|
|
139
|
-
${toolRows}${tools.length === 0 ? empty : ""}
|
|
140
|
-
</div>
|
|
141
|
-
</main>
|
|
142
|
-
<footer>
|
|
143
|
-
<span>Upstream: <span class="upstream">${escapeHtmlText(upstream || "(not set)")}</span></span>
|
|
144
|
-
<span class="deploy-hint">Next: ask the agent to call <code>mcp_deploy</code></span>
|
|
145
|
-
</footer>
|
|
146
|
-
<script>
|
|
147
|
-
document.querySelectorAll('.row[data-link]').forEach(row => {
|
|
148
|
-
const key = row.getAttribute('data-link');
|
|
149
|
-
if (!key) return;
|
|
150
|
-
const matches = () => document.querySelectorAll('[data-link="' + CSS.escape(key) + '"]');
|
|
151
|
-
row.addEventListener('mouseenter', () => matches().forEach(r => r.classList.add('linked')));
|
|
152
|
-
row.addEventListener('mouseleave', () => matches().forEach(r => r.classList.remove('linked')));
|
|
153
|
-
});
|
|
154
|
-
</script>
|
|
155
|
-
</body>
|
|
156
|
-
</html>`;
|
|
157
|
-
}
|
|
158
|
-
export async function writeMcpPreviewFile(panelHtml) {
|
|
159
|
-
const outPath = path.join(process.cwd(), "preman-mcp", "mcp-preview-last.html");
|
|
160
|
-
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
161
|
-
await fs.writeFile(outPath, panelHtml, "utf8");
|
|
162
|
-
return {
|
|
163
|
-
absolutePath: outPath,
|
|
164
|
-
fileUrl: pathToFileURL(outPath).href,
|
|
165
|
-
};
|
|
166
|
-
}
|
package/dist/user_auth_flow.d.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* App user auth (JWT) — tools that call the FastAPI backend at PREMAN_BACKEND /auth/*.
|
|
3
|
-
* Separate from preman_login (device flow + pm_live_ API key). No API key is required
|
|
4
|
-
* for these tools; for JWT-protected routes, pass the access_token from login/verify.
|
|
5
|
-
*/
|
|
6
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
-
/**
|
|
8
|
-
* Register MCP tools for routes/auth (email, OTP, password, JWT).
|
|
9
|
-
* Proxies to the same process as preman-local (PREMAN_BACKEND, e.g. http://127.0.0.1:8000).
|
|
10
|
-
*/
|
|
11
|
-
export declare function registerUserAuthFlowTools(server: McpServer, backendUrl: string): void;
|
package/dist/user_auth_flow.js
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
function toolError(message, code = "backend_error", hints) {
|
|
3
|
-
const payload = { error: message, error_code: code };
|
|
4
|
-
if (hints)
|
|
5
|
-
payload._agent_hints = hints;
|
|
6
|
-
return {
|
|
7
|
-
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
8
|
-
isError: true,
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
function jsonOk(obj) {
|
|
12
|
-
return { content: [{ type: "text", text: JSON.stringify(obj) }] };
|
|
13
|
-
}
|
|
14
|
-
function normalizeBase(backendUrl) {
|
|
15
|
-
return backendUrl.replace(/\/+$/, "");
|
|
16
|
-
}
|
|
17
|
-
async function callAuthJson(base, method, path, opts) {
|
|
18
|
-
const url = new URL(path.startsWith("/") ? path.slice(1) : path, `${base}/`);
|
|
19
|
-
if (opts?.query) {
|
|
20
|
-
for (const [k, v] of Object.entries(opts.query)) {
|
|
21
|
-
if (v != null && v !== "")
|
|
22
|
-
url.searchParams.set(k, v);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const headers = { Accept: "application/json" };
|
|
26
|
-
const hasBody = opts?.json != null && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE");
|
|
27
|
-
if (hasBody) {
|
|
28
|
-
headers["Content-Type"] = "application/json";
|
|
29
|
-
}
|
|
30
|
-
if (opts?.token) {
|
|
31
|
-
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
32
|
-
}
|
|
33
|
-
const init = { method, headers };
|
|
34
|
-
if (hasBody && opts?.json) {
|
|
35
|
-
init.body = JSON.stringify(opts.json);
|
|
36
|
-
}
|
|
37
|
-
const resp = await fetch(url, init);
|
|
38
|
-
const text = await resp.text();
|
|
39
|
-
let parsed;
|
|
40
|
-
try {
|
|
41
|
-
parsed = text ? JSON.parse(text) : {};
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
parsed = { raw: text };
|
|
45
|
-
}
|
|
46
|
-
const body = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
47
|
-
? parsed
|
|
48
|
-
: { value: parsed };
|
|
49
|
-
return {
|
|
50
|
-
status_code: resp.status,
|
|
51
|
-
ok: resp.ok,
|
|
52
|
-
...body,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Register MCP tools for routes/auth (email, OTP, password, JWT).
|
|
57
|
-
* Proxies to the same process as preman-local (PREMAN_BACKEND, e.g. http://127.0.0.1:8000).
|
|
58
|
-
*/
|
|
59
|
-
export function registerUserAuthFlowTools(server, backendUrl) {
|
|
60
|
-
const base = normalizeBase(backendUrl);
|
|
61
|
-
server.tool("user_auth_start_signup", "Start signup with email only. Sends an OTP; next call user_auth_set_password with email, OTP, and new password. Uses POST /auth/start-signup on PREMAN_BACKEND (no API key). If the backend does not support this endpoint yet, use user_auth_signup instead.", {
|
|
62
|
-
email: z.string().describe("User email"),
|
|
63
|
-
}, async (args) => {
|
|
64
|
-
try {
|
|
65
|
-
const r = await callAuthJson(base, "POST", "/auth/start-signup", {
|
|
66
|
-
json: { email: args.email },
|
|
67
|
-
});
|
|
68
|
-
if (!r.ok) {
|
|
69
|
-
if (r.status_code === 404) {
|
|
70
|
-
return toolError("This backend does not support email-only signup yet. Use user_auth_signup with email and password, then user_auth_verify_otp.", "backend_error", {
|
|
71
|
-
next_actions: ["Call user_auth_signup with email and password.", "Then call user_auth_verify_otp with the email code."],
|
|
72
|
-
related_tools: ["user_auth_signup", "user_auth_verify_otp"],
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
return toolError(String(r.detail ?? r.message ?? "start signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
|
|
76
|
-
next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
|
|
77
|
-
related_tools: ["user_auth_set_password", "user_auth_login"],
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
return jsonOk(r);
|
|
81
|
-
}
|
|
82
|
-
catch (e) {
|
|
83
|
-
const m = e instanceof Error ? e.message : String(e);
|
|
84
|
-
return toolError(m, "backend_error", {
|
|
85
|
-
next_actions: ["Ensure PREMAN_BACKEND is running and JWT_SECRET is set on the server."],
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
server.tool("user_auth_signup", "Register with email and password. Sends an OTP; next call user_auth_verify_otp. Uses POST /auth/signup on PREMAN_BACKEND (no API key).", {
|
|
90
|
-
email: z.string().describe("User email"),
|
|
91
|
-
password: z.string().describe("Password (min 6 characters on the server)"),
|
|
92
|
-
}, async (args) => {
|
|
93
|
-
try {
|
|
94
|
-
const r = await callAuthJson(base, "POST", "/auth/signup", {
|
|
95
|
-
json: { email: args.email, password: args.password },
|
|
96
|
-
});
|
|
97
|
-
if (!r.ok) {
|
|
98
|
-
return toolError(String(r.detail ?? r.message ?? "signup failed"), r.status_code === 400 ? "invalid_input" : "backend_error", {
|
|
99
|
-
next_actions: ["If email exists, use user_auth_login or user_auth_resend_otp."],
|
|
100
|
-
related_tools: ["user_auth_verify_otp", "user_auth_login"],
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return jsonOk(r);
|
|
104
|
-
}
|
|
105
|
-
catch (e) {
|
|
106
|
-
const m = e instanceof Error ? e.message : String(e);
|
|
107
|
-
return toolError(m, "backend_error", {
|
|
108
|
-
next_actions: ["Ensure PREMAN_BACKEND is running and JWT_SECRET is set on the server."],
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
server.tool("user_auth_verify_otp", "Verify the email OTP and receive access_token (JWT). POST /auth/verify-otp.", { email: z.string(), otp: z.string().describe("6-digit code from email") }, async (args) => {
|
|
113
|
-
try {
|
|
114
|
-
const r = await callAuthJson(base, "POST", "/auth/verify-otp", {
|
|
115
|
-
json: { email: args.email, otp: args.otp },
|
|
116
|
-
});
|
|
117
|
-
if (!r.ok) {
|
|
118
|
-
return toolError(String(r.detail ?? "verify failed"), "auth_required", {
|
|
119
|
-
next_actions: ["Request a new code with user_auth_resend_otp if expired."],
|
|
120
|
-
related_tools: ["user_auth_resend_otp", "user_auth_signup"],
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
return jsonOk(r);
|
|
124
|
-
}
|
|
125
|
-
catch (e) {
|
|
126
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
server.tool("user_auth_login", "Login with email and password. Returns access_token if email is verified. POST /auth/login.", { email: z.string(), password: z.string() }, async (args) => {
|
|
130
|
-
try {
|
|
131
|
-
const r = await callAuthJson(base, "POST", "/auth/login", {
|
|
132
|
-
json: { email: args.email, password: args.password },
|
|
133
|
-
});
|
|
134
|
-
if (!r.ok) {
|
|
135
|
-
const sc = r.status_code;
|
|
136
|
-
const code = sc === 403 || sc === 401 ? "auth_required" : "backend_error";
|
|
137
|
-
return toolError(String(r.detail ?? "login failed"), code, {
|
|
138
|
-
next_actions: [
|
|
139
|
-
"If 403 email not verified: use user_auth_verify_otp or user_auth_resend_otp.",
|
|
140
|
-
"If 403 migrated account: use user_auth_forgot_password or user_auth_set_password flow.",
|
|
141
|
-
],
|
|
142
|
-
related_tools: ["user_auth_verify_otp", "user_auth_needs_password", "user_auth_set_password"],
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
return jsonOk(r);
|
|
146
|
-
}
|
|
147
|
-
catch (e) {
|
|
148
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
server.tool("user_auth_needs_password", "Check if an account must set a password (e.g. migrated user). GET /auth/needs-password?email=", { email: z.string().optional().describe("Email to check; omit to return false/false from server") }, async (args) => {
|
|
152
|
-
try {
|
|
153
|
-
const r = await callAuthJson(base, "GET", "/auth/needs-password", {
|
|
154
|
-
query: { email: args.email },
|
|
155
|
-
});
|
|
156
|
-
if (!r.ok) {
|
|
157
|
-
return toolError(String(r.detail ?? "request failed"), "backend_error");
|
|
158
|
-
}
|
|
159
|
-
return jsonOk(r);
|
|
160
|
-
}
|
|
161
|
-
catch (e) {
|
|
162
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
server.tool("user_auth_resend_otp", "Resend verification OTP. POST /auth/resend-otp with { email }.", { email: z.string() }, async (args) => {
|
|
166
|
-
try {
|
|
167
|
-
const r = await callAuthJson(base, "POST", "/auth/resend-otp", {
|
|
168
|
-
json: { email: args.email },
|
|
169
|
-
});
|
|
170
|
-
if (!r.ok) {
|
|
171
|
-
return toolError(String(r.detail ?? "resend failed"), "invalid_input");
|
|
172
|
-
}
|
|
173
|
-
return jsonOk(r);
|
|
174
|
-
}
|
|
175
|
-
catch (e) {
|
|
176
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
server.tool("user_auth_forgot_password", "Request password reset OTP. POST /auth/forgot-password with { email }.", { email: z.string() }, async (args) => {
|
|
180
|
-
try {
|
|
181
|
-
const r = await callAuthJson(base, "POST", "/auth/forgot-password", {
|
|
182
|
-
json: { email: args.email },
|
|
183
|
-
});
|
|
184
|
-
if (!r.ok) {
|
|
185
|
-
return toolError(String(r.detail ?? "forgot failed"), "backend_error");
|
|
186
|
-
}
|
|
187
|
-
return jsonOk(r);
|
|
188
|
-
}
|
|
189
|
-
catch (e) {
|
|
190
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
server.tool("user_auth_set_password", "Set a new password using OTP (migrated / forgot flow). Returns access_token. POST /auth/set-password.", {
|
|
194
|
-
email: z.string(),
|
|
195
|
-
otp: z.string(),
|
|
196
|
-
new_password: z.string().min(6),
|
|
197
|
-
}, async (args) => {
|
|
198
|
-
try {
|
|
199
|
-
const r = await callAuthJson(base, "POST", "/auth/set-password", {
|
|
200
|
-
json: {
|
|
201
|
-
email: args.email,
|
|
202
|
-
otp: args.otp,
|
|
203
|
-
new_password: args.new_password,
|
|
204
|
-
},
|
|
205
|
-
});
|
|
206
|
-
if (!r.ok) {
|
|
207
|
-
return toolError(String(r.detail ?? "set password failed"), "auth_required", {
|
|
208
|
-
next_actions: ["Request a new OTP with user_auth_forgot_password or user_auth_resend_otp."],
|
|
209
|
-
related_tools: ["user_auth_forgot_password"],
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
return jsonOk(r);
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
server.tool("user_auth_me", "Current user profile (JWT). GET /auth/me with Authorization: Bearer access_token from login/verify.", {
|
|
219
|
-
access_token: z.string().describe("JWT from user_auth_login or user_auth_verify_otp"),
|
|
220
|
-
}, async (args) => {
|
|
221
|
-
try {
|
|
222
|
-
const r = await callAuthJson(base, "GET", "/auth/me", {
|
|
223
|
-
token: args.access_token,
|
|
224
|
-
});
|
|
225
|
-
if (!r.ok) {
|
|
226
|
-
return toolError(String(r.detail ?? "unauthorized"), "auth_required", {
|
|
227
|
-
next_actions: ["Call user_auth_login to obtain a fresh access_token."],
|
|
228
|
-
related_tools: ["user_auth_login"],
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
return jsonOk(r);
|
|
232
|
-
}
|
|
233
|
-
catch (e) {
|
|
234
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
server.tool("user_auth_change_password", "Change password for the signed-in user. POST /auth/change-password with JWT.", {
|
|
238
|
-
access_token: z.string(),
|
|
239
|
-
current_password: z.string(),
|
|
240
|
-
new_password: z.string().min(6),
|
|
241
|
-
}, async (args) => {
|
|
242
|
-
try {
|
|
243
|
-
const r = await callAuthJson(base, "POST", "/auth/change-password", {
|
|
244
|
-
token: args.access_token,
|
|
245
|
-
json: {
|
|
246
|
-
current_password: args.current_password,
|
|
247
|
-
new_password: args.new_password,
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
if (!r.ok) {
|
|
251
|
-
return toolError(String(r.detail ?? "change password failed"), r.status_code === 401 ? "auth_required" : "invalid_input", {
|
|
252
|
-
related_tools: ["user_auth_login", "user_auth_me"],
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
return jsonOk(r);
|
|
256
|
-
}
|
|
257
|
-
catch (e) {
|
|
258
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
server.tool("user_auth_delete_account", "Delete the current account. DELETE /auth/me with JWT. Irreversible.", {
|
|
262
|
-
access_token: z.string().describe("JWT from user_auth_login or user_auth_verify_otp"),
|
|
263
|
-
}, async (args) => {
|
|
264
|
-
try {
|
|
265
|
-
const r = await callAuthJson(base, "DELETE", "/auth/me", {
|
|
266
|
-
token: args.access_token,
|
|
267
|
-
});
|
|
268
|
-
if (!r.ok) {
|
|
269
|
-
return toolError(String(r.detail ?? "delete failed"), "auth_required", {
|
|
270
|
-
related_tools: ["user_auth_login"],
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
return jsonOk(r);
|
|
274
|
-
}
|
|
275
|
-
catch (e) {
|
|
276
|
-
return toolError(e instanceof Error ? e.message : String(e), "backend_error");
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
}
|