premanmcp 1.1.0 → 1.1.2

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
@@ -34,7 +34,7 @@ import os from "node:os";
34
34
  import path from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
 
37
- import { discoverTarget, startAdapter, stepFeed } from "./eval_target.js";
37
+ import { adapterStats, discoverTarget, startAdapter, stepFeed } from "./eval_target.js";
38
38
  import { resolveEvalSurface } from "./link.js";
39
39
  import {
40
40
  backendUrl,
@@ -799,6 +799,29 @@ export async function executeEvalRun(
799
799
  }
800
800
 
801
801
  if (exitCode === 0) {
802
+ // A harness that exited 0 having never once reached the agent is not a
803
+ // result, and it is the most misleading shape a run can take: every case
804
+ // was scored, the numbers look like measurements, and what the judge
805
+ // actually read was the adapter's apology on each turn. The one that
806
+ // prompted this presented an `api.preman.live` key to an agent on
807
+ // localhost, 401ed eighteen times, and finished ok.
808
+ //
809
+ // Turns are counted rather than failures, because a run where the agent
810
+ // answered some of the time is a real measurement of a flaky agent and
811
+ // deciding otherwise here would throw away the finding.
812
+ const stats = adapterStats();
813
+ if (stats && stats.turns === 0 && stats.failures > 0) {
814
+ const outcome = await finish(false, {
815
+ summary,
816
+ error:
817
+ `not one of the ${stats.failures} turns reached the agent, so nothing here was ` +
818
+ `measured against it. The last reason was: ${stats.lastFailure || "unknown"}`,
819
+ exitCode,
820
+ });
821
+ rmSync(scratch, { recursive: true, force: true });
822
+ return { ...outcome, summary, artifacts };
823
+ }
824
+
802
825
  const outcome = await finish(true, { summary, exitCode });
803
826
  rmSync(scratch, { recursive: true, force: true });
804
827
  return { ...outcome, summary, artifacts };
@@ -35,7 +35,46 @@
35
35
  import { createServer } from "node:http";
36
36
  import { createHash } from "node:crypto";
37
37
 
38
- import { backendUrl, resolveApiKey } from "./shared.js";
38
+ import { backendUrl, resolveApiKey, storedKeyOrigin } from "./shared.js";
39
+
40
+ /**
41
+ * The credential to present to the agent under test.
42
+ *
43
+ * When the agent *is* a PreMan deployment, this is a PreMan key -- but not
44
+ * necessarily the one this CLI reports results with. Those are two different
45
+ * systems that happen to be the same product: the control plane is whichever
46
+ * deployment issued the saved key, and the agent is whatever answered on this
47
+ * machine. A run that presented an `api.preman.live` key to a backend on
48
+ * localhost 401ed on all eighteen turns and still reported a score, because the
49
+ * adapter turns a failure into a transcript the judge can read.
50
+ *
51
+ * Refusing beats warning. Every turn would fail, the whole run would be scored
52
+ * against the adapter's apology, and it would cost a full round of model calls
53
+ * to arrive at a number about nothing.
54
+ */
55
+ function agentToken(args, baseUrl, log) {
56
+ const named = args.value("--agent-key", process.env.PREMAN_AGENT_KEY || "");
57
+ if (named) return named;
58
+
59
+ const key = resolveApiKey(args);
60
+ const origin = storedKeyOrigin(args);
61
+ if (!origin || sameDeployment(origin, baseUrl)) return key;
62
+
63
+ throw new TargetError(
64
+ `the agent here is ${baseUrl}, but the only PreMan key on this machine was issued by ` +
65
+ `${origin}. It would be refused on every turn and the run would score the refusals. ` +
66
+ `Set PREMAN_AGENT_KEY to a key ${baseUrl} accepts, or stop that agent so this measures ` +
67
+ `${origin} instead.`
68
+ );
69
+ }
70
+
71
+ function sameDeployment(a, b) {
72
+ try {
73
+ return new URL(a).host === new URL(b).host;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
39
78
 
40
79
  export class TargetError extends Error {}
41
80
 
@@ -130,7 +169,7 @@ export async function discoverTarget(args, { log = () => {} } = {}) {
130
169
  // The agent's own credential, held here and never sent to PreMan's
131
170
  // control plane. The adapter is the only thing that ever presents it,
132
171
  // which is the whole reason the adapter runs on this machine.
133
- token: resolveApiKey(args),
172
+ token: agentToken(args, `http://127.0.0.1:${port}`, log),
134
173
  label: `PreMan agent (local, port ${port})`,
135
174
  how: `probed localhost and got a PreMan /health on port ${port}`,
136
175
  probed,
@@ -146,7 +185,7 @@ export async function discoverTarget(args, { log = () => {} } = {}) {
146
185
  return {
147
186
  kind: "preman-chat",
148
187
  baseUrl: configured,
149
- token: resolveApiKey(args),
188
+ token: agentToken(args, configured, log),
150
189
  label: `PreMan agent (${configured})`,
151
190
  how: `nothing local answered, so this is the backend the CLI is configured against`,
152
191
  probed,
@@ -463,6 +502,7 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
463
502
  conversations: new Map(),
464
503
  turns: 0,
465
504
  failures: 0,
505
+ lastFailure: "",
466
506
  streamFailures: 0,
467
507
  log,
468
508
  /**
@@ -500,6 +540,7 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
500
540
  reply(200, result);
501
541
  } catch (error) {
502
542
  state.failures += 1;
543
+ state.lastFailure = error.message;
503
544
  // 200 with an error string in `response`, not a 5xx. A transport failure
504
545
  // aborts the case and loses it; an agent that said something unhelpful is
505
546
  // a transcript the judge can score. Which of those this is depends on
@@ -519,12 +560,32 @@ export async function startAdapter(target, args, { log = () => {}, onStep = null
519
560
  const url = `http://127.0.0.1:${port}/chat`;
520
561
  log(`Adapter listening on ${url} -> ${target.label}`);
521
562
 
563
+ activeStats = state;
522
564
  return {
523
565
  url,
524
566
  translated: true,
525
567
  stats: state,
526
568
  close: async () => {
569
+ activeStats = null;
527
570
  await new Promise((resolve) => server.close(resolve));
528
571
  },
529
572
  };
530
573
  }
574
+
575
+ /**
576
+ * The counters for the adapter this process is running, if it is running one.
577
+ *
578
+ * A module-level handle rather than an argument threaded down, because the
579
+ * adapter is started by the command and the counters are needed by
580
+ * `executeEvalRun`, which the runner loop calls — and the loop sits between
581
+ * them knowing nothing about either. There is at most one adapter per process:
582
+ * it is opened once for the whole invocation and the runs share it.
583
+ *
584
+ * Null when the target is a URL the customer named, which the CLI does not
585
+ * mediate and therefore cannot count.
586
+ */
587
+ let activeStats = null;
588
+
589
+ export function adapterStats() {
590
+ return activeStats;
591
+ }
package/bin/shared.js CHANGED
@@ -235,6 +235,20 @@ function activeLogin(args) {
235
235
  return apiKeyIsExplicit(args) ? null : readStoredCredentials();
236
236
  }
237
237
 
238
+ /**
239
+ * Which deployment the saved key was minted against, or "" if that is unknown.
240
+ *
241
+ * Unknown is the honest answer for a key given on the command line or in the
242
+ * environment: nothing records where it came from, and guessing would be worse
243
+ * than admitting it. Callers presenting a key to something other than PreMan's
244
+ * own control plane need this to tell "this will work" from "this is a key for
245
+ * somewhere else".
246
+ */
247
+ export function storedKeyOrigin(args) {
248
+ const login = activeLogin(args);
249
+ return login?.backend_url || "";
250
+ }
251
+
238
252
  /**
239
253
  * Which PreMan deployment this invocation talks to.
240
254
  *
@@ -250,6 +264,27 @@ export function backendUrl(args) {
250
264
  .replace(/\/+$/, "");
251
265
  }
252
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
+
253
288
  /**
254
289
  * Where the dashboard lives for this user.
255
290
  *
@@ -482,11 +517,21 @@ export async function callBackendJson(
482
517
  }
483
518
  }
484
519
 
485
- const resp = await fetch(url, {
486
- method,
487
- headers,
488
- body: form !== undefined && form !== null ? form : hasBody ? JSON.stringify(json) : undefined,
489
- });
520
+ let resp;
521
+ try {
522
+ resp = await fetch(url, {
523
+ method,
524
+ headers,
525
+ body: form !== undefined && form !== null ? form : hasBody ? JSON.stringify(json) : undefined,
526
+ });
527
+ } catch (error) {
528
+ // `fetch failed` is what Node says when nothing accepted the connection,
529
+ // and on its own it is unactionable -- it names neither the address nor
530
+ // the reason that address was chosen. Both are known here.
531
+ throw new Error(
532
+ `could not reach ${url.origin} (${backendSource(args)}): ${error?.cause?.message || error.message}`
533
+ );
534
+ }
490
535
  const text = await resp.text();
491
536
  let body = {};
492
537
  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, "&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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {