premanmcp 0.5.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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Connect AWS, GitHub and Slack from the terminal.
3
+ *
4
+ * Each of these already had a backend that hands back a URL and a way to ask
5
+ * whether the customer finished. What was missing was anywhere to drive them
6
+ * except the dashboard, which meant a terminal-first user had to stop, find the
7
+ * web app, and hunt for three different settings screens before PreMan could
8
+ * show them anything.
9
+ *
10
+ * The shape is the same for all three because the constraint is the same: the
11
+ * customer's session with AWS, GitHub or Slack lives in their browser, not in
12
+ * this process. So we ask the backend for a URL, open it, and poll our own API
13
+ * until the other side reports the connection exists. Nothing is copied back by
14
+ * hand, and no third-party credential ever touches the terminal.
15
+ */
16
+
17
+ import { spawn } from "node:child_process";
18
+
19
+ import {
20
+ assertOk,
21
+ callBackendJson,
22
+ frontendUrl,
23
+ promptText,
24
+ resolveApiKey,
25
+ } from "./shared.js";
26
+
27
+ const POLL_INTERVAL_MS = 3000;
28
+ const POLL_TIMEOUT_MS = 300000;
29
+
30
+ /**
31
+ * Colour only when someone is actually watching.
32
+ *
33
+ * Escape codes in piped output end up in log files and CI transcripts as
34
+ * literal noise, and NO_COLOR is the standard way to ask us not to. The mark
35
+ * itself still prints in both cases -- it carries the meaning, the colour only
36
+ * makes it quicker to find.
37
+ */
38
+ const USE_COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
39
+
40
+ function paint(code, text) {
41
+ return USE_COLOR ? `[${code}m${text}` : text;
42
+ }
43
+
44
+ export const MARK = {
45
+ ok: () => paint("32", "✓"),
46
+ skip: () => paint("90", "○"),
47
+ fail: () => paint("31", "✗"),
48
+ };
49
+
50
+ /** A finished step, marked so it can be found at a glance. */
51
+ export function connected(message) {
52
+ process.stdout.write(`${MARK.ok()} ${message}\n`);
53
+ }
54
+
55
+ /** Open a URL in the customer's default browser, best effort. */
56
+ export function openUrl(url) {
57
+ const command =
58
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
59
+ try {
60
+ const child = spawn(command, [url], {
61
+ stdio: "ignore",
62
+ detached: true,
63
+ shell: process.platform === "win32",
64
+ });
65
+ child.unref();
66
+ return true;
67
+ } catch {
68
+ // Headless boxes and locked-down shells are normal. The URL is printed
69
+ // either way, so this is a convenience, never the only path.
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function present(url, what) {
75
+ process.stdout.write(`\n ${url}\n\n`);
76
+ if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
77
+ else process.stdout.write(`Open the link above to ${what}.\n`);
78
+ }
79
+
80
+ /**
81
+ * Raised by a poll that will never succeed, however long we wait.
82
+ *
83
+ * Distinct from an ordinary failed poll because the two need opposite
84
+ * handling: a network blip while the customer is in another tab should be
85
+ * ignored, but a stack deployed into the wrong AWS account will report the
86
+ * same answer forever, and waiting five minutes to say so is five minutes of
87
+ * the customer believing it might still work.
88
+ */
89
+ export class Unrecoverable extends Error {}
90
+
91
+ /**
92
+ * Poll ``check`` until it reports done.
93
+ *
94
+ * Returns the truthy value from ``check``, or null on timeout. Ordinary
95
+ * exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
96
+ */
97
+ async function waitFor(label, check) {
98
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
99
+ process.stdout.write(`Waiting for ${label}`);
100
+ while (Date.now() < deadline) {
101
+ try {
102
+ const done = await check();
103
+ if (done) {
104
+ process.stdout.write(" done.\n");
105
+ return done;
106
+ }
107
+ } catch (err) {
108
+ if (err instanceof Unrecoverable) {
109
+ process.stdout.write("\n");
110
+ throw err;
111
+ }
112
+ /* keep waiting; the customer is elsewhere */
113
+ }
114
+ process.stdout.write(".");
115
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
116
+ }
117
+ process.stdout.write("\n");
118
+ return null;
119
+ }
120
+
121
+ function requireKey(args) {
122
+ const apiKey = resolveApiKey(args);
123
+ if (!apiKey) {
124
+ throw new Error("Not signed in. Run 'preman login' first.");
125
+ }
126
+ return apiKey;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // AWS
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export async function awsCommand(args) {
134
+ const token = requireKey(args);
135
+
136
+ const accountId = (
137
+ args.value("--account", "") || (await promptText("AWS account ID (12 digits): "))
138
+ ).trim();
139
+ const region = args.value("--region", "us-east-1");
140
+
141
+ const created = await callBackendJson(args, "POST", "/aws-links", {
142
+ token,
143
+ json: { aws_account_id: accountId, console_region: region },
144
+ });
145
+ assertOk(created, "create AWS connection");
146
+
147
+ const setup = created.setup || {};
148
+ process.stdout.write("\nThis grants PreMan read-only access to:\n");
149
+ for (const grant of setup.grants || []) process.stdout.write(` - ${grant}\n`);
150
+
151
+ if (setup.quick_create_url) {
152
+ present(setup.quick_create_url, "create the CloudFormation stack");
153
+ process.stdout.write("Tick the IAM acknowledgement, then click Create stack.\n");
154
+ } else {
155
+ // No hosted template in this deployment: the CLI path still works.
156
+ process.stdout.write(`\nRun this to create the role:\n\n ${setup.cli_command}\n\n`);
157
+ }
158
+
159
+ const linkId = created.link?.id;
160
+ let verified;
161
+ try {
162
+ verified = await waitFor("the stack", async () => {
163
+ const result = await callBackendJson(args, "POST", `/aws-links/${linkId}/verify`, {
164
+ token,
165
+ query: { region },
166
+ });
167
+ // Waiting cannot fix a stack built in the wrong account: it will report
168
+ // this same answer until the customer redeploys somewhere else.
169
+ if (result.reason === "wrong_account") throw new Unrecoverable(result.remediation);
170
+ return result.verified ? result : null;
171
+ });
172
+ } catch (err) {
173
+ if (!(err instanceof Unrecoverable)) throw err;
174
+ process.stdout.write(`${err.message}\n`);
175
+ return;
176
+ }
177
+
178
+ if (!verified) {
179
+ process.stdout.write("Timed out. Re-run 'preman aws' once the stack finishes.\n");
180
+ return;
181
+ }
182
+ connected(`AWS connected: ${verified.role_arn}`);
183
+
184
+ const groups = await callBackendJson(args, "GET", `/aws-links/${linkId}/log-groups`, {
185
+ token,
186
+ query: { region },
187
+ });
188
+ const names = (groups.log_groups || []).map((g) => g.name);
189
+ if (!names.length) {
190
+ process.stdout.write(`No log groups found in ${region}.\n`);
191
+ return;
192
+ }
193
+
194
+ process.stdout.write("\nLog groups PreMan can read:\n");
195
+ names.slice(0, 10).forEach((name, i) => process.stdout.write(` ${i + 1}. ${name}\n`));
196
+
197
+ const projectId = args.value("--project-id", "");
198
+ if (!projectId) {
199
+ process.stdout.write("\nPass --project-id to start streaming one of these.\n");
200
+ return;
201
+ }
202
+
203
+ const pick = args.value("--log-group", "") || (await promptText("Stream which one? [1]: "));
204
+ const chosen = names.includes(pick) ? pick : names[Math.max(0, Number(pick || 1) - 1)];
205
+
206
+ const connector = await callBackendJson(args, "POST", `/projects/${projectId}/log-connectors`, {
207
+ token,
208
+ json: {
209
+ name: chosen,
210
+ connector_type: "cloudwatch",
211
+ stream: "backend",
212
+ interval_seconds: 15,
213
+ config: { account_link_id: linkId, region, log_group: chosen },
214
+ },
215
+ });
216
+ assertOk(connector, "create log connector");
217
+ connected(`Streaming ${chosen}. Logs appear in PreMan within about a minute.`);
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // GitHub
222
+ // ---------------------------------------------------------------------------
223
+
224
+ export async function githubCommand(args) {
225
+ const token = requireKey(args);
226
+
227
+ const before = await callBackendJson(args, "GET", "/integrations/github", { token });
228
+ const seen = new Set((before.integrations || before.repos || []).map((r) => r.id));
229
+
230
+ const started = await callBackendJson(args, "POST", "/integrations/github/app/install", {
231
+ token,
232
+ json: {},
233
+ });
234
+ assertOk(started, "start GitHub install");
235
+
236
+ const url = started.install_url || started.url;
237
+ if (!url) throw new Error("Backend returned no GitHub install URL.");
238
+ present(url, "install the PreMan GitHub App");
239
+ process.stdout.write("Pick the repositories PreMan may read.\n");
240
+
241
+ const done = await waitFor("the installation", async () => {
242
+ const now = await callBackendJson(args, "GET", "/integrations/github", { token });
243
+ const rows = now.integrations || now.repos || [];
244
+ // Compare against what existed before, so a user who already had repos
245
+ // connected is not told they are done the moment polling starts.
246
+ const fresh = rows.filter((r) => !seen.has(r.id));
247
+ return fresh.length ? fresh : null;
248
+ });
249
+
250
+ if (!done) {
251
+ process.stdout.write("Timed out. Re-run 'preman github' if the install finished.\n");
252
+ return;
253
+ }
254
+ connected(`GitHub connected: ${done.length} repository(ies).`);
255
+ for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Slack
260
+ // ---------------------------------------------------------------------------
261
+
262
+ export async function slackCommand(args) {
263
+ const token = requireKey(args);
264
+
265
+ const before = await callBackendJson(args, "GET", "/slack/connections", { token });
266
+ const seen = new Set((before.connections || []).map((c) => c.id));
267
+
268
+ const started = await callBackendJson(args, "POST", "/slack/install", { token, json: {} });
269
+ assertOk(started, "start Slack install");
270
+
271
+ const url = started.install_url || started.url;
272
+ if (!url) throw new Error("Backend returned no Slack install URL.");
273
+ present(url, "add PreMan to your Slack workspace");
274
+
275
+ const done = await waitFor("the Slack install", async () => {
276
+ const now = await callBackendJson(args, "GET", "/slack/connections", { token });
277
+ const fresh = (now.connections || []).filter((c) => !seen.has(c.id));
278
+ return fresh.length ? fresh : null;
279
+ });
280
+
281
+ if (!done) {
282
+ process.stdout.write("Timed out. Re-run 'preman slack' if the install finished.\n");
283
+ return;
284
+ }
285
+ connected(`Slack connected: ${done[0].team_name || done[0].id}`);
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // The guided run
290
+ // ---------------------------------------------------------------------------
291
+
292
+ async function askYes(question, { assumeYes }) {
293
+ if (assumeYes) return true;
294
+ const answer = (await promptText(`${question} [Y/n]: `)).trim().toLowerCase();
295
+ return answer === "" || answer === "y" || answer === "yes";
296
+ }
297
+
298
+ /**
299
+ * Everything a new account needs, in one pass.
300
+ *
301
+ * Each step is optional and each failure is survivable: a customer who cannot
302
+ * finish Slack today should still leave with AWS streaming, so a step that
303
+ * throws is reported and the run continues rather than unwinding the ones that
304
+ * already worked.
305
+ */
306
+ export async function onboardCommand(commandArgs, { makeArgs, authenticateTerminal, connectCommand }) {
307
+ const args = makeArgs(commandArgs);
308
+ const assumeYes = args.has("--yes");
309
+
310
+ process.stdout.write("PreMan setup\n\n");
311
+
312
+ const creds = await authenticateTerminal(args);
313
+ connected(`Signed in as ${creds.user_email || "your account"}.`);
314
+
315
+ const steps = [
316
+ {
317
+ name: "coding agent",
318
+ question: "Connect your coding agent?",
319
+ run: () => connectCommand([...commandArgs, "--skip-login"]),
320
+ },
321
+ { name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
322
+ { name: "AWS logs", question: "Connect AWS?", run: () => awsCommand(args) },
323
+ { name: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
324
+ ];
325
+
326
+ const done = [];
327
+ const skipped = [];
328
+ const failed = [];
329
+
330
+ for (const step of steps) {
331
+ process.stdout.write(`\n── ${step.name} ──\n`);
332
+ if (!(await askYes(step.question, { assumeYes }))) {
333
+ skipped.push(step.name);
334
+ continue;
335
+ }
336
+ try {
337
+ await step.run();
338
+ done.push(step.name);
339
+ } catch (err) {
340
+ // Report and carry on: a failed Slack install must not cost the customer
341
+ // the AWS connection they just finished.
342
+ failed.push(`${step.name}: ${err.message}`);
343
+ process.stdout.write(`Could not finish ${step.name}: ${err.message}\n`);
344
+ }
345
+ }
346
+
347
+ process.stdout.write("\n── done ──\n");
348
+ // One line per step, marked, so the outcome is scannable rather than prose.
349
+ for (const name of done) process.stdout.write(`${MARK.ok()} ${name}\n`);
350
+ for (const name of skipped) process.stdout.write(`${MARK.skip()} ${name} (skipped)\n`);
351
+ for (const failure of failed) process.stdout.write(`${MARK.fail()} ${failure}\n`);
352
+ process.stdout.write(`\nOpen ${frontendUrl(args)} to see your logs and endpoints.\n`);
353
+ }
354
+
355
+ export const INTEGRATIONS_HELP = `
356
+ Setup options:
357
+ preman onboard Sign in, then connect agent, GitHub, AWS and Slack
358
+ preman aws Connect an AWS account and stream a log group
359
+ preman github Install the PreMan GitHub App
360
+ preman slack Add PreMan to a Slack workspace
361
+
362
+ --yes Accept every step without prompting (onboard)
363
+ --account <id> AWS account id, skips the prompt
364
+ --region <region> AWS region for log groups. Defaults to us-east-1
365
+ --project-id <id> PreMan project to attach the log connector to
366
+ --log-group <name> Log group to stream, skips the picker
367
+ `;
package/bin/shared.js CHANGED
@@ -341,8 +341,11 @@ export function buildServerConfig(args, { pairCode = "" } = {}) {
341
341
  if (pairCode) env.PREMAN_PAIR_CODE = pairCode;
342
342
 
343
343
  return {
344
- command: "npx",
345
- args: ["-y", "premanmcp@latest"],
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", "--"],
346
349
  env,
347
350
  };
348
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.5.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",