premanmcp 0.4.0 → 0.7.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/bin/shared.js ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Shared CLI helpers: prompts, credential storage, backend calls, terminal auth.
3
+ *
4
+ * Extracted from bin/cli.js so `connect` and `install` can use the same login
5
+ * and config-writing behavior instead of growing a second copy. Every function
6
+ * that reads flags takes an `args` accessor (see makeArgs) rather than closing
7
+ * over a module-level argv, so a command can pass its own slice.
8
+ */
9
+
10
+ import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { createInterface } from "node:readline/promises";
14
+
15
+ export const DEFAULT_BACKEND = "https://api.preman.live";
16
+ export const DEFAULT_FRONTEND = "https://app.preman.live";
17
+ export const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
18
+ export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
19
+
20
+ /** Wrap a raw argv slice in the positional lookup the CLI has always used. */
21
+ export function makeArgs(commandArgs = []) {
22
+ return {
23
+ raw: commandArgs,
24
+ value(name, fallback = "") {
25
+ const index = commandArgs.indexOf(name);
26
+ if (index === -1) return fallback;
27
+ return commandArgs[index + 1] || fallback;
28
+ },
29
+ has(name) {
30
+ return commandArgs.includes(name);
31
+ },
32
+ };
33
+ }
34
+
35
+ export function readJsonFile(filePath) {
36
+ if (!existsSync(filePath)) return {};
37
+ const raw = readFileSync(filePath, "utf8").trim();
38
+ if (!raw) return {};
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch (error) {
42
+ throw new Error(`Could not parse ${filePath}: ${error.message}`);
43
+ }
44
+ }
45
+
46
+ export function writeJsonFile(filePath, value) {
47
+ mkdirSync(path.dirname(filePath), { recursive: true });
48
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
49
+ // writeFileSync's mode only applies when it creates the file, and these
50
+ // configs can hold a pm_live_ key — tighten an existing file explicitly.
51
+ chmodSync(filePath, 0o600);
52
+ }
53
+
54
+ export function readStoredCredentials() {
55
+ try {
56
+ const raw = readFileSync(CREDENTIALS_FILE, "utf8").trim();
57
+ if (!raw) return null;
58
+ const creds = JSON.parse(raw);
59
+ if (creds && typeof creds.api_key === "string" && creds.api_key.startsWith("pm_live_")) {
60
+ return creds;
61
+ }
62
+ } catch {
63
+ // No stored credentials yet.
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export function saveStoredCredentials(creds) {
69
+ mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
70
+ writeFileSync(CREDENTIALS_FILE, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
71
+ }
72
+
73
+ export function backendUrl(args) {
74
+ return args.value("--backend", process.env.PREMAN_BACKEND || DEFAULT_BACKEND).replace(/\/+$/, "");
75
+ }
76
+
77
+ export function frontendUrl(args) {
78
+ return args.value("--frontend", process.env.PREMAN_FRONTEND || DEFAULT_FRONTEND).replace(/\/+$/, "");
79
+ }
80
+
81
+ export async function promptText(question) {
82
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
83
+ try {
84
+ return (await rl.question(question)).trim();
85
+ } finally {
86
+ rl.close();
87
+ }
88
+ }
89
+
90
+ export async function promptSecret(question) {
91
+ if (!process.stdin.isTTY || !process.stdin.setRawMode) {
92
+ return promptText(question);
93
+ }
94
+
95
+ return new Promise((resolve) => {
96
+ const stdin = process.stdin;
97
+ const stdout = process.stdout;
98
+ const wasRaw = stdin.isRaw;
99
+ let value = "";
100
+
101
+ function cleanup() {
102
+ stdin.off("data", onData);
103
+ stdin.setRawMode(Boolean(wasRaw));
104
+ stdin.pause();
105
+ }
106
+
107
+ function onData(chunk) {
108
+ const text = String(chunk);
109
+ if (text === "\u0003") {
110
+ stdout.write("\n");
111
+ cleanup();
112
+ process.exit(130);
113
+ }
114
+ if (text === "\r" || text === "\n" || text === "\u0004") {
115
+ stdout.write("\n");
116
+ cleanup();
117
+ resolve(value);
118
+ return;
119
+ }
120
+ if (text === "\u007f" || text === "\b") {
121
+ if (value.length) {
122
+ value = value.slice(0, -1);
123
+ stdout.write("\b \b");
124
+ }
125
+ return;
126
+ }
127
+ value += text;
128
+ stdout.write("*");
129
+ }
130
+
131
+ stdout.write(question);
132
+ stdin.setRawMode(true);
133
+ stdin.resume();
134
+ stdin.setEncoding("utf8");
135
+ stdin.on("data", onData);
136
+ });
137
+ }
138
+
139
+ export async function promptPasswordTwice() {
140
+ const password = await promptSecret("Create password: ");
141
+ if (!password || password.length < 6) {
142
+ throw new Error("Password must be at least 6 characters.");
143
+ }
144
+ const confirm = await promptSecret("Confirm password: ");
145
+ if (password !== confirm) {
146
+ throw new Error("Passwords do not match.");
147
+ }
148
+ return password;
149
+ }
150
+
151
+ export async function callBackendJson(args, method, routePath, { json, token, query } = {}) {
152
+ const url = new URL(routePath.replace(/^\/+/, ""), `${backendUrl(args)}/`);
153
+ if (query) {
154
+ for (const [key, value] of Object.entries(query)) {
155
+ if (value != null && value !== "") url.searchParams.set(key, String(value));
156
+ }
157
+ }
158
+
159
+ const headers = { Accept: "application/json" };
160
+ const hasBody = json !== undefined && json !== null;
161
+ if (hasBody) headers["Content-Type"] = "application/json";
162
+ if (token) headers.Authorization = `Bearer ${token}`;
163
+
164
+ const resp = await fetch(url, {
165
+ method,
166
+ headers,
167
+ body: hasBody ? JSON.stringify(json) : undefined,
168
+ });
169
+ const text = await resp.text();
170
+ let body = {};
171
+ try {
172
+ body = text ? JSON.parse(text) : {};
173
+ } catch {
174
+ body = { raw: text };
175
+ }
176
+ return {
177
+ status_code: resp.status,
178
+ ok: resp.ok,
179
+ ...body,
180
+ };
181
+ }
182
+
183
+ export function assertOk(result, action) {
184
+ if (result.ok) return;
185
+ const detail = result.detail || result.message || result.raw || `${action} failed`;
186
+ throw new Error(`${action} failed: ${result.status_code} ${detail}`);
187
+ }
188
+
189
+ async function verifyUnconfirmedAccount(args, email) {
190
+ process.stdout.write("This email exists but is not verified. Sending a new OTP.\n");
191
+ const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
192
+ assertOk(resend, "resend OTP");
193
+ const otp = await promptText("Verification code: ");
194
+ const verified = await callBackendJson(args, "POST", "/auth/verify-otp", { json: { email, otp } });
195
+ assertOk(verified, "verify OTP");
196
+ return verified.access_token;
197
+ }
198
+
199
+ async function createAccountFromTerminal(args, email) {
200
+ const signup = await callBackendJson(args, "POST", "/auth/start-signup", {
201
+ json: { email },
202
+ });
203
+
204
+ if (signup.ok) {
205
+ process.stdout.write("Verification code sent. Check your email.\n");
206
+ const otp = await promptText("Verification code: ");
207
+ const password = await promptPasswordTwice();
208
+ const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
209
+ json: { email, otp, new_password: password },
210
+ });
211
+ assertOk(setPassword, "set password");
212
+ return String(setPassword.access_token || "");
213
+ }
214
+
215
+ if (signup.status_code !== 404) {
216
+ assertOk(signup, "start signup");
217
+ }
218
+
219
+ process.stdout.write(
220
+ "This PreMan backend uses the password-first signup flow. Create your password now, then enter the email code.\n"
221
+ );
222
+ const password = await promptPasswordTwice();
223
+ const legacySignup = await callBackendJson(args, "POST", "/auth/signup", {
224
+ json: { email, password },
225
+ });
226
+ assertOk(legacySignup, "signup");
227
+ process.stdout.write("Verification code sent. Check your email.\n");
228
+ const otp = await promptText("Verification code: ");
229
+ const verified = await callBackendJson(args, "POST", "/auth/verify-otp", {
230
+ json: { email, otp },
231
+ });
232
+ assertOk(verified, "verify OTP");
233
+ return String(verified.access_token || "");
234
+ }
235
+
236
+ /**
237
+ * Get a usable pm_live_ key: an explicit one, or by logging in / signing up
238
+ * right here in the terminal. Always persists to ~/.preman/credentials.json.
239
+ */
240
+ export async function authenticateTerminal(args) {
241
+ const explicitKey = args.value("--api-key", process.env.PREMAN_API_KEY || "");
242
+ if (explicitKey && explicitKey.startsWith("pm_live_")) {
243
+ const creds = {
244
+ api_key: explicitKey,
245
+ backend_url: backendUrl(args),
246
+ user_email: args.value("--email", ""),
247
+ device_name: os.hostname(),
248
+ created_at: new Date().toISOString(),
249
+ };
250
+ saveStoredCredentials(creds);
251
+ return creds;
252
+ }
253
+
254
+ const email = (args.value("--email", "") || await promptText("Email: ")).trim().toLowerCase();
255
+ if (!email) throw new Error("Email is required.");
256
+
257
+ process.stdout.write(`Checking PreMan account for ${email}...\n`);
258
+ const account = await callBackendJson(args, "GET", "/auth/needs-password", { query: { email } });
259
+ assertOk(account, "check account");
260
+
261
+ let accessToken = "";
262
+
263
+ if (!account.exists) {
264
+ process.stdout.write("No PreMan account found. Creating one now.\n");
265
+ accessToken = await createAccountFromTerminal(args, email);
266
+ } else if (account.needs_password) {
267
+ process.stdout.write("This account needs a password. Sending an OTP first.\n");
268
+ const resend = await callBackendJson(args, "POST", "/auth/resend-otp", { json: { email } });
269
+ assertOk(resend, "resend OTP");
270
+ const otp = await promptText("Verification code: ");
271
+ const password = await promptPasswordTwice();
272
+ const setPassword = await callBackendJson(args, "POST", "/auth/set-password", {
273
+ json: { email, otp, new_password: password },
274
+ });
275
+ assertOk(setPassword, "set password");
276
+ accessToken = String(setPassword.access_token || "");
277
+ } else {
278
+ const password = await promptSecret("Password: ");
279
+ const login = await callBackendJson(args, "POST", "/auth/login", {
280
+ json: { email, password },
281
+ });
282
+ if (!login.ok && login.status_code === 403 && String(login.detail || "").toLowerCase().includes("not verified")) {
283
+ accessToken = await verifyUnconfirmedAccount(args, email);
284
+ } else {
285
+ assertOk(login, "login");
286
+ accessToken = String(login.access_token || "");
287
+ }
288
+ }
289
+
290
+ if (!accessToken) throw new Error("PreMan did not return an access token.");
291
+
292
+ const keyName = `PreMan MCP CLI (${os.hostname()})`;
293
+ const key = await callBackendJson(args, "POST", "/api-keys", {
294
+ token: accessToken,
295
+ json: { name: keyName },
296
+ });
297
+ assertOk(key, "create API key");
298
+ if (!key.key || !String(key.key).startsWith("pm_live_")) {
299
+ throw new Error("PreMan did not return a valid API key.");
300
+ }
301
+
302
+ const creds = {
303
+ api_key: String(key.key),
304
+ backend_url: backendUrl(args),
305
+ user_email: email,
306
+ device_name: os.hostname(),
307
+ created_at: new Date().toISOString(),
308
+ };
309
+ saveStoredCredentials(creds);
310
+ return creds;
311
+ }
312
+
313
+ /** Does this invocation already have a key, without prompting for one? */
314
+ export function hasKeyAvailable(args) {
315
+ return Boolean(
316
+ args.value("--api-key", "") || process.env.PREMAN_API_KEY || readStoredCredentials()
317
+ );
318
+ }
319
+
320
+ /** Resolve the key to embed in a written config, if any. */
321
+ export function resolveApiKey(args) {
322
+ const explicit = args.value("--api-key", process.env.PREMAN_API_KEY || "");
323
+ if (explicit) return explicit;
324
+ const stored = readStoredCredentials();
325
+ return stored ? stored.api_key : "";
326
+ }
327
+
328
+ /**
329
+ * The MCP server block written into every agent's config.
330
+ *
331
+ * Matches the backend's install snippets (`build_install_snippets`) so the
332
+ * copy-paste path and this writer cannot drift.
333
+ */
334
+ export function buildServerConfig(args, { pairCode = "" } = {}) {
335
+ const env = {
336
+ PREMAN_BACKEND: backendUrl(args),
337
+ PREMAN_FRONTEND: frontendUrl(args),
338
+ };
339
+ const apiKey = resolveApiKey(args);
340
+ if (apiKey) env.PREMAN_API_KEY = apiKey;
341
+ if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
342
+
343
+ return {
344
+ // npm exec rather than npx: same resolution, but it is the command every
345
+ // install of npm ships, and it is what the rest of our instructions use.
346
+ // The trailing "--" stops npm from claiming flags meant for the server.
347
+ command: "npm",
348
+ args: ["exec", "-y", "premanmcp@latest", "--"],
349
+ env,
350
+ };
351
+ }
package/dist/server.js CHANGED
@@ -62,6 +62,7 @@ async function heartbeatWorkbenchLink() {
62
62
  try {
63
63
  const resp = await fetch(`${BACKEND_URL}/workbench/coding-agent/heartbeat`, {
64
64
  method: "POST",
65
+ signal: AbortSignal.timeout(5000),
65
66
  headers: {
66
67
  Authorization: `Bearer ${API_KEY}`,
67
68
  "Content-Type": "application/json",
@@ -360,7 +361,7 @@ export function createServer() {
360
361
  "",
361
362
  "## Tool taxonomy",
362
363
  "- **Read tools** (no side effects): get_endpoints, get_coverage, detect_drift, preman_status, list_collections, get_collection, list_runs",
363
- "- **Action tools** (execute tests / mutate state): test_api, generate_tests, import_collection, test_endpoint_by_id, run_tests, delete_collection",
364
+ "- **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",
364
365
  "- **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",
365
366
  "- **Auth (API key / PreMan)**: preman_create_api_key (JWT -> saved pm_live_ key), preman_login, preman_login_complete, preman_logout",
366
367
  "- **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)",
@@ -372,6 +373,9 @@ export function createServer() {
372
373
  "- 'preview my MCP before deploying': mcp_preview (read-only). 'actually deploy': mcp_deploy.",
373
374
  "- '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.",
374
375
  "- 'import my Postman/OpenAPI/Bruno/curl collection': import_collection.",
376
+ "- '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).",
377
+ "- 'stress / load test X': run_stress_test. Read-only unless the user explicitly authorises writes — never set allow_writes yourself.",
378
+ "- After discover_endpoints_from_codebase, call register_discovered_endpoints with the JSON array it asked for, then verify_endpoints_live to confirm they respond.",
375
379
  "- App-auth flows (signup, OTP, login, change_password) live under the user_auth_* tools and do not need an pm_live_ key.",
376
380
  "- 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.",
377
381
  "",
@@ -469,6 +473,70 @@ export function createServer() {
469
473
  });
470
474
  }
471
475
  });
476
+ // ── generate_endpoint_tests ───────────────────────────────────────
477
+ server.tool("generate_endpoint_tests", "Generate scenario tests for one endpoint (happy path, auth, validation, boundaries, plus user-described scenarios) and run them by default. Pass a workbench request_id, a registry endpoint_id, or target to let PreMan resolve either. Set include_code=true for ready-to-write pytest/jest files in code_artifacts.", {
478
+ request_id: z.string().optional().describe("Saved workbench request id"),
479
+ endpoint_id: z.string().optional().describe("Registry endpoint UUID from get_endpoints"),
480
+ target: z.string().optional().describe("Either kind of id; PreMan resolves it"),
481
+ scenarios: z.array(z.string()).optional().describe("The user's scenario descriptions, one per entry"),
482
+ run: z.boolean().optional().default(true).describe("Execute the generated cases now (read-only by default)"),
483
+ allow_writes: z.boolean().optional().default(false).describe("Permit mutating cases — only when the user authorised it"),
484
+ persist_suite: z.boolean().optional().default(false).describe("Also save as a recurring auto-test suite (paid feature)"),
485
+ max_cases: z.number().optional().default(10).describe("Max generated cases (1-25)"),
486
+ include_code: z.boolean().optional().default(false).describe("Return unit-test files in code_artifacts"),
487
+ test_framework: z.enum(["pytest", "jest"]).optional().default("pytest").describe("Framework for code_artifacts"),
488
+ }, async (args) => {
489
+ try {
490
+ const result = await callBackend("generate_endpoint_tests", args);
491
+ return withFrontendUrl(result, "/endpoints");
492
+ }
493
+ catch (e) {
494
+ return toolError(e.message, inferErrorCode(e.message), {
495
+ next_actions: ["Call get_endpoints (include_workbench=true) to find a valid request or endpoint id."],
496
+ related_tools: ["get_endpoints", "run_stress_test"],
497
+ });
498
+ }
499
+ });
500
+ // ── run_stress_test ───────────────────────────────────────────────
501
+ server.tool("run_stress_test", "Bounded load test against one endpoint: paced requests for a fixed duration, reporting p50/p95/p99 latency, error rate, throughput, and a failure-classification mix. Read-only endpoints unless the user explicitly authorised writes; destructive endpoints always refused. Paid feature; server-side caps apply.", {
502
+ request_id: z.string().optional().describe("Saved workbench request id"),
503
+ endpoint_id: z.string().optional().describe("Registry endpoint UUID from get_endpoints"),
504
+ target: z.string().optional().describe("Either kind of id; PreMan resolves it"),
505
+ duration_seconds: z.number().optional().default(15).describe("Run length (server caps apply)"),
506
+ rps: z.number().optional().default(5).describe("Target requests per second (server caps apply)"),
507
+ concurrency: z.number().optional().default(5).describe("Max in-flight requests (server caps apply)"),
508
+ allow_writes: z.boolean().optional().default(false).describe("Only when the user authorised stressing a write endpoint"),
509
+ }, async (args) => {
510
+ try {
511
+ const result = await callBackend("run_stress_test", args);
512
+ return withFrontendUrl(result, "/endpoints");
513
+ }
514
+ catch (e) {
515
+ return toolError(e.message, inferErrorCode(e.message), {
516
+ next_actions: ["Call get_endpoints (include_workbench=true) to find a valid request or endpoint id."],
517
+ related_tools: ["get_endpoints", "generate_endpoint_tests"],
518
+ });
519
+ }
520
+ });
521
+ // ── register_discovered_endpoints ─────────────────────────────────
522
+ 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.", {
523
+ endpoints: z.array(z.record(z.any())).optional().describe("Discovery-shaped endpoint objects (method, path_template, schemas, confidence, …); max 100"),
524
+ endpoint_ids: z.array(z.string()).optional().describe("Alternatively, existing registry ids to set up as runnable requests"),
525
+ project_id: z.string().optional().describe("Scope registration to a project"),
526
+ base_url: z.string().optional().describe("Applied to endpoints that carry none"),
527
+ setup_workbench: z.boolean().optional().default(true).describe("Also create saved workbench requests (default true)"),
528
+ }, async (args) => {
529
+ try {
530
+ const result = await callBackend("register_discovered_endpoints", args);
531
+ return withFrontendUrl(result, "/endpoints");
532
+ }
533
+ catch (e) {
534
+ return toolError(e.message, inferErrorCode(e.message), {
535
+ next_actions: ["Run discover_endpoints_from_codebase first and pass its endpoints array here."],
536
+ related_tools: ["discover_endpoints_from_codebase", "verify_endpoints_live"],
537
+ });
538
+ }
539
+ });
472
540
  // ── get_endpoints ─────────────────────────────────────────────────
473
541
  server.tool("get_endpoints", "Return endpoint inventory (registry, optionally MCP sessions and collections). Default: structured JSON for agents. Pass format='text' for deprecated human-friendly output, or open_ui=true for the visual dashboard. Schemas stripped by default to save tokens; set include_schemas=true when needed.", {
474
542
  status: z.string().optional().describe("Filter: tested, needed, draft"),
@@ -476,6 +544,7 @@ export function createServer() {
476
544
  include_sessions: z.boolean().optional().default(false).describe("Include recent MCP session test results"),
477
545
  include_collections: z.boolean().optional().default(false).describe("Include imported collection endpoints"),
478
546
  include_schemas: z.boolean().optional().default(false).describe("Include request/response schemas per endpoint (default false to save tokens)"),
547
+ include_workbench: z.boolean().optional().default(false).describe("Also list the default workspace's saved runnable requests"),
479
548
  limit: z.number().optional().default(50).describe("Max endpoints per page (default 50)"),
480
549
  offset: z.number().optional().default(0).describe("Pagination offset"),
481
550
  project_id: z.string().optional().describe("Scope to a specific project"),
@@ -1068,6 +1137,42 @@ export function createServer() {
1068
1137
  // ── Hosted MCP platform tools ──────────────────────────────────────
1069
1138
  // Thin stdio proxies; all real logic lives in flowtest/mcp/hosted_mcp_tools.py
1070
1139
  // and is reached via the /mcp/call-tool HTTP bridge.
1140
+ server.tool("connect_logs", "Front door for connecting a customer's production logs to PreMan. Call with NO arguments first: it returns the projects you can use, the supported sources, and the questions to ask. Then call again with action='connect' (CloudWatch/S3/PostHog), action='push' (logs that live anywhere else — Kubernetes, a PaaS drain, a self-hosted collector), and finally action='verify'. NEVER ask the user for AWS access keys: AWS access is a read-only role the customer creates from the CloudFormation template this tool returns, and your job is to run the deploy command it gives you. For action='push', reference $PREMAN_API_KEY by name in any config you write — never print the key itself.", {
1141
+ action: z
1142
+ .enum(["brief", "connect", "push", "verify"])
1143
+ .optional()
1144
+ .describe("Omit for the guided brief; then connect | push | verify"),
1145
+ project_id: z.string().optional().describe("PreMan project the logs belong to"),
1146
+ source: z
1147
+ .enum(["cloudwatch", "s3", "posthog"])
1148
+ .optional()
1149
+ .describe("Where the logs already land; required for action='connect'"),
1150
+ config: z
1151
+ .record(z.any())
1152
+ .optional()
1153
+ .describe("Source config: cloudwatch {region, log_group}, s3 {region, bucket, prefix?}, posthog {project_id, host?, event_names?}"),
1154
+ aws_account_id: z
1155
+ .string()
1156
+ .optional()
1157
+ .describe("Customer's 12-digit AWS account id; used to predict the role ARN the template creates"),
1158
+ posthog_api_key: z.string().optional().describe("PostHog personal API key, read scope (posthog only)"),
1159
+ name: z.string().optional().describe("Display name for the connector"),
1160
+ env_name: z.string().optional().describe("Environment the logs belong to, e.g. production"),
1161
+ stream: z.enum(["frontend", "backend", "unknown"]).optional().describe("Which log rail these lines land on"),
1162
+ interval_seconds: z.number().optional().describe("Poll interval; defaults per source type"),
1163
+ connector_id: z.string().optional().describe("Connector to check; required for action='verify'"),
1164
+ }, async (args) => {
1165
+ try {
1166
+ const result = await callBackend("connect_logs", args);
1167
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1168
+ }
1169
+ catch (e) {
1170
+ return toolError(e.message, inferErrorCode(e.message), {
1171
+ next_actions: ["Call connect_logs with no arguments to see the supported actions."],
1172
+ related_tools: ["connect_logs"],
1173
+ });
1174
+ }
1175
+ });
1071
1176
  server.tool("discover_endpoints_from_codebase", "Return a brief the coding agent follows to extract HTTP endpoints from the user's codebase. Call this first when the user asks to turn their API into an MCP — the returned instructions tell your agent how to walk the repo and what shape to produce. Then pass the findings to verify_endpoints_live.", {
1072
1177
  base_path: z.string().optional().describe("Directory to scan (defaults to CWD)"),
1073
1178
  framework_hint: z.string().optional().describe("fastapi | express | nestjs | rails | django | nextjs"),
@@ -1323,6 +1428,21 @@ async function main() {
1323
1428
  const server = createServer();
1324
1429
  const transport = new StdioServerTransport();
1325
1430
  await server.connect(transport);
1431
+ // Starting the configured MCP server is itself proof that the coding agent
1432
+ // is live. Previously the workbench remained in `connecting` until someone
1433
+ // explicitly asked the agent to call `preman_status`, even when this process
1434
+ // already had valid stored credentials and the pairing code in its env.
1435
+ // Keep startup non-blocking so a slow control plane cannot delay MCP setup.
1436
+ if (API_KEY) {
1437
+ void heartbeatWorkbenchLink().then((link) => {
1438
+ if (link?.ok) {
1439
+ console.error("[PreMan] Coding-agent connection heartbeat sent");
1440
+ }
1441
+ else if (link) {
1442
+ console.error(`[PreMan] Coding-agent heartbeat not accepted: ${String(link.detail || link.status || "unknown error")}`);
1443
+ }
1444
+ });
1445
+ }
1326
1446
  }
1327
1447
  main().catch(console.error);
1328
1448
  process.on("SIGINT", () => { process.exit(0); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "Turn APIs into agent-callable MCP tools with auth, testing, and audit logs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "build": "tsc -p tsconfig.server.json",
13
13
  "dev": "tsx src/server.ts",
14
14
  "open-mcp-preview": "node scripts/emit-mcp-preview.mjs",
15
- "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs"
15
+ "open-mcp-preview-in-cursor": "node scripts/open-cursor-preview.mjs",
16
+ "test": "node --test scripts/smoke-onboard.mjs"
16
17
  },
17
18
  "dependencies": {
18
19
  "@modelcontextprotocol/ext-apps": "^0.1.0",