premanmcp 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/desktop.js CHANGED
@@ -133,9 +133,16 @@ export function desktopAppRunning(destination = "/Applications") {
133
133
  * installed. A copy that ignores the request keeps running and the caller says
134
134
  * so rather than escalating.
135
135
  */
136
+ // Longer than the app allows its own shutdown, which is fifteen seconds of
137
+ // waiting for the runner's last calls to PreMan. Giving up first is not a
138
+ // neutral act: what follows is a launch, and a launch that lands while the old
139
+ // instance still holds the single-instance lock is delivered to that instance
140
+ // rather than starting a new one.
141
+ const QUIT_TIMEOUT_MS = 20_000;
142
+
136
143
  export async function quitDesktopApp({
137
144
  destination = "/Applications",
138
- timeoutMs = 8_000,
145
+ timeoutMs = QUIT_TIMEOUT_MS,
139
146
  sleep = defaultSleep,
140
147
  } = {}) {
141
148
  if (!desktopAppRunning(destination)) return "not-running";
@@ -230,6 +237,7 @@ export async function openDesktopSignedIn(
230
237
  // exercised without a real app on the machine running the tests.
231
238
  isRunning = desktopAppRunning,
232
239
  quit = quitDesktopApp,
240
+ launch = defaultLaunch,
233
241
  } = {}
234
242
  ) {
235
243
  if (!desktopAppInstalled(destination)) {
@@ -241,9 +249,18 @@ export async function openDesktopSignedIn(
241
249
  restarted = (await quit({ destination, sleep })) === "quit";
242
250
  }
243
251
  const handoff = writeDesktopSession(creds);
244
- // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
245
- // which may well pick a different copy than the one just installed.
246
- spawn("open", ["-a", installedAppPath(destination)], { stdio: "ignore", detached: true }).unref();
252
+
253
+ // Asked to go and still here. Launching now does not start anything: the old
254
+ // instance holds the single-instance lock until it actually exits, so the
255
+ // launch reaches it as a second-instance and it re-shows the window it was in
256
+ // the middle of dismantling -- which is how onboarding ended on a black
257
+ // rectangle. Nothing here can hurry it, and the session on disk is what the
258
+ // next launch adopts, so saying so beats making it worse.
259
+ if (wasRunning && restartIfRunning && !restarted) {
260
+ return { state: "opened-already-running", handoff: handoff.state };
261
+ }
262
+
263
+ launch(destination);
247
264
  if (handoff.state !== "written") return { state: "opened", handoff: handoff.state };
248
265
 
249
266
  if (wasRunning && !restarted) {
@@ -266,6 +283,14 @@ export async function openDesktopSignedIn(
266
283
 
267
284
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
268
285
 
286
+ // The bundle path rather than the name: `open -a PreMan` asks LaunchServices,
287
+ // which may well pick a different copy than the one just installed.
288
+ const defaultLaunch = (destination) =>
289
+ spawn("open", ["-a", installedAppPath(destination)], {
290
+ stdio: "ignore",
291
+ detached: true,
292
+ }).unref();
293
+
269
294
  /**
270
295
  * Open a Playground session URL in PreMan.app on macOS when it is installed.
271
296
  * Windows/Linux (and Mac without the app) get the website URL printed instead.
@@ -18,6 +18,7 @@ import { describeCheckout, explainNoCheckout, resolveCheckout } from "./repo.js"
18
18
  import { desktopAppInstalled, writeDesktopSession } from "./desktop.js";
19
19
  import {
20
20
  DEFAULT_BACKEND,
21
+ askWhileWaiting,
21
22
  assertOk,
22
23
  backendUrl,
23
24
  callBackendJson,
@@ -116,37 +117,58 @@ export class Unrecoverable extends Error {}
116
117
  * Returns the truthy value from ``check``, or null on timeout. Ordinary
117
118
  * exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
118
119
  */
120
+ const NOT_ANSWERED = Symbol("not answered");
121
+
119
122
  async function waitFor(
120
123
  label,
121
124
  check,
122
- { hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS } = {}
125
+ { hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS, escape = null } = {}
123
126
  ) {
124
127
  const startedAt = Date.now();
125
128
  const deadline = startedAt + timeoutMs;
126
129
  let hinted = false;
130
+ let asked = null;
127
131
  process.stdout.write(`Waiting for ${label}`);
128
- while (Date.now() < deadline) {
129
- try {
130
- const done = await check();
131
- if (done) {
132
- process.stdout.write(" done.\n");
133
- return done;
132
+ try {
133
+ while (Date.now() < deadline) {
134
+ try {
135
+ const done = await check();
136
+ if (done) {
137
+ process.stdout.write(asked ? "\n" : " done.\n");
138
+ return done;
139
+ }
140
+ } catch (err) {
141
+ if (err instanceof Unrecoverable) {
142
+ process.stdout.write("\n");
143
+ throw err;
144
+ }
145
+ /* keep waiting; the customer is elsewhere */
134
146
  }
135
- } catch (err) {
136
- if (err instanceof Unrecoverable) {
137
- process.stdout.write("\n");
138
- throw err;
147
+ // A minute of dots is indistinguishable from a hang, and the reason this
148
+ // waits forever is usually something the customer can act on.
149
+ if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
150
+ hinted = true;
151
+ process.stdout.write(`\n${hint}\n`);
152
+ // Dots and a question cannot share a line. Once there is something to
153
+ // answer, the answering is the whole of what this is doing.
154
+ if (escape) asked = askWhileWaiting(escape.question);
155
+ else process.stdout.write("Still waiting");
156
+ }
157
+ if (!asked) process.stdout.write(".");
158
+ // Polling carries on underneath the question, so an install that lands
159
+ // while it is on the screen still ends this the way it should.
160
+ const answer = await Promise.race([
161
+ new Promise((resolve) => setTimeout(() => resolve(NOT_ANSWERED), POLL_INTERVAL_MS)),
162
+ asked ? asked.reply : new Promise(() => {}),
163
+ ]);
164
+ if (answer !== NOT_ANSWERED) {
165
+ escape.answer = typeof answer === "string" ? answer : "";
166
+ return null;
139
167
  }
140
- /* keep waiting; the customer is elsewhere */
141
- }
142
- // A minute of dots is indistinguishable from a hang, and the reason this
143
- // waits forever is usually something the customer can act on.
144
- if (hint && !hinted && Date.now() - startedAt > hintAfterMs) {
145
- hinted = true;
146
- process.stdout.write(`\n${hint}\nStill waiting`);
147
168
  }
148
- process.stdout.write(".");
149
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
169
+ } finally {
170
+ // Whichever side won, the question goes when the wait does.
171
+ asked?.cancel();
150
172
  }
151
173
  process.stdout.write("\n");
152
174
  return null;
@@ -300,37 +322,64 @@ export async function githubCommand(args, { ensureDesktopApp = null } = {}) {
300
322
  // a success carrying no repositories means the App is installed and sharing
301
323
  // nothing — two dead ends that are indistinguishable from the repository list.
302
324
  let refresh = null;
303
- const done = await waitFor(
304
- "the installation",
305
- async () => {
306
- // Installing the App and having repositories appear are two events: the
307
- // callback records the installation, and a refresh materialises the repos.
308
- // Polling the repo list alone waits for something that may never arrive on
309
- // its own.
310
- refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
311
- token,
312
- json: {},
313
- });
314
- // Compare against what existed before, so a user who already had repos
315
- // connected is not told they are done the moment polling starts.
316
- const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
317
- return fresh.length ? fresh : null;
318
- },
319
- {
320
- timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
321
- hintAfterMs: 20000,
322
- hint: "Still nothing from GitHub confirm a repository to come back here.",
323
- }
324
- );
325
+ const lookForRepos = async () => {
326
+ // Installing the App and having repositories appear are two events: the
327
+ // callback records the installation, and a refresh materialises the repos.
328
+ // Polling the repo list alone waits for something that may never arrive on
329
+ // its own.
330
+ refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
331
+ token,
332
+ json: {},
333
+ });
334
+ // Compare against what existed before, so a user who already had repos
335
+ // connected is not told they are done the moment polling starts.
336
+ const fresh = (await listRepos()).filter((r) => !seen.has(r.id));
337
+ return fresh.length ? fresh : null;
338
+ };
339
+
340
+ // Waiting is the right default, because the install usually lands inside it.
341
+ // What it cannot be is the only thing on offer. Somebody who picked no
342
+ // repository, or installed onto an organisation where an owner has to approve
343
+ // it first, is watching dots for an event that is not coming, and the only
344
+ // way out of that was to guess that Ctrl-C here was safe.
345
+ const escape = {
346
+ question:
347
+ "Have you saved the repository selection on GitHub? [y/n, or Enter to keep waiting]: ",
348
+ answer: null,
349
+ };
350
+ let done = await waitFor("the installation", lookForRepos, {
351
+ timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
352
+ hintAfterMs: Number(process.env.PREMAN_GITHUB_HINT_MS) || 20000,
353
+ hint: "Still nothing from GitHub — confirm a repository to come back here.",
354
+ escape,
355
+ });
356
+
357
+ // A yes buys one more look, not one more minute. The callback lands while the
358
+ // question is still on the screen often enough to be worth asking again, and
359
+ // if it has not by now, more dots will not change that.
360
+ if (!done && affirmative(escape.answer)) {
361
+ done = await lookForRepos().catch(() => null);
362
+ }
325
363
 
326
364
  if (!done) {
327
- process.stdout.write(githubHandOff(args, refresh));
365
+ process.stdout.write(githubHandOff(args, refresh, escape.answer));
328
366
  return;
329
367
  }
330
368
  connected(`GitHub connected: ${done.length} repository(ies).`);
331
369
  for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
332
370
  }
333
371
 
372
+ /**
373
+ * Whether a free-text reply means yes.
374
+ *
375
+ * Silence is not consent anywhere else in this walk and it is not here either:
376
+ * only an explicit yes counts, and everything else is left for later.
377
+ */
378
+ function affirmative(answer) {
379
+ const said = (answer || "").trim().toLowerCase();
380
+ return said === "y" || said === "yes";
381
+ }
382
+
334
383
  /**
335
384
  * Stop waiting, and name the dead end instead of the timeout.
336
385
  *
@@ -338,7 +387,16 @@ export async function githubCommand(args, { ensureDesktopApp = null } = {}) {
338
387
  * finishes in the browser whether this process is watching or not, and the two
339
388
  * ways it can complete and still leave PreMan with nothing are both actionable.
340
389
  */
341
- function githubHandOff(args, refresh) {
390
+ function githubHandOff(args, refresh, answer = null) {
391
+ // A no is a decision, not a dead end. Reporting it as one would be telling
392
+ // somebody that something went wrong immediately after they said they had
393
+ // simply not done it yet.
394
+ if (answer !== null && !affirmative(answer)) {
395
+ return (
396
+ `Left for later, and nothing here is broken.\n` +
397
+ ` Connect it whenever you like with '${cliInvocation()} github'.\n`
398
+ );
399
+ }
342
400
  const installed = Boolean(refresh?.ok) && Number(refresh.installations_refreshed || 0) > 0;
343
401
  if (installed) {
344
402
  return (
package/bin/shared.js CHANGED
@@ -321,6 +321,48 @@ export async function promptText(question) {
321
321
  }
322
322
  }
323
323
 
324
+ /**
325
+ * Ask a question that whatever we are waiting for may answer first.
326
+ *
327
+ * Returns the reply and a `cancel` for when it arrives too late to matter. A
328
+ * plain promptText cannot be raced: abandoning its promise leaves the readline
329
+ * interface open, an open interface holds stdin, and stdin holds the process
330
+ * open with it -- so a wait that ended on its own would sit there afterwards on
331
+ * a question nobody needed answering.
332
+ *
333
+ * Where there is no terminal there is nobody to ask, so the reply is a promise
334
+ * that never settles. A race is then decided entirely by the other side, which
335
+ * is how an unattended run goes on behaving like one.
336
+ */
337
+ export function askWhileWaiting(question) {
338
+ const nobodyThere =
339
+ !process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed;
340
+ if (nobodyThere) return { reply: new Promise(() => {}), cancel: () => {} };
341
+
342
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
343
+ let cancelled = false;
344
+ let settle = null;
345
+ const reply = new Promise((resolve) => {
346
+ settle = (value) => {
347
+ if (settle.done) return;
348
+ settle.done = true;
349
+ resolve(value);
350
+ };
351
+ });
352
+ rl.question(question).then(
353
+ (value) => settle(value.trim()),
354
+ () => settle("")
355
+ );
356
+ rl.once("close", () => settle(cancelled ? null : ""));
357
+ return {
358
+ reply,
359
+ cancel: () => {
360
+ cancelled = true;
361
+ rl.close();
362
+ },
363
+ };
364
+ }
365
+
324
366
  export async function promptSecret(question) {
325
367
  if (!process.stdin.isTTY || !process.stdin.setRawMode) {
326
368
  return promptText(question);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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
- }