premanmcp 0.10.7 → 0.12.0

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/README.md CHANGED
@@ -4,14 +4,24 @@ Turn your APIs into MCP tools that coding agents can discover, call, test, and a
4
4
 
5
5
  PreMan is agent-first API infrastructure. It lets backend teams expose endpoints to AI coding agents through MCP, add an auth layer around those tools, and see exactly which agent called what.
6
6
 
7
- ## Connect
7
+ ## Start
8
+
9
+ ```bash
10
+ npm exec -y premanmcp@latest -- onboard
11
+ ```
12
+
13
+ Create the account, verify the email, and get the PreMan app installed and opened
14
+ already signed in. A bare `preman` in a terminal does the same thing.
15
+
16
+ ## Connect a coding agent
8
17
 
9
18
  ```bash
10
19
  npm exec -y premanmcp@latest -- connect
11
20
  ```
12
21
 
13
- Pick your coding agent from the list — Cursor, Claude Code, or Codex — and PreMan
14
- writes that agent's MCP config for you. No hand-edited config anywhere.
22
+ Optional, and separate from starting out. Pick your coding agent from the list — Cursor,
23
+ Claude Code, or Codex — and PreMan writes that agent's MCP config for you. No hand-edited
24
+ config anywhere.
15
25
 
16
26
  Local development form:
17
27
 
@@ -82,10 +92,18 @@ preman github # or connect it in the dashboard
82
92
  preman status # which of those are done
83
93
  ```
84
94
 
85
- `preman onboard` (or `setup`) is the prompted walk through all of it sign in, coding
86
- agent, endpoints, runner, GitHub, AWS, Slack one question per step, `b` to go back, and a
87
- summary at the end. `connect --guide` runs the old full pass inside connect itself:
88
- discovery, a first test, the runner, the desktop app and the integration prompts.
95
+ `preman onboard` (or `setup`, or a bare `preman` in a terminal) is what someone starting
96
+ out runs: create the account and verify the email, then install the PreMan app and open it
97
+ already signed in, then GitHub, AWS, Slack one question per step, `b` to go back, and a
98
+ summary at the end. Connecting a coding agent is no longer part of starting out; run
99
+ `preman connect` when you actually want one wired into an IDE. `connect --guide` still runs
100
+ the full pass inside connect itself: discovery, a first test, the runner, the desktop app
101
+ and the integration prompts.
102
+
103
+ The app opens signed in because the CLI leaves the key it just minted in
104
+ `~/.preman/desktop-session.json`, which the app reads once and deletes. An app too old to
105
+ look for it, or a machine that is not macOS, falls back to signing in on the app's own
106
+ login screen with the account you just created.
89
107
 
90
108
  Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
91
109
  project-local config, `--print` shows the config without writing it, `--no-hook` leaves
package/bin/api_tools.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { readFileSync } from "node:fs";
10
10
 
11
11
  import { callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
12
+ import { printPlayground } from "./desktop.js";
12
13
 
13
14
  export const ENDPOINTS_HELP = `
14
15
  Endpoints:
@@ -144,6 +145,13 @@ export async function endpointsCommand(commandArgs) {
144
145
  if (requests.length) {
145
146
  process.stdout.write(`runnable as: ${requests.join(", ")}\n`);
146
147
  }
148
+ const url = result.ui?.url || result.url;
149
+ printPlayground(url);
150
+ if (result.campaign?.id) {
151
+ process.stdout.write(
152
+ `test campaign ${result.campaign.id}: ${result.campaign.queued || 0} queued, ${result.campaign.skipped || 0} skipped\n`,
153
+ );
154
+ }
147
155
  return undefined;
148
156
  }
149
157
 
