premanmcp 1.1.1 → 1.1.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/eval.js +3 -1
- package/bin/link.js +21 -8
- package/bin/runner.js +1 -1
- package/bin/shared.js +48 -7
- package/dist/auth_flow_ui.d.ts +38 -0
- package/dist/auth_flow_ui.js +170 -0
- package/dist/mcp-preview-panel.d.ts +30 -0
- package/dist/mcp-preview-panel.js +166 -0
- package/dist/user_auth_flow.d.ts +11 -0
- package/dist/user_auth_flow.js +279 -0
- package/package.json +1 -1
package/bin/eval.js
CHANGED
|
@@ -1057,7 +1057,9 @@ async function runWithToken(
|
|
|
1057
1057
|
// printed a URL and waited out the full timeout for a window it never asked
|
|
1058
1058
|
// anything to open.
|
|
1059
1059
|
const resolved = resolveEvalSurface(args, { runId: runs[0].id });
|
|
1060
|
-
const surface = resolved
|
|
1060
|
+
const surface = resolved
|
|
1061
|
+
? { ...resolved, open: () => openUrl(resolved.href, { app: resolved.app }) }
|
|
1062
|
+
: null;
|
|
1061
1063
|
if (surface?.webUrl) say(`Watch it at ${surface.webUrl}`);
|
|
1062
1064
|
say("");
|
|
1063
1065
|
|
package/bin/link.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* a `git push` is exactly the behaviour that gets a tool uninstalled.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { desktopAppInstalled, installedDesktopVersion } from "./desktop.js";
|
|
17
|
+
import { desktopAppInstalled, installedAppPath, installedDesktopVersion } from "./desktop.js";
|
|
18
18
|
import { DEFAULT_FRONTEND, frontendUrl, pairedFrontendUrl } from "./shared.js";
|
|
19
19
|
|
|
20
20
|
export { installedDesktopVersion };
|
|
@@ -158,6 +158,10 @@ export function resolveEvalSurface(args, { runId = "", destination = "/Applicati
|
|
|
158
158
|
* safe when routing is not: no build can misread it, and no dashboard can be
|
|
159
159
|
* the wrong one, because none is named. It cannot land you on the run, and the
|
|
160
160
|
* caller says so rather than implying otherwise.
|
|
161
|
+
*
|
|
162
|
+
* Which is why it now ranks below a browser link rather than above one: "cannot
|
|
163
|
+
* land you on the run" is a real cost, and it is only worth paying when the
|
|
164
|
+
* alternative is nothing at all. See the ordering in `resolveSurface`.
|
|
161
165
|
*/
|
|
162
166
|
function resolveSurface(
|
|
163
167
|
args,
|
|
@@ -167,23 +171,32 @@ function resolveSurface(
|
|
|
167
171
|
const base = requirePairing ? pairedFrontendUrl(args) : frontendUrl(args);
|
|
168
172
|
const webUrl = base ? `${base}${route}` : "";
|
|
169
173
|
const installed = desktopAppInstalled(destination);
|
|
174
|
+
const app = installed ? installedAppPath(destination) : "";
|
|
170
175
|
|
|
171
176
|
if (base === DEFAULT_FRONTEND && installed && desktopSupportsRouting(destination)) {
|
|
172
177
|
return {
|
|
173
178
|
href: `preman://open?source=${source}&route=${encodeURIComponent(route)}`,
|
|
174
179
|
webUrl,
|
|
180
|
+
app,
|
|
175
181
|
surface: "desktop",
|
|
176
182
|
};
|
|
177
183
|
}
|
|
178
184
|
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
// A raise is now the last resort rather than the second choice, and only when
|
|
186
|
+
// there is no page to send anyone to instead.
|
|
187
|
+
//
|
|
188
|
+
// It was ahead of the web branch on the reasoning that somebody watching an
|
|
189
|
+
// eval is already sitting in front of the app. True, but it trades a link
|
|
190
|
+
// that lands on the run for a window that lands wherever it was left --
|
|
191
|
+
// which, for an app too old to route, is the one thing it can never be. What
|
|
192
|
+
// that bought in practice was a stale view and the question "where is my
|
|
193
|
+
// run", while the browser would have shown it. Raising still beats nothing,
|
|
194
|
+
// so it survives for the case where no dashboard is known at all.
|
|
195
|
+
if (!base) {
|
|
196
|
+
return preferApp && installed
|
|
197
|
+
? { href: `preman://open?source=${source}`, webUrl, app, surface: "desktop-raise" }
|
|
198
|
+
: null;
|
|
184
199
|
}
|
|
185
|
-
|
|
186
|
-
if (!base) return null;
|
|
187
200
|
if (base !== DEFAULT_FRONTEND) return { href: webUrl, webUrl, surface: "web-local" };
|
|
188
201
|
return { href: webUrl, webUrl, surface: "web" };
|
|
189
202
|
}
|
package/bin/runner.js
CHANGED
|
@@ -467,7 +467,7 @@ export async function runLeasedEval(args, state, job, { log = () => {}, headless
|
|
|
467
467
|
log,
|
|
468
468
|
headless,
|
|
469
469
|
projectPath: state.project_path || process.cwd(),
|
|
470
|
-
surface: { ...surface, open: () => openUrl(surface.href) },
|
|
470
|
+
surface: { ...surface, open: () => openUrl(surface.href, { app: surface.app }) },
|
|
471
471
|
});
|
|
472
472
|
if (result.reason === "lease_lost") log(`eval ${job.id} was taken over or cancelled`);
|
|
473
473
|
else log(`eval ${job.id} finished: ${result.ok ? "ok" : "failed"}`);
|
package/bin/shared.js
CHANGED
|
@@ -264,6 +264,27 @@ export function backendUrl(args) {
|
|
|
264
264
|
.replace(/\/+$/, "");
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Why this invocation is talking to that deployment.
|
|
269
|
+
*
|
|
270
|
+
* Only ever shown when reaching it failed. An unreachable backend is nearly
|
|
271
|
+
* always a stale override rather than an outage -- a shell that exported
|
|
272
|
+
* PREMAN_BACKEND at a local server hours ago keeps pointing there long after
|
|
273
|
+
* the server is gone, and the CLI cannot tell that apart from the deployment
|
|
274
|
+
* being down. The address alone does not help: the question a person has at
|
|
275
|
+
* that moment is not "what did you call" but "why on earth are you calling
|
|
276
|
+
* *that*", and the answer is somewhere they are not looking.
|
|
277
|
+
*
|
|
278
|
+
* Mirrors the precedence in `backendUrl` above, which is the only way it can
|
|
279
|
+
* be right; the two would have to be changed together.
|
|
280
|
+
*/
|
|
281
|
+
export function backendSource(args) {
|
|
282
|
+
if (args.value("--backend", "")) return "the --backend flag";
|
|
283
|
+
if (process.env.PREMAN_BACKEND) return "PREMAN_BACKEND, exported in this shell";
|
|
284
|
+
if (activeLogin(args)?.backend_url) return "the deployment you last logged in to";
|
|
285
|
+
return "the default";
|
|
286
|
+
}
|
|
287
|
+
|
|
267
288
|
/**
|
|
268
289
|
* Where the dashboard lives for this user.
|
|
269
290
|
*
|
|
@@ -319,15 +340,25 @@ export function pairedFrontendUrl(args) {
|
|
|
319
340
|
* ever a convenience — a suite that exercises these flows should not be able to
|
|
320
341
|
* throw tabs at whoever is running it.
|
|
321
342
|
*/
|
|
322
|
-
export function openUrl(url) {
|
|
343
|
+
export function openUrl(url, { app = "" } = {}) {
|
|
323
344
|
const optOut = (process.env.PREMAN_NO_BROWSER || "").trim().toLowerCase();
|
|
324
345
|
if (optOut && !["0", "false", "no"].includes(optOut)) return false;
|
|
325
346
|
if (!process.stdout.isTTY) return false;
|
|
326
347
|
|
|
327
348
|
const opener =
|
|
328
349
|
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
350
|
+
// `app` names the bundle that must receive the URL, and is the difference
|
|
351
|
+
// between "a PreMan window" and "the PreMan window we checked". A custom
|
|
352
|
+
// scheme is claimed, not owned: every build that ever ran on this machine
|
|
353
|
+
// registers `preman:`, including one in the Trash and any dev copy started
|
|
354
|
+
// from a checkout, and LaunchServices picks among them by its own rules. So
|
|
355
|
+
// the CLI inspected the app in /Applications, decided from its version what
|
|
356
|
+
// the link could do, and then handed the URL to whichever claimant won --
|
|
357
|
+
// which on this machine was a dev build pointed at a local backend that was
|
|
358
|
+
// not running. `open -a` addresses the bundle directly and skips the auction.
|
|
359
|
+
const argv = app && process.platform === "darwin" ? ["-a", app, url] : [url];
|
|
329
360
|
try {
|
|
330
|
-
const child = spawn(opener,
|
|
361
|
+
const child = spawn(opener, argv, {
|
|
331
362
|
stdio: "ignore",
|
|
332
363
|
detached: true,
|
|
333
364
|
shell: process.platform === "win32",
|
|
@@ -496,11 +527,21 @@ export async function callBackendJson(
|
|
|
496
527
|
}
|
|
497
528
|
}
|
|
498
529
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
530
|
+
let resp;
|
|
531
|
+
try {
|
|
532
|
+
resp = await fetch(url, {
|
|
533
|
+
method,
|
|
534
|
+
headers,
|
|
535
|
+
body: form !== undefined && form !== null ? form : hasBody ? JSON.stringify(json) : undefined,
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
// `fetch failed` is what Node says when nothing accepted the connection,
|
|
539
|
+
// and on its own it is unactionable -- it names neither the address nor
|
|
540
|
+
// the reason that address was chosen. Both are known here.
|
|
541
|
+
throw new Error(
|
|
542
|
+
`could not reach ${url.origin} (${backendSource(args)}): ${error?.cause?.message || error.message}`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
504
545
|
const text = await resp.text();
|
|
505
546
|
let body = {};
|
|
506
547
|
try {
|
|
@@ -0,0 +1,38 @@
|
|
|
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>;
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
}>;
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
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;
|
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
}
|