premanmcp 1.1.2 → 1.1.4

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 CHANGED
@@ -21,15 +21,25 @@
21
21
  * run — cancelled, or taken over — and the only correct response is to stop.
22
22
  * - The runner token never enters the child's environment.
23
23
  *
24
- * One rule is this module's own: the run executes in a scratch directory, never
25
- * in the customer's project. assert-ai writes results under `cwd`, and a harness
26
- * that scattered `artifacts/` into somebody's repository would be a bad guest
27
- * even before the first `git status`.
24
+ * One rule is this module's own: the run executes outside the customer's
25
+ * project, under `~/.preman/evals`. assert-ai writes results under `cwd`, and a
26
+ * harness that scattered `artifacts/` into somebody's repository would be a bad
27
+ * guest even before the first `git status`. That directory is kept between runs
28
+ * rather than thrown away — see `suiteHome` for what is stored there and why
29
+ * deleting it was the reason every run measured different cases.
28
30
  */
29
31
 
30
32
  import { spawn, spawnSync } from "node:child_process";
31
33
  import { createHash } from "node:crypto";
32
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
34
+ import {
35
+ existsSync,
36
+ mkdirSync,
37
+ readdirSync,
38
+ readFileSync,
39
+ rmSync,
40
+ statSync,
41
+ writeFileSync,
42
+ } from "node:fs";
33
43
  import os from "node:os";
34
44
  import path from "node:path";
35
45
  import { fileURLToPath } from "node:url";
@@ -75,10 +85,114 @@ const MAX_STDERR_BYTES = 256 * 1024;
75
85
 
76
86
  export class EvalError extends Error {}
77
87
 
88
+ // ── Where a suite lives between runs ────────────────────────────────────
89
+
90
+ /** Everything this machine keeps about evals it has run. */
91
+ const EVAL_HOME = path.join(os.homedir(), ".preman", "evals");
92
+
93
+ /**
94
+ * Upstream's own rule for a suite id, copied rather than referenced.
95
+ *
96
+ * `assert_ai.config._validate_identifier` applies this to `suite:` before it
97
+ * builds a path from it. Applying it again here is not distrust of the backend
98
+ * but of the join: this is the one place a value off the network becomes a
99
+ * directory name in the user's home.
100
+ */
101
+ const SAFE_SUITE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
102
+
103
+ /** Finished runs kept per suite. Enough to compare against last week. */
104
+ const KEEP_RUNS = 20;
105
+
106
+ /**
107
+ * The directory this suite runs in — the same one, every time.
108
+ *
109
+ * This being stable is the whole reason a rerun measures the same cases as the
110
+ * run before it. assert-ai caches its two generative stages: `systematize` and
111
+ * `test_set` write into `<suite>/artifacts/<stage>/v0001, v0002, ...` beside a
112
+ * hash of what produced them, and on the next run it re-derives that hash and
113
+ * reuses the matching version instead of generating again. The hash covers the
114
+ * behaviour text, the stage config and the taxonomy the test set was built
115
+ * from, so an edit to any of those regenerates and nothing else does.
116
+ *
117
+ * That machinery was already there and we were defeating it. The run used to
118
+ * execute in a fresh `mkdtemp` that was deleted on the way out, so the lookup
119
+ * had an empty disk to search and missed every time — which is why two runs of
120
+ * an unchanged behaviour never scored the same cases, and why nothing here was
121
+ * comparable to anything.
122
+ *
123
+ * Deliberately not the customer's project, and deliberately not keyed by it
124
+ * either: the bank belongs to the behaviour, and running the same eval from a
125
+ * second checkout should reuse it rather than pay to invent a parallel one.
126
+ */
127
+ export function suiteHome(job, { home = EVAL_HOME } = {}) {
128
+ const suite = String(job?.suite ?? "");
129
+ if (suite.length > 255 || suite.includes("..") || !SAFE_SUITE.test(suite)) {
130
+ throw new EvalError(
131
+ `this run names a suite that cannot be a directory: ${JSON.stringify(suite)}`
132
+ );
133
+ }
134
+ const directory = path.join(home, suite);
135
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
136
+ return directory;
137
+ }
138
+
139
+ /**
140
+ * Drop the oldest finished runs, keeping the bank.
141
+ *
142
+ * Run outputs are transcripts and scores and they accumulate without limit;
143
+ * the cached stages next to them are the reason this directory is kept at all.
144
+ * So the rule is by name, not by age alone: `artifacts/` is never a candidate
145
+ * however old it looks, and everything else under the suite is a run.
146
+ *
147
+ * Called before the run starts rather than after it finishes, so that nothing
148
+ * being deleted here can be a run that is still being uploaded.
149
+ */
150
+ export function pruneRuns(home, job, { keep = KEEP_RUNS } = {}) {
151
+ const results = path.join(home, "artifacts", "results", String(job.suite));
152
+ let entries;
153
+ try {
154
+ entries = readdirSync(results, { withFileTypes: true });
155
+ } catch {
156
+ return [];
157
+ }
158
+
159
+ const runs = [];
160
+ for (const entry of entries) {
161
+ if (!entry.isDirectory() || entry.name === "artifacts") continue;
162
+ const full = path.join(results, entry.name);
163
+ try {
164
+ runs.push({ full, at: statSync(full).mtimeMs });
165
+ } catch {
166
+ // Vanished between the listing and the stat. Nothing to prune.
167
+ }
168
+ }
169
+
170
+ const doomed = runs.sort((a, b) => b.at - a.at).slice(keep);
171
+ for (const run of doomed) rmSync(run.full, { recursive: true, force: true });
172
+ return doomed.map((run) => run.full);
173
+ }
174
+
175
+ /**
176
+ * What a finished run leaves behind, and what it must not.
177
+ *
178
+ * The bundle goes. It is whatever the frame carried — a spec, and sandbox or
179
+ * fixture files that can hold a URL and a credential for the customer's own
180
+ * systems — and when this ran in a temp directory that was deleted on the way
181
+ * out, none of it outlived the run. Keeping the directory must not quietly
182
+ * turn that into a token sitting in `~/.preman` until someone notices.
183
+ *
184
+ * The bank and the results stay. Those are the point: generated cases to reuse
185
+ * and scores to compare against.
186
+ */
187
+ function tidy(home) {
188
+ if (!home) return;
189
+ rmSync(path.join(home, "bundle"), { recursive: true, force: true });
190
+ }
191
+
78
192
  // ── The bundle on disk ──────────────────────────────────────────────────
79
193
 
80
194
  /**
81
- * Write the leased run into a scratch directory and return what to run.
195
+ * Write the leased run into its suite's directory and return what to run.
82
196
  *
83
197
  * Every path in the frame is checked before it is joined, not because the
84
198
  * backend is untrusted but because "the server would never send that" is the
@@ -88,6 +202,11 @@ export class EvalError extends Error {}
88
202
  export function materialize(job, root) {
89
203
  mkdirSync(root, { recursive: true, mode: 0o700 });
90
204
  const bundle = path.join(root, "bundle");
205
+ // Emptied first, because the directory outlives the run now. Every file the
206
+ // spec refers to arrives in the frame, so anything already here came from a
207
+ // previous run — and a sandbox or fixture the behaviour has since dropped
208
+ // would otherwise still be sitting where the spec used to point.
209
+ rmSync(bundle, { recursive: true, force: true });
91
210
  mkdirSync(bundle, { recursive: true, mode: 0o700 });
92
211
 
93
212
  for (const entry of job.files || []) {
@@ -306,9 +425,10 @@ function readJson(file) {
306
425
  /**
307
426
  * Where this run's output lands.
308
427
  *
309
- * `artifacts_base()` upstream is `Path.cwd()`, so this is a consequence of the
310
- * scratch directory rather than a configuration choice — which is also why the
311
- * scratch directory has to be stable for the life of the run.
428
+ * `artifacts_base()` upstream is `Path.cwd()`, so this is a consequence of
429
+ * where the run is executed rather than a configuration choice — which is why
430
+ * `suiteHome` is what it is. The sibling of this path is the cached bank the
431
+ * same suite reuses next time.
312
432
  */
