premanmcp 0.10.6 → 0.11.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 +11 -5
- package/bin/account.js +19 -4
- package/bin/api_tools.js +8 -0
- package/bin/cli.js +8 -2
- package/bin/connect/agents.js +198 -0
- package/bin/connect/checkin.js +503 -0
- package/bin/connect/configs.js +276 -0
- package/bin/connect/dispatch.js +131 -0
- package/bin/connect/errors.js +16 -0
- package/bin/connect/guide.js +535 -0
- package/bin/connect/pairing.js +94 -0
- package/bin/connect.js +101 -1554
- package/bin/desktop.js +28 -3
- package/bin/detect.js +42 -3
- package/bin/hook.js +10 -1
- package/bin/hosted.js +4 -4
- package/bin/progress.js +2 -5
- package/bin/runner.js +3 -30
- package/bin/shared.js +26 -1
- package/bin/status.js +1 -6
- package/bin/tests.js +341 -0
- package/bin/verify.js +82 -54
- package/dist/server.js +112 -5
- package/package.json +2 -2
package/bin/verify.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* unless the repository has explicitly opted into blocking.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
21
22
|
import { readFileSync } from "node:fs";
|
|
22
23
|
import os from "node:os";
|
|
23
24
|
import path from "node:path";
|
|
@@ -25,7 +26,7 @@ import path from "node:path";
|
|
|
25
26
|
import { changedFilesForPush, readChangedFiles, readPushRefsFromStdin, repoRoot } from "./changed.js";
|
|
26
27
|
import { resolveLocalTarget } from "./detect.js";
|
|
27
28
|
import { createReporter } from "./progress.js";
|
|
28
|
-
import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
|
|
29
|
+
import { backendUrl, callBackendJson, cliInvocation, makeArgs, nowMs, resolveApiKey } from "./shared.js";
|
|
29
30
|
|
|
30
31
|
export const VERIFY_HELP = `
|
|
31
32
|
Verify options:
|
|
@@ -42,6 +43,10 @@ Verify options:
|
|
|
42
43
|
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
43
44
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
44
45
|
const CONCURRENCY = 6;
|
|
46
|
+
const INVENTORY_PAGE_SIZE = 200;
|
|
47
|
+
const INVENTORY_MAX_ENDPOINTS = 2000;
|
|
48
|
+
// POST /projects/{id}/api-runs rejects more than this (schemas.pulse.MAX_UPLOAD_RUNS).
|
|
49
|
+
const UPLOAD_BATCH_SIZE = 200;
|
|
45
50
|
const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
46
51
|
// Never fired automatically even against loopback: a destructive verb that
|
|
47
52
|
// happens to point at a real datastore is not made safe by the port being local.
|
|
@@ -55,10 +60,6 @@ const REPO_CONFIG = ".preman.json";
|
|
|
55
60
|
// push — the exact failure the advisory guarantee exists to prevent.
|
|
56
61
|
export const BLOCK_EXIT_CODE = 97;
|
|
57
62
|
|
|
58
|
-
function nowMs() {
|
|
59
|
-
return Number(process.hrtime.bigint() / 1000000n);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
63
|
function originLabel() {
|
|
63
64
|
const raw = `${os.hostname() || "local"}`.toLowerCase();
|
|
64
65
|
const cleaned = raw.replace(/[^a-z0-9._-]/g, "-").replace(/^[^a-z0-9]+/, "");
|
|
@@ -103,7 +104,7 @@ async function runCase(baseUrl, endpoint, { batchId }) {
|
|
|
103
104
|
client_run_id: `${batchId}:${method}:${endpoint.path_template || endpoint.path}`,
|
|
104
105
|
method,
|
|
105
106
|
url: target,
|
|
106
|
-
status: "
|
|
107
|
+
status: "error",
|
|
107
108
|
http_status: null,
|
|
108
109
|
latency_ms: nowMs() - t0,
|
|
109
110
|
error_message: aborted ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : String(error?.message || error),
|
|
@@ -201,7 +202,7 @@ async function runGeneratedCase(testCase, { batchId, endpoint }) {
|
|
|
201
202
|
endpoint: `${endpoint.method} ${endpoint.path}`,
|
|
202
203
|
method,
|
|
203
204
|
url: testCase.url,
|
|
204
|
-
status: "
|
|
205
|
+
status: "error",
|
|
205
206
|
http_status: null,
|
|
206
207
|
latency_ms: nowMs() - t0,
|
|
207
208
|
error_message: aborted ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : String(error?.message || error),
|
|
@@ -239,19 +240,66 @@ async function safeCall(args, method, routePath, options) {
|
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
async function fetchInventory(args, token) {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
243
|
+
const endpoints = [];
|
|
244
|
+
for (let offset = 0; offset < INVENTORY_MAX_ENDPOINTS; offset += INVENTORY_PAGE_SIZE) {
|
|
245
|
+
const result = await safeCall(args, "POST", "/mcp/call-tool", {
|
|
246
|
+
token,
|
|
247
|
+
json: {
|
|
248
|
+
tool: "get_endpoints",
|
|
249
|
+
arguments: { format: "json", limit: INVENTORY_PAGE_SIZE, offset },
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
if (!result.ok) {
|
|
253
|
+
return {
|
|
254
|
+
endpoints,
|
|
255
|
+
error: result.detail || `status ${result.status_code}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const payload = result.result ?? result.data ?? result;
|
|
260
|
+
const page =
|
|
261
|
+
payload?.endpoints ||
|
|
262
|
+
payload?.result?.endpoints ||
|
|
263
|
+
(Array.isArray(payload) ? payload : []) ||
|
|
264
|
+
[];
|
|
265
|
+
const rows = Array.isArray(page) ? page : [];
|
|
266
|
+
endpoints.push(...rows);
|
|
267
|
+
const pagination = payload?.pagination || payload?.result?.pagination;
|
|
268
|
+
if (!pagination?.has_more || rows.length === 0) break;
|
|
269
|
+
if (offset + INVENTORY_PAGE_SIZE >= INVENTORY_MAX_ENDPOINTS) {
|
|
270
|
+
return {
|
|
271
|
+
endpoints,
|
|
272
|
+
error: `endpoint inventory exceeds the ${INVENTORY_MAX_ENDPOINTS}-endpoint verification limit`,
|
|
273
|
+
incomplete: true,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { endpoints };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function uploadRuns(args, { token, projectId, batchId, runs }) {
|
|
281
|
+
if (!projectId || !runs.length) return { inserted: 0, error: null };
|
|
282
|
+
let inserted = 0;
|
|
283
|
+
for (let offset = 0; offset < runs.length; offset += UPLOAD_BATCH_SIZE) {
|
|
284
|
+
const chunk = runs.slice(offset, offset + UPLOAD_BATCH_SIZE);
|
|
285
|
+
const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
|
|
286
|
+
token,
|
|
287
|
+
json: {
|
|
288
|
+
origin: "local",
|
|
289
|
+
origin_label: originLabel(),
|
|
290
|
+
source: "ci",
|
|
291
|
+
batch_id: batchId,
|
|
292
|
+
batch_label: "pre-push verify",
|
|
293
|
+
runs: chunk,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
if (!upload.ok) {
|
|
297
|
+
const detail = upload.detail || (upload.status_code ? `HTTP ${upload.status_code}` : "request failed");
|
|
298
|
+
return { inserted, error: `verification results were not uploaded: ${detail}` };
|
|
299
|
+
}
|
|
300
|
+
inserted += Number(upload.inserted || 0);
|
|
301
|
+
}
|
|
302
|
+
return { inserted, error: null };
|
|
255
303
|
}
|
|
256
304
|
|
|
257
305
|
function selectMethods(args) {
|
|
@@ -390,7 +438,7 @@ async function runPlan(
|
|
|
390
438
|
stream: asJson ? process.stderr : process.stdout,
|
|
391
439
|
live: asJson ? false : undefined,
|
|
392
440
|
});
|
|
393
|
-
const batchId =
|
|
441
|
+
const batchId = randomUUID();
|
|
394
442
|
const changes = plan.changes || [];
|
|
395
443
|
|
|
396
444
|
if (!asJson) {
|
|
@@ -449,21 +497,8 @@ async function runPlan(
|
|
|
449
497
|
);
|
|
450
498
|
const removed = changes.filter((c) => c.change_type === "removed");
|
|
451
499
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
|
|
455
|
-
token,
|
|
456
|
-
json: {
|
|
457
|
-
origin: "local",
|
|
458
|
-
origin_label: originLabel(),
|
|
459
|
-
source: "ci",
|
|
460
|
-
batch_id: batchId,
|
|
461
|
-
batch_label: "pre-push verify",
|
|
462
|
-
runs: completed,
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
if (upload.ok) uploaded = Number(upload.inserted || 0);
|
|
466
|
-
}
|
|
500
|
+
const upload = await uploadRuns(args, { token, projectId, batchId, runs: completed });
|
|
501
|
+
const uploaded = upload.inserted;
|
|
467
502
|
|
|
468
503
|
const gate = blockOnBreaking(args, root);
|
|
469
504
|
const shouldBlock = gate.blocking && (breaking.length > 0 || removed.length > 0);
|
|
@@ -479,6 +514,7 @@ async function runPlan(
|
|
|
479
514
|
}
|
|
480
515
|
if (withheld) reporter.line(`[preman] ${withheld} mutating case${withheld === 1 ? "" : "s"} withheld`);
|
|
481
516
|
if (uploaded) reporter.line(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}`);
|
|
517
|
+
if (upload.error) reporter.line(`[preman] ${upload.error}`);
|
|
482
518
|
if (shouldBlock) {
|
|
483
519
|
reporter.line(`[preman] blocking this push (${gate.from}). Set PREMAN_SKIP_HOOK=1 to override.`);
|
|
484
520
|
} else if (breaking.length || removed.length) {
|
|
@@ -518,6 +554,7 @@ async function runPlan(
|
|
|
518
554
|
error: r.error_message || null,
|
|
519
555
|
})),
|
|
520
556
|
uploaded,
|
|
557
|
+
upload_error: upload.error,
|
|
521
558
|
// Advisory unless the repository opted in. `advisory` only flattens an
|
|
522
559
|
// unexpected throw; a deliberate block has to survive it.
|
|
523
560
|
exitCode: shouldBlock ? BLOCK_EXIT_CODE : 0,
|
|
@@ -589,7 +626,8 @@ async function runVerify(args) {
|
|
|
589
626
|
}
|
|
590
627
|
|
|
591
628
|
const extraPorts = args.value("--port", "") ? [args.value("--port", "")] : [];
|
|
592
|
-
const
|
|
629
|
+
const confirmationEndpoints = planEndpoints.length ? planEndpoints : endpoints;
|
|
630
|
+
const resolved = await resolveLocalTarget(confirmationEndpoints, {
|
|
593
631
|
extraPorts,
|
|
594
632
|
timeoutMs: 2000,
|
|
595
633
|
});
|
|
@@ -605,6 +643,7 @@ async function runVerify(args) {
|
|
|
605
643
|
|
|
606
644
|
const target = resolved.target;
|
|
607
645
|
const policy = selectMethods(args);
|
|
646
|
+
const targetInventory = resolved.inventory || endpoints;
|
|
608
647
|
|
|
609
648
|
if (planned.plan?.selected?.length) {
|
|
610
649
|
return finish(
|
|
@@ -623,7 +662,7 @@ async function runVerify(args) {
|
|
|
623
662
|
);
|
|
624
663
|
}
|
|
625
664
|
|
|
626
|
-
const selected =
|
|
665
|
+
const selected = targetInventory.filter((ep) => {
|
|
627
666
|
const method = String(ep.method || "GET").toUpperCase();
|
|
628
667
|
if (READ_METHODS.has(method)) return true;
|
|
629
668
|
if (DESTRUCTIVE_METHODS.has(method)) return false;
|
|
@@ -638,7 +677,7 @@ async function runVerify(args) {
|
|
|
638
677
|
);
|
|
639
678
|
}
|
|
640
679
|
|
|
641
|
-
const batchId =
|
|
680
|
+
const batchId = randomUUID();
|
|
642
681
|
const runs = await runPool(
|
|
643
682
|
selected,
|
|
644
683
|
(endpoint) =>
|
|
@@ -650,21 +689,8 @@ async function runVerify(args) {
|
|
|
650
689
|
const completed = runs.filter(Boolean);
|
|
651
690
|
const failed = completed.filter((r) => r.status !== "passed");
|
|
652
691
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
|
|
656
|
-
token,
|
|
657
|
-
json: {
|
|
658
|
-
origin: "local",
|
|
659
|
-
origin_label: originLabel(),
|
|
660
|
-
source: "ci",
|
|
661
|
-
batch_id: batchId,
|
|
662
|
-
batch_label: "pre-push verify",
|
|
663
|
-
runs: completed,
|
|
664
|
-
},
|
|
665
|
-
});
|
|
666
|
-
if (upload.ok) uploaded = Number(upload.inserted || 0);
|
|
667
|
-
}
|
|
692
|
+
const upload = await uploadRuns(args, { token, projectId, batchId, runs: completed });
|
|
693
|
+
const uploaded = upload.inserted;
|
|
668
694
|
|
|
669
695
|
const outcome = {
|
|
670
696
|
state: failed.length ? "failed" : "passed",
|
|
@@ -678,6 +704,7 @@ async function runVerify(args) {
|
|
|
678
704
|
error: r.error_message || null,
|
|
679
705
|
})),
|
|
680
706
|
uploaded,
|
|
707
|
+
upload_error: upload.error,
|
|
681
708
|
// Advisory by construction: a real failure still reports zero so the push
|
|
682
709
|
// proceeds. The report is the product, not the gate.
|
|
683
710
|
exitCode: 0,
|
|
@@ -695,6 +722,7 @@ async function runVerify(args) {
|
|
|
695
722
|
process.stdout.write(`[preman] all ${completed.length} endpoints healthy\n`);
|
|
696
723
|
}
|
|
697
724
|
if (uploaded) process.stdout.write(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}\n`);
|
|
725
|
+
if (upload.error) process.stdout.write(`[preman] ${upload.error}\n`);
|
|
698
726
|
}
|
|
699
727
|
|
|
700
728
|
return finish({ ...outcome, message: null });
|
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)",
|
|
@@ -393,9 +394,11 @@ export function createServer() {
|
|
|
393
394
|
"- 'preview my MCP before deploying': mcp_preview (read-only). 'actually deploy': mcp_deploy.",
|
|
394
395
|
"- 'test the login endpoint' / 'hit POST /auth/login with body': test_api with the full URL. Use test_endpoint_by_id ONLY when the user references a saved endpoint by id/uuid.",
|
|
395
396
|
"- 'import my Postman/OpenAPI/Bruno/curl collection': import_collection.",
|
|
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.",
|
|
396
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).",
|
|
397
399
|
"- 'stress / load test X': run_stress_test. Read-only unless the user explicitly authorises writes — never set allow_writes yourself.",
|
|
398
|
-
"- After discover_endpoints_from_codebase, call register_discovered_endpoints with the JSON array it asked for
|
|
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.",
|
|
399
402
|
"- App-auth flows (signup, OTP, login, change_password) live under the user_auth_* tools and do not need an pm_live_ key.",
|
|
400
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.",
|
|
401
404
|
"",
|
|
@@ -410,6 +413,7 @@ export function createServer() {
|
|
|
410
413
|
"2. **Import then generate**: import_collection (Postman/OpenAPI/curl) -> generate_tests -> test_api to run them.",
|
|
411
414
|
'3. **Coverage audit**: get_coverage on a collection -> generate_tests(coverage_level="comprehensive") for gaps.',
|
|
412
415
|
"4. **Drift detection**: detect_drift with a collection + base_url -> test_api on drifted endpoints.",
|
|
416
|
+
"5. **Postman migration**: migrate_from_postman -> review next_steps (base URLs, secrets needing values) -> re-run with activate=true -> failures open fix tasks you collect with preman_get_fix_task.",
|
|
413
417
|
"",
|
|
414
418
|
"## Auth",
|
|
415
419
|
"PreMan API tools (get_endpoints, test_api, …) need an pm_live_ key. Preferred no-browser flow: user_auth_start_signup -> user_auth_set_password -> preman_create_api_key, or user_auth_login -> preman_create_api_key. Browser fallback: preman_login -> preman_login_complete.",
|
|
@@ -539,7 +543,7 @@ export function createServer() {
|
|
|
539
543
|
}
|
|
540
544
|
});
|
|
541
545
|
// ── register_discovered_endpoints ─────────────────────────────────
|
|
542
|
-
server.tool("register_discovered_endpoints", "Save discovered endpoints into PreMan
|
|
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).", {
|
|
543
547
|
endpoints: z.array(z.record(z.any())).optional().describe("Discovery-shaped endpoint objects (method, path_template, schemas, confidence, …); max 100"),
|
|
544
548
|
endpoint_ids: z.array(z.string()).optional().describe("Alternatively, existing registry ids to set up as runnable requests"),
|
|
545
549
|
project_id: z.string().optional().describe("Scope registration to a project"),
|
|
@@ -548,12 +552,64 @@ export function createServer() {
|
|
|
548
552
|
}, async (args) => {
|
|
549
553
|
try {
|
|
550
554
|
const result = await callBackend("register_discovered_endpoints", args);
|
|
551
|
-
return withFrontendUrl(result, "/
|
|
555
|
+
return withFrontendUrl(result, "/try");
|
|
552
556
|
}
|
|
553
557
|
catch (e) {
|
|
554
558
|
return toolError(e.message, inferErrorCode(e.message), {
|
|
555
559
|
next_actions: ["Run discover_endpoints_from_codebase first and pass its endpoints array here."],
|
|
556
|
-
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"],
|
|
557
613
|
});
|
|
558
614
|
}
|
|
559
615
|
});
|
|
@@ -665,6 +721,57 @@ export function createServer() {
|
|
|
665
721
|
});
|
|
666
722
|
}
|
|
667
723
|
});
|
|
724
|
+
// ── migrate_from_postman ──────────────────────────────────────────
|
|
725
|
+
server.tool("migrate_from_postman", "Move a Postman collection into PreMan, fully wired, in one call. Converts the collection keeping the assertions its pm.test blocks declared, splits the environment into shared variables and encrypted secrets, and creates one scheduled test suite per request seeded from those assertions. Suites are created switched OFF; pass activate=true to start them and attach alert rules so a failing check opens a fix task. Returns next_steps — the honest list of what still needs a human.", {
|
|
726
|
+
content: z.string().optional().describe("Raw Postman collection export (v2.x JSON)"),
|
|
727
|
+
collection_id: z.string().optional().describe("Pull from a connected Postman workspace instead of pasting content"),
|
|
728
|
+
environment: z.string().optional().describe("Raw Postman environment export (JSON)"),
|
|
729
|
+
name: z.string().optional().describe("Override the collection name"),
|
|
730
|
+
project_id: z.string().optional().describe("Scope the migration to a project"),
|
|
731
|
+
activate: z.boolean().optional().default(false).describe("Start the suites and create alert rules. Leave false to review first."),
|
|
732
|
+
channel_ids: z.array(z.string()).optional().describe("Alert channels to notify. Empty still records events and opens fix tasks."),
|
|
733
|
+
autofix_enabled: z.boolean().optional().default(false).describe("Let a failure dispatch to the coding agent automatically"),
|
|
734
|
+
include_yaml: z.boolean().optional().default(false).describe("Include the full .ot.yaml string (default false to save tokens)"),
|
|
735
|
+
}, async (args) => {
|
|
736
|
+
try {
|
|
737
|
+
const result = await callBackend("migrate_from_postman", args);
|
|
738
|
+
const collectionId = result?.collection_id ?? "";
|
|
739
|
+
const dashPath = collectionId ? `/collections?id=${collectionId}` : "/collections";
|
|
740
|
+
emitActivity("navigate", { path: dashPath });
|
|
741
|
+
emitActivity("data_refresh", { scope: "collections" });
|
|
742
|
+
return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
|
|
743
|
+
}
|
|
744
|
+
catch (e) {
|
|
745
|
+
return toolError(e.message, inferErrorCode(e.message), {
|
|
746
|
+
next_actions: [
|
|
747
|
+
"Provide either content (a Postman v2.x export) or collection_id from a connected Postman workspace.",
|
|
748
|
+
"Connect a workspace at /integrations/postman/connect to use collection_id.",
|
|
749
|
+
],
|
|
750
|
+
related_tools: ["import_collection", "get_endpoints"],
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
// ── recover_assertions ────────────────────────────────────────────
|
|
755
|
+
server.tool("recover_assertions", "Hand back assertions you translated from a Postman script PreMan could not read. An import's migration_report lists every dropped pm.test block with the script attached under entries[].source — read that JavaScript, convert what it checks, and call this. The assertion lands on the collection endpoint, its workbench request and the scheduled suite, so the next run grades it. Each assertion accepts: status (int or '2xx'), status_not (int), response_time_ms ('< 500'), json_paths ({'json.id': 7}, or 'exists' to require a field).", {
|
|
756
|
+
collection_id: z.string().describe("The collection the import created"),
|
|
757
|
+
endpoint_id: z.string().describe("entries[].endpoint_id from the migration report (or the endpoint name)"),
|
|
758
|
+
assertions: z.array(z.record(z.any())).describe("The converted assertions"),
|
|
759
|
+
}, async (args) => {
|
|
760
|
+
try {
|
|
761
|
+
const result = await callBackend("recover_assertions", args);
|
|
762
|
+
emitActivity("data_refresh", { scope: "collections" });
|
|
763
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
764
|
+
}
|
|
765
|
+
catch (e) {
|
|
766
|
+
return toolError(e.message, inferErrorCode(e.message), {
|
|
767
|
+
next_actions: [
|
|
768
|
+
"Check collection_id and endpoint_id against the migration_report entries.",
|
|
769
|
+
"Each assertion must use the documented fields: status, status_not, response_time_ms, json_paths.",
|
|
770
|
+
],
|
|
771
|
+
related_tools: ["get_collection", "migrate_from_postman"],
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
});
|
|
668
775
|
// ── detect_drift ──────────────────────────────────────────────────
|
|
669
776
|
server.tool("detect_drift", "Detect spec drift by comparing live API responses against a collection spec. Returns a list of drift alerts per endpoint with expected vs actual differences.", {
|
|
670
777
|
base_url: z.string().describe("Base URL to test against (e.g. https://api.example.com)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "premanmcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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-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-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-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",
|