package/bin/cli.js CHANGED
@@ -22,7 +22,6 @@ import {
22
22
  CONNECT_HELP,
23
23
  DISPATCH_HELP,
24
24
  connectCommand,
25
- discoverEndpoints,
26
25
  dispatchCommand,
27
26
  resolveAgentForPairing,
28
27
  writeCursorConfig,
@@ -39,7 +38,7 @@ import { STATUS_HELP, statusCommand } from "./status.js";
39
38
  import { HOOK_HELP, hookCommand, scheduleHookRepair } from "./hook.js";
40
39
  import { RUNNER_HELP, runnerCommand } from "./runner.js";
41
40
  import { VERIFY_HELP, verifyCommand } from "./verify.js";
42
- import { DESKTOP_HELP, installDesktopCommand } from "./desktop.js";
41
+ import { DESKTOP_HELP, installDesktopCommand, openDesktopSignedIn } from "./desktop.js";
43
42
  import { ACCOUNT_HELP, doctorCommand, loginBrowser, logoutCommand, watchCommand } from "./account.js";
44
43
  import {
45
44
  CREDENTIALS_FILE,
@@ -57,8 +56,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
57
56
  const ROOT = path.join(__dirname, "..");
58
57
 
59
58
  const args = process.argv.slice(2);
59
+ // A bare `preman` in a terminal is someone starting out, so it runs setup:
60
+ // account first, then the app. Piped or redirected (no TTY) it is an MCP host
61
+ // launching the server, which must stay the behaviour for `start`.
60
62
  const command = args.length === 0 && process.stdin.isTTY
61
- ? "connect"
63
+ ? "onboard"
62
64
  : args[0] === "--help" || args[0] === "-h"
63
65
  ? "help"
64
66
  : args[0] && !args[0].startsWith("-")
@@ -89,8 +91,8 @@ function printHelp() {
89
91
  ["runner start|status|stop", "Run PreMan's queued agent work on this machine"],
90
92
  ["doctor", "Diagnose credentials, backend, target, integrations"],
91
93
  ["install-desktop", "Download and install the PreMan desktop app"],
92
- ["onboard", "Sign in, then agent, endpoints, runner, GitHub, AWS, Slack"],
93
- ["connect [options]", "Pick a coding agent and connect it"],
94
+ ["onboard", "Create an account, install the app signed in, then integrations"],
95
+ ["connect [options]", "Pick a coding agent and connect it (optional)"],
94
96
  ["dispatch [options]", "Let PreMan start agent runs for you"],
95
97
  ["aws | github | slack", "Connect one integration on its own"],
96
98
  ["login [--browser]", "Create/login to PreMan from the terminal"],
@@ -254,14 +256,13 @@ async function main() {
254
256
  } else if (command === "dispatch") {
255
257
  await dispatchCommand(commandArgs);
256
258
  } else if (command === "onboard" || command === "setup") {
257
- // makeArgs/authenticateTerminal/connectCommand are injected rather than
259
+ // makeArgs/authenticateTerminal/the desktop pair are injected rather than
258
260
  // imported there, so integrations.js stays free of a cycle back into the CLI.
259
261
  await onboardCommand(commandArgs, {
260
262
  makeArgs,
261
263
  authenticateTerminal,
262
- connectCommand,
263
- discoverEndpoints,
264
- runnerCommand,
264
+ installDesktop: installDesktopCommand,
265
+ openDesktopSignedIn,
265
266
  });
266
267
  } else if (command === "aws") {
267
268
  await awsCommand(makeArgs(commandArgs));
@@ -12,7 +12,7 @@ import path from "node:path";
12
12
  import { spawn } from "node:child_process";
13
13
  import { existsSync } from "node:fs";
14
14
  import { callTool as callPremanTool, printTestSummary } from "../api_tools.js";
15
- import { installDesktopCommand } from "../desktop.js";
15
+ import { installDesktopCommand, printPlayground } from "../desktop.js";
16
16
  import { hookStatus, installHook } from "../hook.js";
17
17
  import { MARK, awsCommand, githubCommand, slackCommand } from "../integrations.js";
18
18
  import { confirmRunnerOnline, pairingIsLive, readRunnerState, registerRunner, runnerIsAlive, startBackground } from "../runner.js";
@@ -198,6 +198,18 @@ export async function discoverEndpoints(
198
198
  process.stdout.write(
199
199
  `${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
200
200
  );
201
+ try {
202
+ const listed = await callPremanTool(args, "get_endpoints", {
203
+ limit: 100,
204
+ include_schemas: true,
205
+ });
206
+ const share = await callPremanTool(args, "share_endpoints_with_ui", {
207
+ endpoints: listed.endpoints || [],
208
+ });
209
+ printPlayground(share.ui?.url || share.url);
210
+ } catch {
211
+ // Mapping succeeded; a missing Playground link must not look like discovery failed.
212
+ }
201
213
  return after;
202
214
  } catch (error) {
203
215
  process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
package/bin/desktop.js CHANGED
@@ -11,13 +11,13 @@
11
11
  * has to do.
12
12
  */
13
13
 
14
- import { spawnSync } from "node:child_process";
14
+ import { spawn, spawnSync } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
16
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
 
20
- import { cliInvocation, makeArgs } from "./shared.js";
20
+ import { CREDENTIALS_DIR, cliInvocation, makeArgs } from "./shared.js";
21
21
 
22
22
  export const DESKTOP_HELP = `
23
23
  Install-desktop options:
@@ -29,9 +29,88 @@ Install-desktop options:
29
29
 
30
30
  const RELEASES_BASE = "https://github.com/PreMan-Inc/PreMan-Desktop/releases";
31
31
  const RELEASE_API = "https://api.github.com/repos/PreMan-Inc/PreMan-Desktop/releases/latest";
32
- const APP_NAME = "PreMan.app";
32
+ export const APP_NAME = "PreMan.app";
33
33
  const DOWNLOAD_TIMEOUT_MS = 300_000;
34
34
 
35
+ /**
36
+ * Where the desktop app looks for a session the CLI just established.
37
+ *
38
+ * The key travels through a file rather than the `preman://` URL because a URL
39
+ * is handed to LaunchServices, which records it; the file sits in the directory
40
+ * that already holds the same key, with the same 0600 mode, so this adds no new
41
+ * class of exposure. The app deletes it on read, which is what makes it
42
+ * single-use -- the CLI cannot know whether an installed app is new enough to
43
+ * consume it, so expiry is enforced on the reading side.
44
+ */
45
+ export const DESKTOP_SESSION_FILE = path.join(CREDENTIALS_DIR, "desktop-session.json");
46
+
47
+ export function installedAppPath(destination = "/Applications") {
48
+ return path.join(destination, APP_NAME);
49
+ }
50
+
51
+ export function desktopAppInstalled(destination = "/Applications") {
52
+ return process.platform === "darwin" && existsSync(installedAppPath(destination));
53
+ }
54
+
55
+ /**
56
+ * Hand the account the CLI just signed in to over to the desktop app.
57
+ *
58
+ * Returns what actually happened rather than a boolean, because the caller has
59
+ * something different to say in each case: an app that is not installed is not
60
+ * a failure, it is the fallback where the customer signs in themselves.
61
+ */
62
+ export function writeDesktopSession(creds) {
63
+ const apiKey = String(creds?.api_key || "").trim();
64
+ if (!apiKey.startsWith("pm_live_")) return { state: "no-key" };
65
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
66
+ writeFileSync(
67
+ DESKTOP_SESSION_FILE,
68
+ `${JSON.stringify({ api_key: apiKey, user_email: creds?.user_email ?? null, created_at: new Date().toISOString() }, null, 2)}\n`,
69
+ { mode: 0o600 }
70
+ );
71
+ return { state: "written", path: DESKTOP_SESSION_FILE };
72
+ }
73
+
74
+ /**
75
+ * Install-or-not aside, get the customer into the app signed in.
76
+ *
77
+ * The session is written before the app is launched so a cold start finds it on
78
+ * first read, rather than racing a window that is already loading.
79
+ */
80
+ export function openDesktopSignedIn(creds, { destination = "/Applications" } = {}) {
81
+ if (!desktopAppInstalled(destination)) {
82
+ return { state: "not-installed" };
83
+ }
84
+ const handoff = writeDesktopSession(creds);
85
+ spawn("open", ["-a", "PreMan"], { stdio: "ignore", detached: true }).unref();
86
+ return { state: handoff.state === "written" ? "opened-signed-in" : "opened", handoff: handoff.state };
87
+ }
88
+
89
+ /**
90
+ * Open a Playground session URL in PreMan.app on macOS when it is installed.
91
+ * Windows/Linux (and Mac without the app) get the website URL printed instead.
92
+ */
93
+ export function openPlayground(url) {
94
+ const target = String(url || "").trim();
95
+ if (!target) return "none";
96
+ const app = path.join("/Applications", APP_NAME);
97
+ if (process.platform === "darwin" && existsSync(app)) {
98
+ spawn("open", ["-a", "PreMan", target], { stdio: "ignore", detached: true }).unref();
99
+ return "desktop";
100
+ }
101
+ return "web";
102
+ }
103
+
104
+ export function printPlayground(url) {
105
+ const target = String(url || "").trim();
106
+ if (!target) return;
107
+ const where = openPlayground(target);
108
+ process.stdout.write(`Watch it: ${target}\n`);
109
+ if (where === "desktop") {
110
+ process.stdout.write("Opened in PreMan desktop\n");
111
+ }
112
+ }
113
+
35
114
  export function dmgUrl(arch) {
36
115
  return `${RELEASES_BASE}/latest/download/PreMan-mac-${arch}.dmg`;
37
116
  }
@@ -200,8 +279,8 @@ export async function installDesktopCommand(commandArgs = []) {
200
279
  chmodSync(target, 0o755);
201
280
 
202
281
  process.stdout.write(
203
- `\nInstalled ${target}\n\nNext: open PreMan and it will pair with this account.\n` +
204
- `Then run \`${cliInvocation()} status\` to see endpoint health.\n`
282
+ `\nInstalled ${target}\n\nNext: open PreMan and sign in with the account you use here.\n` +
283
+ `\`${cliInvocation()} onboard\` installs it and opens it already signed in.\n`
205
284
  );
206
285
  return { state: "installed", version, arch, path: target };
207
286
  } finally {
@@ -337,17 +337,6 @@ export async function slackCommand(args) {
337
337
  // The guided run
338
338
  // ---------------------------------------------------------------------------
339
339
 
340
- /**
341
- * Why a step that needs a connected agent cannot run on its own.
342
- *
343
- * Both of these drive the agent `connect` just linked, so skipping that step
344
- * leaves them without one -- which is a thing to say plainly, with the command
345
- * that does it later, rather than a stack trace about a missing id.
346
- */
347
- function needsAgent(command) {
348
- return `no coding agent connected yet -- run '${cliInvocation()} connect', then '${cliInvocation()} ${command}'`;
349
- }
350
-
351
340
  /** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
352
341
  async function askStep(question, { assumeYes, canGoBack }) {
353
342
  if (assumeYes) return "yes";
@@ -368,7 +357,7 @@ async function askStep(question, { assumeYes, canGoBack }) {
368
357
  */
369
358
  export async function onboardCommand(
370
359
  commandArgs,
371
- { makeArgs, authenticateTerminal, connectCommand, discoverEndpoints, runnerCommand }
360
+ { makeArgs, authenticateTerminal, installDesktop, openDesktopSignedIn }
372
361
  ) {
373
362
  const args = makeArgs(commandArgs);
374
363
  const assumeYes = args.has("--yes");
@@ -378,32 +367,30 @@ export async function onboardCommand(
378
367
  const creds = await authenticateTerminal(args);
379
368
  connected(`Signed in as ${creds.user_email || "your account"}.`);
380
369
 
381
- // Which agent the endpoints and runner steps drive. `connect` decided it, by
382
- // detection or by asking, and this is the answer rather than a second prompt.
383
- let linked = null;
384
-
385
370
  const steps = [
386
371
  {
387
- name: "coding agent",
388
- question: "Connect your coding agent?",
372
+ name: "PreMan app",
373
+ question: "Install the PreMan app and open it signed in?",
389
374
  run: async () => {
390
- linked = (await connectCommand([...commandArgs, "--skip-login"])) || null;
391
- },
392
- },
393
- {
394
- name: "endpoints",
395
- question: "Map this repository's endpoints?",
396
- run: () => {
397
- if (!linked?.agent) throw new Error(needsAgent("endpoints discover"));
398
- return discoverEndpoints(args, linked.agent, linked.serverName);
399
- },
400
- },
401
- {
402
- name: "runner",
403
- question: "Let PreMan run your agent here when it finds something to fix?",
404
- run: () => {
405
- if (!linked?.agent) throw new Error(needsAgent("runner start --background"));
406
- return runnerCommand(["start", "--background", "--agent", linked.agent.id]);
375
+ const installed = await installDesktop([...commandArgs]);
376
+ if (installed?.state === "unsupported") {
377
+ // Not a failure: the download link has already been printed, and the
378
+ // account this step exists to create is finished either way.
379
+ process.stdout.write("Sign in there with the account you just used.\n");
380
+ return;
381
+ }
382
+ const opened = openDesktopSignedIn(creds);
383
+ if (opened.state === "opened-signed-in") {
384
+ process.stdout.write("Opened PreMan, signed in as this account.\n");
385
+ } else if (opened.state === "not-installed") {
386
+ process.stdout.write(
387
+ "PreMan is not in /Applications yet \u2014 open it once installed and sign in.\n"
388
+ );
389
+ } else {
390
+ // The app is there but the session could not be handed over, so say so
391
+ // rather than let the customer wonder why they are at a login screen.
392
+ process.stdout.write("Opened PreMan \u2014 sign in with the account you just used.\n");
393
+ }
407
394
  },
408
395
  },
409
396
  { name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
@@ -464,8 +451,9 @@ export async function onboardCommand(
464
451
 
465
452
  export const INTEGRATIONS_HELP = `
466
453
  Setup options:
467
- preman onboard Sign in, then agent, endpoints, runner, GitHub,
468
- AWS and Slack, one prompt per step
454
+ preman onboard Create or sign in to an account, install the
455
+ PreMan app signed in, then GitHub, AWS and Slack,
456
+ one prompt per step
469
457
  preman aws Connect an AWS account and stream a log group
470
458
  preman github Install the PreMan GitHub App
471
459
  preman slack Add PreMan to a Slack workspace
package/dist/server.js CHANGED
@@ -381,7 +381,8 @@ export function createServer() {
381
381
  "",
382
382
  "## Tool taxonomy",
383
383
  "- **Read tools** (no side effects): get_endpoints, get_coverage, detect_drift, preman_status, list_collections, get_collection, list_runs",
384
- "- **Action tools** (execute tests / mutate state): test_api, generate_tests, generate_endpoint_tests, run_stress_test, import_collection, test_endpoint_by_id, register_discovered_endpoints, run_tests, delete_collection",
384
+ "- **Action tools** (execute tests / mutate state): test_api, generate_tests, generate_endpoint_tests, run_stress_test, start_test_campaign, import_collection, test_endpoint_by_id, register_discovered_endpoints, run_tests, delete_collection",
385
+ "- **Create-then-delete contracts**: propose_lifecycle_contracts (propose a POST/DELETE pairing), approve_lifecycle_contracts (user decision — ask first)",
385
386
  "- **MCP conversion tools**: discover_endpoints_from_codebase, verify_endpoints_live, mcp_preview (returns inline two-pane panel), mcp_deploy, mcp_list_deployed, mcp_mint_consumer_token, mcp_revoke_consumer_token",
386
387
  "- **Auth (API key / PreMan)**: preman_create_api_key (JWT -> saved pm_live_ key), preman_login, preman_login_complete, preman_logout",
387
388
  "- **Auth (app JWT — email/OTP/password on your API)**: user_auth_start_signup, user_auth_signup, user_auth_verify_otp, user_auth_login, user_auth_needs_password, user_auth_resend_otp, user_auth_forgot_password, user_auth_set_password, user_auth_me, user_auth_change_password, user_auth_delete_account (HTTP to PREMAN_BACKEND /auth/*; no pm_live_ key required)",
@@ -396,7 +397,8 @@ export function createServer() {
396
397
  "- 'migrate / move / switch from Postman', 'bring my Postman workspace over': migrate_from_postman — it imports AND creates scheduled suites, environments and alerts in one call. Prefer it over import_collection whenever the user frames this as leaving Postman.",
397
398
  "- 'generate tests for endpoint X' / 'write unit tests for X and run them': generate_endpoint_tests (set include_code=true and write the returned code_artifacts to disk when the user wants test files).",
398
399
  "- 'stress / load test X': run_stress_test. Read-only unless the user explicitly authorises writes — never set allow_writes yourself.",
399
- "- After discover_endpoints_from_codebase, call register_discovered_endpoints with the JSON array it asked for, then verify_endpoints_live to confirm they respond.",
400
+ "- After discover_endpoints_from_codebase, call register_discovered_endpoints with the JSON array it asked for. That streams endpoints to the Playground and starts a read-safe test campaign. Then verify_endpoints_live if you need a live probe.",
401
+ "- 'why aren't my POSTs tested?' / 'test the creates too': POSTs are skipped because nothing undoes them. Read the code, pair each POST with the DELETE that reverses it via propose_lifecycle_contracts, then ask the user to approve. Approve once and every campaign creates and deletes a real record. Never call approve_lifecycle_contracts without the user saying yes.",
400
402
  "- App-auth flows (signup, OTP, login, change_password) live under the user_auth_* tools and do not need an pm_live_ key.",
401
403
  "- PREMAN_BACKEND / backend_url is the PreMan control plane. It is NOT the target API upstream for a generated MCP unless the user's own API is PreMan. Prefer base_url from verify_endpoints_live results.",
402
404
  "",
@@ -541,7 +543,7 @@ export function createServer() {
541
543
  }
542
544
  });
543
545
  // ── register_discovered_endpoints ─────────────────────────────────
544
- server.tool("register_discovered_endpoints", "Save discovered endpoints into PreMan and set them up as runnable requests. Use after discover_endpoints_from_codebase with the JSON array its brief asked you to build; then call verify_endpoints_live to confirm they respond.", {
546
+ server.tool("register_discovered_endpoints", "Save discovered endpoints into PreMan, stream them to the Playground, and start a read-safe test campaign. Use after discover_endpoints_from_codebase. Mutating endpoints stay skipped until start_test_campaign(allow_writes=true).", {
545
547
  endpoints: z.array(z.record(z.any())).optional().describe("Discovery-shaped endpoint objects (method, path_template, schemas, confidence, …); max 100"),
546
548
  endpoint_ids: z.array(z.string()).optional().describe("Alternatively, existing registry ids to set up as runnable requests"),
547
549
  project_id: z.string().optional().describe("Scope registration to a project"),
@@ -550,12 +552,64 @@ export function createServer() {
550
552
  }, async (args) => {
551
553
  try {
552
554
  const result = await callBackend("register_discovered_endpoints", args);
553
- return withFrontendUrl(result, "/endpoints");
555
+ return withFrontendUrl(result, "/try");
554
556
  }
555
557
  catch (e) {
556
558
  return toolError(e.message, inferErrorCode(e.message), {
557
559
  next_actions: ["Run discover_endpoints_from_codebase first and pass its endpoints array here."],
558
- related_tools: ["discover_endpoints_from_codebase", "verify_endpoints_live"],
560
+ related_tools: ["discover_endpoints_from_codebase", "start_test_campaign", "verify_endpoints_live"],
561
+ });
562
+ }
563
+ });
564
+ server.tool("start_test_campaign", "Fan out tests across registered endpoints: functional scenarios, GET-only stress, and security-lite negatives (missing auth / empty body). Read-only by default. Not a scanner or exploit runner.", {
565
+ endpoint_ids: z.array(z.string()).optional().describe("Registry ids from register_discovered_endpoints"),
566
+ allow_writes: z.boolean().optional().default(false).describe("Permit POST/PUT/PATCH functional jobs; DELETE stays skipped"),
567
+ }, async (args) => {
568
+ try {
569
+ const result = await callBackend("start_test_campaign", args);
570
+ return withFrontendUrl(result, "/try");
571
+ }
572
+ catch (e) {
573
+ return toolError(e.message, inferErrorCode(e.message), {
574
+ next_actions: ["Call register_discovered_endpoints first, then pass its endpoint_ids."],
575
+ related_tools: ["register_discovered_endpoints", "get_endpoints"],
576
+ });
577
+ }
578
+ });
579
+ // ── create-then-delete contracts ──────────────────────────────────
580
+ server.tool("propose_lifecycle_contracts", "Propose that a POST be tested for real by deleting what it creates. You read the customer's routes and response models, so you know which DELETE undoes which POST and where the new id appears. PreMan re-checks eligibility itself: billing-, auth- and notification-shaped creates are always rejected because a DELETE cannot claw back money or unsend an email. Proposals do nothing until the user approves them.", {
581
+ contracts: z
582
+ .array(z.object({
583
+ create_endpoint_id: z.string().describe("Registry id of the POST"),
584
+ cleanup_endpoint_id: z.string().describe("Registry id of the DELETE that undoes it"),
585
+ id_source: z.string().optional().describe("Where the new id sits in the create response, e.g. 'json.id' or 'json.data.id'"),
586
+ evidence: z.string().optional().describe("Why this pairing is correct (route/model you read)"),
587
+ }))
588
+ .describe("Pairings to propose; max 100"),
589
+ }, async (args) => {
590
+ try {
591
+ const result = await callBackend("propose_lifecycle_contracts", args);
592
+ return withFrontendUrl(result, "/try");
593
+ }
594
+ catch (e) {
595
+ return toolError(e.message, inferErrorCode(e.message), {
596
+ next_actions: ["Register the endpoints first so both ids exist, then propose the pairing."],
597
+ related_tools: ["register_discovered_endpoints", "approve_lifecycle_contracts"],
598
+ });
599
+ }
600
+ });
601
+ server.tool("approve_lifecycle_contracts", "Approve (or reject) create-then-delete contracts. ASK THE USER FIRST — never approve on their behalf. An approved create runs for real on every campaign from then on, teardown included; the teardown is scoped to the id that run just created and can never delete anything else.", {
602
+ contract_ids: z.array(z.string()).describe("Ids from propose_lifecycle_contracts or register_discovered_endpoints' lifecycle_proposals"),
603
+ approve: z.boolean().optional().default(true).describe("False records a rejection so the pairing stops being re-proposed"),
604
+ }, async (args) => {
605
+ try {
606
+ const result = await callBackend("approve_lifecycle_contracts", args);
607
+ return withFrontendUrl(result, "/try");
608
+ }
609
+ catch (e) {
610
+ return toolError(e.message, inferErrorCode(e.message), {
611
+ next_actions: ["Confirm with the user which POST endpoints they want exercised for real, then retry."],
612
+ related_tools: ["propose_lifecycle_contracts", "start_test_campaign"],
559
613
  });
560
614
  }
561
615
  });
@@ -1278,6 +1332,90 @@ export function createServer() {
1278
1332
  });
1279
1333
  }
1280
1334
  });
1335
+ // ── Collections test generation ────────────────────────────────────
1336
+ // The dashboard's Generate Tests / Review flagged / Setup fixtures /
1337
+ // Enrich with agent buttons, so an agent can run them without clicking.
1338
+ server.tool("generate_saved_tests", "Generate scheduled test suites for every endpoint already saved in a PreMan workspace. This is the dashboard's Collections 'Generate Tests' button: it clones the workspace's connected GitHub repository, matches the discovered routes onto the saved endpoints by method and path, and writes heuristic cases onto the ones it matches. No model is involved, so nothing invents a body for a DELETE. Write, destructive, low-confidence, and path-parameter routes come back flagged and disabled — clear them with review_generated_tests. This is not generate_tests, which writes cases for a single endpoint you describe.", {
1339
+ action: z
1340
+ .enum(["generate", "status"])
1341
+ .optional()
1342
+ .describe("generate (default) runs the scan; status is read-only counts"),
1343
+ integration_id: z
1344
+ .string()
1345
+ .optional()
1346
+ .describe("Connected repo to scan. Omit to use the one with the most synced endpoints."),
1347
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1348
+ }, async (args) => {
1349
+ try {
1350
+ const result = await callBackend("generate_saved_tests", args);
1351
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1352
+ }
1353
+ catch (e) {
1354
+ return toolError(e.message, inferErrorCode(e.message), {
1355
+ next_actions: [
1356
+ "Connect a GitHub repository to this workspace in Settings → Integrations, then retry.",
1357
+ ],
1358
+ related_tools: ["review_generated_tests", "setup_test_fixtures", "enrich_generated_tests"],
1359
+ });
1360
+ }
1361
+ });
1362
+ server.tool("review_generated_tests", "List and approve the generated test suites PreMan flagged for human review. This is the dashboard's 'Review flagged' button. generate_saved_tests deliberately leaves mutating and ambiguous routes disabled; this is how a reviewed suite gets turned on. Approving enables the suite's read-only schedule and does not raise the unattended write policy, so a DELETE stays gated at run time. List first and show the user each suite and its reason; only use approve_all when they asked to clear the whole queue.", {
1363
+ action: z
1364
+ .enum(["list", "approve", "approve_all"])
1365
+ .optional()
1366
+ .describe("list (default) | approve one request_id | approve_all (max 50)"),
1367
+ request_id: z.string().optional().describe("Saved request to approve; required for approve"),
1368
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1369
+ }, async (args) => {
1370
+ try {
1371
+ const result = await callBackend("review_generated_tests", args);
1372
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1373
+ }
1374
+ catch (e) {
1375
+ return toolError(e.message, inferErrorCode(e.message), {
1376
+ next_actions: ["Call review_generated_tests with action='list' to see the queue."],
1377
+ related_tools: ["generate_saved_tests", "enrich_generated_tests"],
1378
+ });
1379
+ }
1380
+ });
1381
+ server.tool("setup_test_fixtures", "Fill the {id} path parameters in saved PreMan endpoints with real record ids. This is the dashboard's 'Setup fixtures' button: a happy-path test for GET /users/{id} needs an id that exists, so PreMan calls the sibling list route read-only, harvests ids from the response, and falls back to the endpoint's schema example. It never mints a fake UUID that would only 404 and never fires a write. Harvested values are stored in workspace settings and returned as a .env snippet. This does not approve anything.", {
1382
+ action: z
1383
+ .enum(["setup", "list"])
1384
+ .optional()
1385
+ .describe("setup (default) harvests ids; list reports what is still missing"),
1386
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1387
+ }, async (args) => {
1388
+ try {
1389
+ const result = await callBackend("setup_test_fixtures", args);
1390
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1391
+ }
1392
+ catch (e) {
1393
+ return toolError(e.message, inferErrorCode(e.message), {
1394
+ next_actions: ["Save at least one endpoint with a {id} path parameter, then retry."],
1395
+ related_tools: ["generate_saved_tests", "review_generated_tests"],
1396
+ });
1397
+ }
1398
+ });
1399
+ server.tool("enrich_generated_tests", "Add model-written test cases on top of PreMan's heuristic suites. This is the dashboard's 'Enrich with agent' button. generate_saved_tests writes deterministic cases only; this pass asks a model for a few extra edge cases per suite. It touches review-cleared suites only, skips destructive and billing-sensitive requests unless workspace policy already allows that risk, skips suites already enriched, and stops after 40 suites per call so a large collection cannot fan out into hundreds of model calls. Leftovers come back as remaining.", {
1400
+ action: z
1401
+ .enum(["enrich", "list"])
1402
+ .optional()
1403
+ .describe("enrich (default) runs the pass; list returns the eligible suites"),
1404
+ workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
1405
+ }, async (args) => {
1406
+ try {
1407
+ const result = await callBackend("enrich_generated_tests", args);
1408
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1409
+ }
1410
+ catch (e) {
1411
+ return toolError(e.message, inferErrorCode(e.message), {
1412
+ next_actions: [
1413
+ "Run generate_saved_tests first, then clear the review queue with review_generated_tests.",
1414
+ ],
1415
+ related_tools: ["generate_saved_tests", "review_generated_tests"],
1416
+ });
1417
+ }
1418
+ });
1281
1419
  server.tool("share_endpoints_with_ui", "Push discovered or verified endpoints into the PreMan Playground so the user can see them, test them, and convert selected endpoints into hosted MCP tools. Use this after verify_endpoints_live or when the user explicitly asks to stream endpoints to the UI.", {
1282
1420
  endpoints: z.array(z.any()).describe("Endpoints to push. Each item should include method plus path/path_template/url; include schemas when available."),
1283
1421
  upstream_base_url: z.string().optional().describe("Default upstream base URL for testing and MCP generation, e.g. http://127.0.0.1:8000 or https://api.example.com"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.10.7",
3
+ "version": "0.12.0",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
16
16
  "test": "npm run build && npm run test:connect && npm run test:node",
17
17
  "test:connect": "node scripts/smoke-connect.mjs",
18
- "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs"
18
+ "test:node": "node --test --test-timeout=90000 scripts/smoke-account.mjs scripts/smoke-cli-entrypoint.mjs scripts/smoke-launcher-config.mjs scripts/smoke-runner.mjs scripts/smoke-repo-config.mjs scripts/smoke-onboard.mjs scripts/smoke-local-detect.mjs scripts/smoke-prepush-hook.mjs scripts/smoke-cli-identity.mjs scripts/smoke-runner-heartbeat.mjs scripts/smoke-verify-prepush.mjs scripts/smoke-push-diff.mjs scripts/smoke-progress-reporter.mjs scripts/smoke-verify-plan.mjs scripts/smoke-install-desktop.mjs scripts/smoke-desktop-session.mjs scripts/smoke-api-tools.mjs scripts/smoke-tests-workbench.mjs scripts/smoke-bin-scope.mjs"
19
19
  },
20
20
  "dependencies": {
21
21
  "@modelcontextprotocol/ext-apps": "^0.1.0",