313
433
  export function runDir(cwd, job) {
314
434
  return path.join(cwd, "artifacts", "results", String(job.suite), String(job.run));
@@ -652,12 +772,13 @@ export async function executeEvalRun(
652
772
  return { ok, reason: result.ok ? "reported" : "report_failed" };
653
773
  };
654
774
 
655
- let scratch = "";
775
+ let home = "";
656
776
  let laid;
657
777
  let python;
658
778
  try {
659
- scratch = mkdtempSync(path.join(os.tmpdir(), "preman-eval-"));
660
- laid = materialize(job, scratch);
779
+ home = suiteHome(job);
780
+ pruneRuns(home, job);
781
+ laid = materialize(job, home);
661
782
  python = resolveInterpreter({ projectPath, log });
662
783
  log(`eval ${job.id}: assert-ai ${python.version} via ${python.how}`);
663
784
  } catch (error) {
@@ -665,12 +786,12 @@ export async function executeEvalRun(
665
786
  // amount of retrying changes it. Failing with the reason is more use than
666
787
  // letting the lease lapse and having the server call it a lost device.
667
788
  log(`eval ${job.id} cannot run here: ${error.message}`);
668
- if (scratch) rmSync(scratch, { recursive: true, force: true });
789
+ tidy(home);
669
790
  return finish(false, { error: error.message });
670
791
  }
671
792
 
672
793
  if (!existsSync(HARNESS_PATH)) {
673
- rmSync(scratch, { recursive: true, force: true });
794
+ tidy(home);
674
795
  return finish(false, { error: `the eval harness is missing from this install (${HARNESS_PATH})` });
675
796
  }
676
797
 
@@ -680,13 +801,13 @@ export async function executeEvalRun(
680
801
  // which reports it from inside `inference` as an authentication error
681
802
  // against a model name -- true, and no use to somebody who simply has not
682
803
  // saved a key anywhere.
683
- rmSync(scratch, { recursive: true, force: true });
804
+ tidy(home);
684
805
  return finish(false, { error: missingKey });
685
806
  }
686
807
 
687
808
  const handoff = await awaitAudience(job, { call, log, lease, headless, surface });
688
809
  if (handoff.lost) {
689
- rmSync(scratch, { recursive: true, force: true });
810
+ tidy(home);
690
811
  return { ok: false, reason: "lease_lost" };
691
812
  }
692
813
 
@@ -774,8 +895,8 @@ export async function executeEvalRun(
774
895
  clearInterval(heartbeat);
775
896
  clearTimeout(killer);
776
897
 
777
- // Deliberately before the scratch directory is removed: the summary is read
778
- // off the disk the run just wrote to.
898
+ // Deliberately before the bundle is removed: the summary is read off the
899
+ // disk the run just wrote to.
779
900
  const summary = readJson(path.join(runDir(laid.cwd, job), "metrics.json")) || {};
780
901
  const artifacts = runDir(laid.cwd, job);
781
902
 
@@ -783,7 +904,7 @@ export async function executeEvalRun(
783
904
  // No completion callback and no final sync. The run belongs to something
784
905
  // else now, and this device reporting a result for it — or writing over its
785
906
  // artifacts — is the exact thing the fencing token exists to prevent.
786
- rmSync(scratch, { recursive: true, force: true });
907
+ tidy(home);
787
908
  return { ok: false, reason: "lease_lost" };
788
909
  }
789
910
 
@@ -794,7 +915,7 @@ export async function executeEvalRun(
794
915
  // whose results are already readable.
795
916
  const final = await syncArtifacts(job, laid.cwd, sent, { call, log, lease });
796
917
  if (final.lost) {
797
- rmSync(scratch, { recursive: true, force: true });
918
+ tidy(home);
798
919
  return { ok: false, reason: "lease_lost" };
799
920
  }
800
921
 
@@ -818,12 +939,12 @@ export async function executeEvalRun(
818
939
  `measured against it. The last reason was: ${stats.lastFailure || "unknown"}`,
819
940
  exitCode,
820
941
  });
821
- rmSync(scratch, { recursive: true, force: true });
942
+ tidy(home);
822
943
  return { ...outcome, summary, artifacts };
823
944
  }
824
945
 
825
946
  const outcome = await finish(true, { summary, exitCode });
826
- rmSync(scratch, { recursive: true, force: true });
947
+ tidy(home);
827
948
  return { ...outcome, summary, artifacts };
828
949
  }
829
950
 
@@ -833,7 +954,7 @@ export async function executeEvalRun(
833
954
  error: `the eval harness exited ${exitCode}: ${tail}`,
834
955
  exitCode,
835
956
  });
836
- rmSync(scratch, { recursive: true, force: true });
957
+ tidy(home);
837
958
  return { ...outcome, summary, artifacts };
838
959
  }
839
960
 
@@ -1057,7 +1178,9 @@ async function runWithToken(
1057
1178
  // printed a URL and waited out the full timeout for a window it never asked
1058
1179
  // anything to open.
1059
1180
  const resolved = resolveEvalSurface(args, { runId: runs[0].id });
1060
- const surface = resolved ? { ...resolved, open: () => openUrl(resolved.href) } : null;
1181
+ const surface = resolved
1182
+ ? { ...resolved, open: () => openUrl(resolved.href, { app: resolved.app }) }
1183
+ : null;
1061
1184
  if (surface?.webUrl) say(`Watch it at ${surface.webUrl}`);
1062
1185
  say("");
1063
1186
 
@@ -447,26 +447,37 @@ async function premanTurn(target, args, { message, history }, state) {
447
447
  }
448
448
 
449
449
  /**
450
- * PreMan's `turn.artifacts` as the events assert-ai understands.
450
+ * What the turn did, as the events assert-ai understands.
451
451
  *
452
- * Best-effort and deliberately lossy: only artifacts that clearly describe a
453
- * tool invocation become events. Forwarding everything would put rendered
454
- * markdown and UI intents in front of the judge as though they were actions the
455
- * agent took, and a judge scoring "did it do something it should not have" would
456
- * be reading the wrong evidence.
452
+ * Read from `turn.tool_calls`, which the backend records by name. It used to
453
+ * be inferred from `turn.artifacts` and that never worked: artifacts are keyed
454
+ * by the UI card they render rather than by the tool that produced them, no
455
+ * artifact anywhere carried a tool name, and the loop that looked for one
456
+ * returned an empty array on every turn ever scored. A judge asked whether the
457
+ * agent checked before answering was reading a transcript in which checking
458
+ * was invisible.
459
+ *
460
+ * `executed: false` is reported as a distinct event rather than dropped or
461
+ * flattened into a call. A risky tool is not run — the broker records a
462
+ * proposal and waits for a person — so treating the request as the deed would
463
+ * have the judge score a deletion that never occurred.
457
464
  */
458
465
  function eventsFrom(turn) {
459
- const artifacts = Array.isArray(turn.artifacts) ? turn.artifacts : [];
466
+ const calls = Array.isArray(turn.tool_calls) ? turn.tool_calls : [];
460
467
  const events = [];
461
- for (const artifact of artifacts) {
462
- const name = artifact?.tool || artifact?.name || artifact?.tool_name;
468
+ for (const call of calls) {
469
+ const name = String(call?.name || "");
463
470
  if (!name) continue;
464
- events.push({ type: "tool_call", name: String(name), arguments: artifact.arguments ?? artifact.input ?? {} });
465
- if (artifact.result !== undefined || artifact.output !== undefined) {
471
+ events.push({
472
+ type: "tool_call",
473
+ name,
474
+ arguments: call.arguments ?? {},
475
+ });
476
+ if (call.executed === false) {
466
477
  events.push({
467
478
  type: "tool_result",
468
- name: String(name),
469
- result: artifact.result ?? artifact.output,
479
+ name,
480
+ result: { executed: false, reason: "awaiting the user's approval" },
470
481
  });
471
482
  }
472
483
  }
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
- // Deliberately ahead of the "no dashboard is known" return below: not knowing
180
- // which browser page holds the run says nothing about whether the app is
181
- // installed, and raising it is still the most useful thing available.
182
- if (preferApp && installed) {
183
- return { href: `preman://open?source=${source}`, webUrl, surface: "desktop-raise" };
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
@@ -340,15 +340,25 @@ export function pairedFrontendUrl(args) {
340
340
  * ever a convenience — a suite that exercises these flows should not be able to
341
341
  * throw tabs at whoever is running it.
342
342
  */
343
- export function openUrl(url) {
343
+ export function openUrl(url, { app = "" } = {}) {
344
344
  const optOut = (process.env.PREMAN_NO_BROWSER || "").trim().toLowerCase();
345
345
  if (optOut && !["0", "false", "no"].includes(optOut)) return false;
346
346
  if (!process.stdout.isTTY) return false;
347
347
 
348
348
  const opener =
349
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];
350
360
  try {
351
- const child = spawn(opener, [url], {
361
+ const child = spawn(opener, argv, {
352
362
  stdio: "ignore",
353
363
  detached: true,
354
364
  shell: process.platform === "win32",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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>;
@@ -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, "&amp;").replace(/"/g, "&quot;");
12
- }
13
- export function escapeHtmlText(s) {
14
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- }
@@ -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;
@@ -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
- }