premanmcp 0.16.2 → 1.0.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 +40 -371
- package/bin/api_tools.js +46 -7
- package/bin/cli.js +1 -1
- package/bin/desktop.js +155 -22
- package/bin/integrations.js +32 -5
- package/bin/link.js +3 -18
- package/bin/shared.js +20 -1
- package/bin/tests.js +1 -1
- package/bin/verify.js +2 -5
- package/dist/server.d.ts +7 -2
- package/dist/server.js +109 -1843
- package/package.json +10 -15
package/dist/server.js
CHANGED
|
@@ -1,1875 +1,141 @@
|
|
|
1
|
-
|
|
2
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { registerUserAuthFlowTools } from "./user_auth_flow.js";
|
|
5
|
-
import { shareAuthFlowToUi } from "./auth_flow_ui.js";
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
-
import fs from "node:fs/promises";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import os from "node:os";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { randomUUID } from "node:crypto";
|
|
13
|
-
import { MCP_PREVIEW_RESOURCE_URI, RESOURCE_URI_META_KEY, buildConversionPanelHtml, writeMcpPreviewFile, } from "./mcp-preview-panel.js";
|
|
14
|
-
let REPO_CONFIG = null;
|
|
15
|
-
function applyRepoPremanConfig() {
|
|
16
|
-
const candidates = [
|
|
17
|
-
path.join(process.cwd(), ".cursor", "preman-mcp.config.json"),
|
|
18
|
-
path.join(process.cwd(), "preman-mcp.config.json"),
|
|
19
|
-
];
|
|
20
|
-
for (const p of candidates) {
|
|
21
|
-
try {
|
|
22
|
-
if (!existsSync(p))
|
|
23
|
-
continue;
|
|
24
|
-
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
25
|
-
const override = j.PREMAN_CONFIG_OVERRIDE === true || j.PREMAN_CONFIG_OVERRIDE === "true";
|
|
26
|
-
const applied = [];
|
|
27
|
-
for (const [key, val] of Object.entries(j)) {
|
|
28
|
-
if (!key.startsWith("PREMAN_") || key === "PREMAN_CONFIG_OVERRIDE")
|
|
29
|
-
continue;
|
|
30
|
-
if (typeof val !== "string" || !val.trim())
|
|
31
|
-
continue;
|
|
32
|
-
if (!override && process.env[key])
|
|
33
|
-
continue;
|
|
34
|
-
process.env[key] = val.trim();
|
|
35
|
-
applied.push(key);
|
|
36
|
-
}
|
|
37
|
-
REPO_CONFIG = { path: p, override, applied };
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
/* try next path */
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
applyRepoPremanConfig();
|
|
46
|
-
const BACKEND_URL = process.env.PREMAN_BACKEND || "https://api.preman.live";
|
|
47
|
-
const FRONTEND_URL = process.env.PREMAN_FRONTEND || "https://app.preman.live";
|
|
48
|
-
let API_KEY = process.env.PREMAN_API_KEY || "";
|
|
49
|
-
/**
|
|
50
|
-
* Where the URLs this process is using came from.
|
|
1
|
+
/** Transparent stdio -> hosted Streamable HTTP MCP bridge.
|
|
51
2
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* redirected it turns that into a one-line diagnosis.
|
|
3
|
+
* This process deliberately defines no tools or schemas. Initialization,
|
|
4
|
+
* discovery, notifications, and calls are forwarded byte-for-byte at the JSON-
|
|
5
|
+
* RPC layer so the Python service remains the sole public contract authority.
|
|
56
6
|
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
function detectCodingAgent() {
|
|
66
|
-
const forced = (process.env.PREMAN_CODING_AGENT || "").trim().toLowerCase();
|
|
67
|
-
if (forced)
|
|
68
|
-
return forced.replace("-", "_");
|
|
69
|
-
if (process.env.CLAUDECODE || process.env.CLAUDE_CODE)
|
|
70
|
-
return "claude_code";
|
|
71
|
-
if (process.env.CODEX_HOME || process.env.OPENAI_CODEX)
|
|
72
|
-
return "codex";
|
|
73
|
-
if (process.env.CURSOR_AGENT || process.env.CURSOR_TRACE_ID || process.env.CURSOR_SESSION_ID) {
|
|
74
|
-
return "cursor";
|
|
75
|
-
}
|
|
76
|
-
return "cursor";
|
|
77
|
-
}
|
|
78
|
-
/** Prove this MCP session is live to the workbench coding-agent link. */
|
|
79
|
-
async function heartbeatWorkbenchLink() {
|
|
80
|
-
if (!API_KEY)
|
|
81
|
-
return null;
|
|
82
|
-
try {
|
|
83
|
-
const resp = await fetch(`${BACKEND_URL}/workbench/coding-agent/heartbeat`, {
|
|
84
|
-
method: "POST",
|
|
85
|
-
signal: AbortSignal.timeout(5000),
|
|
86
|
-
headers: {
|
|
87
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
88
|
-
"Content-Type": "application/json",
|
|
89
|
-
},
|
|
90
|
-
body: JSON.stringify({
|
|
91
|
-
pair_code: process.env.PREMAN_PAIR_CODE || undefined,
|
|
92
|
-
agent: detectCodingAgent(),
|
|
93
|
-
project_path: process.cwd(),
|
|
94
|
-
client_label: "premanmcp",
|
|
95
|
-
source: "preman_status",
|
|
96
|
-
}),
|
|
97
|
-
});
|
|
98
|
-
const text = await resp.text();
|
|
99
|
-
let data = {};
|
|
100
|
-
try {
|
|
101
|
-
data = text ? JSON.parse(text) : {};
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
data = { detail: text };
|
|
105
|
-
}
|
|
106
|
-
if (!resp.ok) {
|
|
107
|
-
return {
|
|
108
|
-
ok: false,
|
|
109
|
-
status: resp.status,
|
|
110
|
-
detail: data.detail || data.message || text,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
return { ok: true, ...data };
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
let sessionId = "";
|
|
12
|
+
let protocolVersion = "2025-06-18";
|
|
13
|
+
async function credentials() {
|
|
14
|
+
if (process.env.PREMAN_API_KEY) {
|
|
116
15
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
16
|
+
api_key: process.env.PREMAN_API_KEY,
|
|
17
|
+
backend_url: process.env.PREMAN_BACKEND,
|
|
119
18
|
};
|
|
120
19
|
}
|
|
121
|
-
}
|
|
122
|
-
const PREMAN_CONTROL_PLANE_HOSTS = new Set([
|
|
123
|
-
"api.preman.live",
|
|
124
|
-
"preman.live",
|
|
125
|
-
"www.preman.live",
|
|
126
|
-
"app.preman.live",
|
|
127
|
-
]);
|
|
128
|
-
function urlOrigin(value) {
|
|
129
|
-
if (typeof value !== "string" || !value.trim())
|
|
130
|
-
return "";
|
|
131
20
|
try {
|
|
132
|
-
const
|
|
133
|
-
return
|
|
21
|
+
const file = path.join(os.homedir(), ".preman", "credentials.json");
|
|
22
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
134
23
|
}
|
|
135
24
|
catch {
|
|
136
|
-
return
|
|
25
|
+
return {};
|
|
137
26
|
}
|
|
138
27
|
}
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
28
|
+
function endpoint(stored) {
|
|
29
|
+
const exact = (process.env.PREMAN_MCP_URL || "").trim();
|
|
30
|
+
if (exact)
|
|
31
|
+
return exact.replace(/\/+$/, "");
|
|
32
|
+
const backend = (process.env.PREMAN_BACKEND || stored.backend_url || "https://api.preman.live").replace(/\/+$/, "");
|
|
33
|
+
return `${backend}/mcp`;
|
|
34
|
+
}
|
|
35
|
+
function write(message) {
|
|
36
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
148
37
|
}
|
|
149
|
-
function
|
|
150
|
-
if (
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
38
|
+
function rpcError(message, code, detail) {
|
|
39
|
+
if (message.id === undefined)
|
|
40
|
+
return;
|
|
41
|
+
write({
|
|
42
|
+
jsonrpc: "2.0",
|
|
43
|
+
id: message.id,
|
|
44
|
+
error: { code, message: detail.slice(0, 500) },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function parseSse(body) {
|
|
48
|
+
const messages = [];
|
|
49
|
+
for (const block of body.split(/\r?\n\r?\n/)) {
|
|
50
|
+
const data = block
|
|
51
|
+
.split(/\r?\n/)
|
|
52
|
+
.filter((line) => line.startsWith("data:"))
|
|
53
|
+
.map((line) => line.slice(5).trimStart())
|
|
54
|
+
.join("\n");
|
|
55
|
+
if (!data)
|
|
155
56
|
continue;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (typeof row[key] === "string" && row[key].trim()) {
|
|
159
|
-
candidates.push(row[key].trim().replace(/\/+$/, ""));
|
|
160
|
-
}
|
|
57
|
+
try {
|
|
58
|
+
messages.push(JSON.parse(data));
|
|
161
59
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const base = ref.base_url;
|
|
165
|
-
if (typeof base === "string" && base.trim()) {
|
|
166
|
-
candidates.push(base.trim().replace(/\/+$/, ""));
|
|
167
|
-
}
|
|
60
|
+
catch {
|
|
61
|
+
// A malformed upstream event is a transport error, not stdout chatter.
|
|
168
62
|
}
|
|
169
|
-
const origin = urlOrigin(row.url);
|
|
170
|
-
if (origin)
|
|
171
|
-
candidates.push(origin);
|
|
172
63
|
}
|
|
173
|
-
|
|
174
|
-
const nonControl = unique.filter((u) => !isPreManControlPlaneUrl(u));
|
|
175
|
-
if (nonControl.length === 1)
|
|
176
|
-
return nonControl[0];
|
|
177
|
-
if (unique.length === 1)
|
|
178
|
-
return unique[0];
|
|
179
|
-
return "";
|
|
180
|
-
}
|
|
181
|
-
function normalizeFrontendBaseUrl(url) {
|
|
182
|
-
return url.replace(/\/+$/, "");
|
|
183
|
-
}
|
|
184
|
-
/** Origin only (no path). */
|
|
185
|
-
const FRONTEND_BASE = normalizeFrontendBaseUrl(FRONTEND_URL);
|
|
186
|
-
/**
|
|
187
|
-
* Static hosts often 404 on `/endpoints` because only `/` maps to index.html.
|
|
188
|
-
* Cold-load the SPA shell at `/` and pass the client route in the query so the first request always hits index.html.
|
|
189
|
-
*/
|
|
190
|
-
const AGENT_ROUTE_PARAM = "ot_agent_route";
|
|
191
|
-
function buildAgentDashboardUrlForBase(baseUrl, routePath) {
|
|
192
|
-
const base = normalizeFrontendBaseUrl(baseUrl);
|
|
193
|
-
const u = new URL(`${base}/`);
|
|
194
|
-
u.searchParams.set("agent_session", AGENT_SESSION_ID);
|
|
195
|
-
const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
|
|
196
|
-
u.searchParams.set(AGENT_ROUTE_PARAM, p);
|
|
197
|
-
return u.toString();
|
|
198
|
-
}
|
|
199
|
-
function buildAgentDashboardUrl(routePath) {
|
|
200
|
-
return buildAgentDashboardUrlForBase(FRONTEND_BASE, routePath);
|
|
64
|
+
return messages;
|
|
201
65
|
}
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
async function loadStoredCredentials() {
|
|
208
|
-
try {
|
|
209
|
-
const raw = await fs.readFile(CREDENTIALS_FILE, "utf-8");
|
|
210
|
-
const creds = JSON.parse(raw);
|
|
211
|
-
if (creds.api_key && creds.api_key.startsWith("pm_live_"))
|
|
212
|
-
return creds;
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
async function saveCredentials(creds) {
|
|
220
|
-
await fs.mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
221
|
-
await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
222
|
-
console.error(`[PreMan] Credentials saved to ${CREDENTIALS_FILE}`);
|
|
223
|
-
}
|
|
224
|
-
async function clearCredentials() {
|
|
225
|
-
try {
|
|
226
|
-
await fs.unlink(CREDENTIALS_FILE);
|
|
227
|
-
console.error("[PreMan] Credentials cleared");
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
// file didn't exist, that's fine
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
async function verifyApiKey(key) {
|
|
234
|
-
try {
|
|
235
|
-
const resp = await fetch(`${BACKEND_URL}/mcp/whoami`, {
|
|
236
|
-
headers: { Authorization: `Bearer ${key}` },
|
|
237
|
-
});
|
|
238
|
-
if (!resp.ok)
|
|
239
|
-
return { valid: false, reachable: true };
|
|
240
|
-
const data = await resp.json();
|
|
241
|
-
return { valid: true, reachable: true, email: data.email, key_name: data.key_name };
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
return { valid: false, reachable: false };
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
async function initAuth() {
|
|
248
|
-
if (API_KEY) {
|
|
249
|
-
console.error("[PreMan] Using API key from PREMAN_API_KEY env var");
|
|
66
|
+
async function forward(message) {
|
|
67
|
+
const stored = await credentials();
|
|
68
|
+
const apiKey = (stored.api_key || "").trim();
|
|
69
|
+
if (!apiKey) {
|
|
70
|
+
rpcError(message, -32001, "PreMan login required; run `preman login` first");
|
|
250
71
|
return;
|
|
251
72
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
73
|
+
if (message.method === "initialize") {
|
|
74
|
+
const requested = message.params?.protocolVersion;
|
|
75
|
+
if (typeof requested === "string" && requested)
|
|
76
|
+
protocolVersion = requested;
|
|
256
77
|
}
|
|
257
|
-
const check = await verifyApiKey(creds.api_key);
|
|
258
|
-
if (check.valid) {
|
|
259
|
-
API_KEY = creds.api_key;
|
|
260
|
-
console.error(`[PreMan] Authenticated as ${creds.user_email ?? "unknown"} (stored credentials)`);
|
|
261
|
-
}
|
|
262
|
-
else if (!check.reachable) {
|
|
263
|
-
API_KEY = creds.api_key;
|
|
264
|
-
console.error("[PreMan] Backend unreachable — using stored credentials (will verify on first call)");
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
console.error("[PreMan] Stored credentials are invalid or expired. Run `npm exec -y premanmcp@latest -- login` or use preman_login to re-authenticate.");
|
|
268
|
-
await clearCredentials();
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
// Panel HTML: ./mcp-preview-panel.ts (also used by scripts/emit-mcp-preview.mjs)
|
|
272
|
-
// ── Backend proxy ─────────────────────────────────────────────────────
|
|
273
|
-
function requireAuth() {
|
|
274
|
-
if (!API_KEY) {
|
|
275
|
-
throw new Error("Not authenticated. Run `npm exec -y premanmcp@latest -- login`, or use user_auth_* then preman_create_api_key, or run preman_login.");
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
async function callBackend(toolName, args) {
|
|
279
|
-
requireAuth();
|
|
280
78
|
const headers = {
|
|
281
|
-
|
|
282
|
-
|
|
79
|
+
authorization: `Bearer ${apiKey}`,
|
|
80
|
+
accept: "application/json, text/event-stream",
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
"mcp-protocol-version": protocolVersion,
|
|
283
83
|
};
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
84
|
+
if (sessionId)
|
|
85
|
+
headers["mcp-session-id"] = sessionId;
|
|
86
|
+
let response;
|
|
87
|
+
try {
|
|
88
|
+
response = await fetch(endpoint(stored), {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers,
|
|
91
|
+
body: JSON.stringify(message),
|
|
92
|
+
signal: AbortSignal.timeout(360_000),
|
|
93
|
+
});
|
|
292
94
|
}
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
fetch(`${BACKEND_URL}/agent/activity/${AGENT_SESSION_ID}`, {
|
|
297
|
-
method: "POST",
|
|
298
|
-
headers: { "Content-Type": "application/json" },
|
|
299
|
-
body: JSON.stringify({ event_type: eventType, data }),
|
|
300
|
-
}).catch((err) => {
|
|
301
|
-
console.error(`[PreMan] emitActivity(${eventType}) failed: ${err.message}`);
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
/** Build the `_agent_session` block included in every tool response. */
|
|
305
|
-
function agentSessionPayload(dashboardPath = "/endpoints") {
|
|
306
|
-
return {
|
|
307
|
-
_agent_session: {
|
|
308
|
-
session_id: AGENT_SESSION_ID,
|
|
309
|
-
dashboard_url: buildAgentDashboardUrl(dashboardPath),
|
|
310
|
-
},
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Return a structured MCP error response that agents can reliably parse.
|
|
315
|
-
*
|
|
316
|
-
* Every error has the same shape: ``{ error, error_code, _agent_hints }``.
|
|
317
|
-
*/
|
|
318
|
-
function toolError(message, code = "backend_error", hints) {
|
|
319
|
-
const payload = {
|
|
320
|
-
error: message,
|
|
321
|
-
error_code: code,
|
|
322
|
-
};
|
|
323
|
-
if (hints) {
|
|
324
|
-
payload._agent_hints = hints;
|
|
95
|
+
catch (error) {
|
|
96
|
+
rpcError(message, -32000, `Hosted PreMan MCP is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
return;
|
|
325
98
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
function inferErrorCode(msg) {
|
|
333
|
-
const lower = msg.toLowerCase();
|
|
334
|
-
if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("not authenticated"))
|
|
335
|
-
return "auth_required";
|
|
336
|
-
if (lower.includes("404") || lower.includes("not found"))
|
|
337
|
-
return "not_found";
|
|
338
|
-
if (lower.includes("400") || lower.includes("invalid") || lower.includes("missing"))
|
|
339
|
-
return "invalid_input";
|
|
340
|
-
return "backend_error";
|
|
341
|
-
}
|
|
342
|
-
// ── Helper: attach PreMan frontend URL to every tool response ────────
|
|
343
|
-
/** Merge backend payload with `ui.url` pointing at the Endpoints page (for get_endpoints JSON without a separate panel). */
|
|
344
|
-
function enrichEndpointsBrowserUrl(payload) {
|
|
345
|
-
const prevUi = typeof payload.ui === "object" && payload.ui !== null
|
|
346
|
-
? payload.ui
|
|
347
|
-
: {};
|
|
348
|
-
return {
|
|
349
|
-
...payload,
|
|
350
|
-
ui: {
|
|
351
|
-
...prevUi,
|
|
352
|
-
url: buildAgentDashboardUrl("/endpoints"),
|
|
353
|
-
note: "Use Agent Browser: browser_navigate to `ui.url` to open the PreMan Endpoints page (sign in if prompted). Prefer this over the site homepage.",
|
|
354
|
-
},
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
function withFrontendUrl(payload, path) {
|
|
358
|
-
const p = path ?? "/endpoints";
|
|
359
|
-
const prevUi = typeof payload.ui === "object" && payload.ui !== null
|
|
360
|
-
? payload.ui
|
|
361
|
-
: {};
|
|
362
|
-
return {
|
|
363
|
-
content: [{
|
|
364
|
-
type: "text",
|
|
365
|
-
text: JSON.stringify({
|
|
366
|
-
...payload,
|
|
367
|
-
ui: {
|
|
368
|
-
...prevUi,
|
|
369
|
-
url: buildAgentDashboardUrl(p),
|
|
370
|
-
note: "Open this URL in Agent Browser (browser_navigate) to see the PreMan Endpoints page first (sign in if prompted). Prefer this link over the site homepage.",
|
|
371
|
-
},
|
|
372
|
-
}),
|
|
373
|
-
}],
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
// ── MCP Server ────────────────────────────────────────────────────────
|
|
377
|
-
export function createServer() {
|
|
378
|
-
const server = new McpServer({ name: "PreMan", version: "1.0.0" }, {
|
|
379
|
-
instructions: [
|
|
380
|
-
"PreMan is an API testing platform. It discovers, tests, and monitors API endpoints.",
|
|
381
|
-
"",
|
|
382
|
-
"## Tool taxonomy",
|
|
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, 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)",
|
|
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",
|
|
387
|
-
"- **SDK tools**: generate_sdk (builds a client from endpoints PreMan already discovered - takes no OpenAPI file), get_sdk_generation (poll a run), get_sdk_languages (roster + whether the toolchain is present)",
|
|
388
|
-
"- **Auth (API key / PreMan)**: preman_create_api_key (JWT -> saved pm_live_ key), preman_login, preman_login_complete, preman_logout",
|
|
389
|
-
"- **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)",
|
|
390
|
-
"- **Auth to PreMan Playground**: share_user_auth_flow_with_ui pushes signup, verify-otp, login, resend-otp with JSON schemas to the signed-in Playground. Requires preman_login / PREMAN_API_KEY so the session appears in the user's dashboard.",
|
|
391
|
-
"",
|
|
392
|
-
"## Routing — pick the right tool for what the user said",
|
|
393
|
-
"- 'scan / discover / find / list my endpoints' (codebase): use discover_endpoints_from_codebase. NEVER use get_endpoints for this — that returns inventory already saved in PreMan, not source code.",
|
|
394
|
-
"- 'list endpoints already saved in my PreMan dashboard': use get_endpoints. NEVER use discover_endpoints_from_codebase for this.",
|
|
395
|
-
"- 'preview my MCP before deploying': mcp_preview (read-only). 'actually deploy': mcp_deploy.",
|
|
396
|
-
"- 'generate an SDK / client library for my API', 'I use Speakeasy / Fern': generate_sdk. Do NOT ask the user for an OpenAPI file or run a spec export first - PreMan builds the spec from the endpoints it already discovered, and needing a file is the chore this replaces. Pass open_pr=true only when the user asks for a pull request rather than a download.",
|
|
397
|
-
"- '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.",
|
|
398
|
-
"- 'import my Postman/OpenAPI/Bruno/curl collection': import_collection.",
|
|
399
|
-
"- '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.",
|
|
400
|
-
"- '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).",
|
|
401
|
-
"- 'stress / load test X': run_stress_test. Read-only unless the user explicitly authorises writes — never set allow_writes yourself.",
|
|
402
|
-
"- 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.",
|
|
403
|
-
"- '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.",
|
|
404
|
-
"- App-auth flows (signup, OTP, login, change_password) live under the user_auth_* tools and do not need an pm_live_ key.",
|
|
405
|
-
"- 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.",
|
|
406
|
-
"",
|
|
407
|
-
"## Defaults for agents",
|
|
408
|
-
'- Always use format="json" (the default) for structured data. Only use format="text" if the user explicitly asks for human-readable output.',
|
|
409
|
-
"- Do NOT set open_ui=true unless the user asks to see the dashboard.",
|
|
410
|
-
"- endpoints_dashboard is DEPRECATED. Use get_endpoints(include_sessions=true, include_collections=true) instead.",
|
|
411
|
-
"- When opening the PreMan app in a browser, use **ui.url** from tool results (SPA entry `/?agent_session=...&ot_agent_route=/endpoints`, not a bare `/endpoints` path on static hosting). Do **not** start at the site homepage without `ot_agent_route` unless the user explicitly asked for it.",
|
|
412
|
-
"",
|
|
413
|
-
"## Common workflows",
|
|
414
|
-
"1. **Discover then test**: get_endpoints -> pick untested/failing -> test_api for each.",
|
|
415
|
-
"2. **Import then generate**: import_collection (Postman/OpenAPI/curl) -> generate_tests -> test_api to run them.",
|
|
416
|
-
'3. **Coverage audit**: get_coverage on a collection -> generate_tests(coverage_level="comprehensive") for gaps.',
|
|
417
|
-
"4. **Drift detection**: detect_drift with a collection + base_url -> test_api on drifted endpoints.",
|
|
418
|
-
"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.",
|
|
419
|
-
"",
|
|
420
|
-
"## Auth",
|
|
421
|
-
"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.",
|
|
422
|
-
"To drive the backend's **email/JWT** auth (signup, OTP, login, /auth/me), use the user_auth_* tools — they call PREMAN_BACKEND /auth/* directly and do not use the API key.",
|
|
423
|
-
].join("\n"),
|
|
424
|
-
});
|
|
425
|
-
// ── test_api ──────────────────────────────────────────────────────
|
|
426
|
-
server.tool("test_api", "Execute an HTTP request against an API endpoint. Returns structured JSON: status_code, response_time_ms, body, assertions results, and extracted variables. The endpoint is automatically registered in the PreMan registry.", {
|
|
427
|
-
method: z.string().describe("HTTP method (GET, POST, PUT, PATCH, DELETE)"),
|
|
428
|
-
url: z.string().describe("Full URL to test"),
|
|
429
|
-
headers: z.record(z.string()).optional().describe("Request headers"),
|
|
430
|
-
body: z.any().optional().describe("JSON request body"),
|
|
431
|
-
assertions: z.array(z.any()).optional().describe("List of assertions"),
|
|
432
|
-
project_id: z.string().optional().describe("Scope endpoint registration to a project"),
|
|
433
|
-
}, async (args) => {
|
|
434
|
-
try {
|
|
435
|
-
emitActivity("test_started", { method: args.method, url: args.url });
|
|
436
|
-
const result = await callBackend("test_api", args);
|
|
437
|
-
const runId = result?.run_id != null ? String(result.run_id) : "";
|
|
438
|
-
const dashPath = `/endpoints?id=${runId}`;
|
|
439
|
-
emitActivity("test_complete", {
|
|
440
|
-
run_id: runId,
|
|
441
|
-
method: args.method,
|
|
442
|
-
url: args.url,
|
|
443
|
-
status_code: result?.status_code,
|
|
444
|
-
passed: result?.status_code >= 200 && result?.status_code < 300,
|
|
445
|
-
response_time_ms: result?.response_time_ms,
|
|
446
|
-
path: dashPath,
|
|
447
|
-
});
|
|
448
|
-
return withFrontendUrl({
|
|
449
|
-
...result,
|
|
450
|
-
...agentSessionPayload(dashPath),
|
|
451
|
-
_request: { method: args.method, url: args.url, headers: args.headers, body: args.body },
|
|
452
|
-
}, dashPath);
|
|
453
|
-
}
|
|
454
|
-
catch (e) {
|
|
455
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
456
|
-
next_actions: ["Verify the URL and method are correct, then retry. Call get_endpoints to check available endpoints."],
|
|
457
|
-
related_tools: ["get_endpoints"],
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
// ── test_endpoint_by_id ───────────────────────────────────────────
|
|
462
|
-
server.tool("test_endpoint_by_id", "Run test_api against a saved registry endpoint by UUID from get_endpoints (no manual URL assembly). Optional body/headers override the defaults for that call.", {
|
|
463
|
-
endpoint_id: z.string().describe("UUID from get_endpoints endpoints[].id"),
|
|
464
|
-
headers: z.record(z.string()).optional().describe("Request headers"),
|
|
465
|
-
body: z.any().optional().describe("JSON request body (e.g. login credentials)"),
|
|
466
|
-
assertions: z.array(z.any()).optional().describe("List of assertions"),
|
|
467
|
-
extract: z.record(z.string()).optional().describe("json_path -> variable name for chaining"),
|
|
468
|
-
}, async (args) => {
|
|
469
|
-
try {
|
|
470
|
-
emitActivity("test_started", { endpoint_id: args.endpoint_id });
|
|
471
|
-
const result = await callBackend("test_endpoint_by_id", args);
|
|
472
|
-
const runId = result?.run_id != null ? String(result.run_id) : "";
|
|
473
|
-
const dashPath = runId ? `/endpoints?id=${runId}` : "/endpoints";
|
|
474
|
-
emitActivity("test_complete", {
|
|
475
|
-
run_id: runId,
|
|
476
|
-
endpoint_id: args.endpoint_id,
|
|
477
|
-
method: result?.method,
|
|
478
|
-
url: result?.resolved_url,
|
|
479
|
-
status_code: result?.status_code,
|
|
480
|
-
passed: result?.status_code >= 200 && result?.status_code < 300,
|
|
481
|
-
response_time_ms: result?.response_time_ms,
|
|
482
|
-
path: dashPath,
|
|
483
|
-
});
|
|
484
|
-
return withFrontendUrl({
|
|
485
|
-
...result,
|
|
486
|
-
...agentSessionPayload(dashPath),
|
|
487
|
-
_request: {
|
|
488
|
-
endpoint_id: args.endpoint_id,
|
|
489
|
-
resolved_url: result?.resolved_url,
|
|
490
|
-
headers: args.headers,
|
|
491
|
-
body: args.body,
|
|
492
|
-
},
|
|
493
|
-
}, dashPath);
|
|
494
|
-
}
|
|
495
|
-
catch (e) {
|
|
496
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
497
|
-
next_actions: ["Verify the endpoint_id exists by calling get_endpoints first."],
|
|
498
|
-
related_tools: ["get_endpoints", "test_api"],
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
});
|
|
502
|
-
// ── generate_endpoint_tests ───────────────────────────────────────
|
|
503
|
-
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.", {
|
|
504
|
-
request_id: z.string().optional().describe("Saved workbench request id"),
|
|
505
|
-
endpoint_id: z.string().optional().describe("Registry endpoint UUID from get_endpoints"),
|
|
506
|
-
target: z.string().optional().describe("Either kind of id; PreMan resolves it"),
|
|
507
|
-
scenarios: z.array(z.string()).optional().describe("The user's scenario descriptions, one per entry"),
|
|
508
|
-
run: z.boolean().optional().default(true).describe("Execute the generated cases now (read-only by default)"),
|
|
509
|
-
allow_writes: z.boolean().optional().default(false).describe("Permit mutating cases — only when the user authorised it"),
|
|
510
|
-
persist_suite: z.boolean().optional().default(false).describe("Also save as a recurring auto-test suite (paid feature)"),
|
|
511
|
-
max_cases: z.number().optional().default(10).describe("Max generated cases (1-25)"),
|
|
512
|
-
include_code: z.boolean().optional().default(false).describe("Return unit-test files in code_artifacts"),
|
|
513
|
-
test_framework: z.enum(["pytest", "jest"]).optional().default("pytest").describe("Framework for code_artifacts"),
|
|
514
|
-
}, async (args) => {
|
|
99
|
+
const returnedSession = response.headers.get("mcp-session-id");
|
|
100
|
+
if (returnedSession)
|
|
101
|
+
sessionId = returnedSession;
|
|
102
|
+
const body = await response.text();
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
let detail = `Hosted PreMan MCP returned HTTP ${response.status}`;
|
|
515
105
|
try {
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
catch (e) {
|
|
520
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
521
|
-
next_actions: ["Call get_endpoints (include_workbench=true) to find a valid request or endpoint id."],
|
|
522
|
-
related_tools: ["get_endpoints", "run_stress_test"],
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
// ── run_stress_test ───────────────────────────────────────────────
|
|
527
|
-
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.", {
|
|
528
|
-
request_id: z.string().optional().describe("Saved workbench request id"),
|
|
529
|
-
endpoint_id: z.string().optional().describe("Registry endpoint UUID from get_endpoints"),
|
|
530
|
-
target: z.string().optional().describe("Either kind of id; PreMan resolves it"),
|
|
531
|
-
duration_seconds: z.number().optional().default(15).describe("Run length (server caps apply)"),
|
|
532
|
-
rps: z.number().optional().default(5).describe("Target requests per second (server caps apply)"),
|
|
533
|
-
concurrency: z.number().optional().default(5).describe("Max in-flight requests (server caps apply)"),
|
|
534
|
-
allow_writes: z.boolean().optional().default(false).describe("Only when the user authorised stressing a write endpoint"),
|
|
535
|
-
}, async (args) => {
|
|
536
|
-
try {
|
|
537
|
-
const result = await callBackend("run_stress_test", args);
|
|
538
|
-
return withFrontendUrl(result, "/endpoints");
|
|
539
|
-
}
|
|
540
|
-
catch (e) {
|
|
541
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
542
|
-
next_actions: ["Call get_endpoints (include_workbench=true) to find a valid request or endpoint id."],
|
|
543
|
-
related_tools: ["get_endpoints", "generate_endpoint_tests"],
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
// ── register_discovered_endpoints ─────────────────────────────────
|
|
548
|
-
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).", {
|
|
549
|
-
endpoints: z.array(z.record(z.any())).optional().describe("Discovery-shaped endpoint objects (method, path_template, schemas, confidence, …); max 100"),
|
|
550
|
-
endpoint_ids: z.array(z.string()).optional().describe("Alternatively, existing registry ids to set up as runnable requests"),
|
|
551
|
-
project_id: z.string().optional().describe("Scope registration to a project"),
|
|
552
|
-
base_url: z.string().optional().describe("Applied to endpoints that carry none"),
|
|
553
|
-
setup_workbench: z.boolean().optional().default(true).describe("Also create saved workbench requests (default true)"),
|
|
554
|
-
}, async (args) => {
|
|
555
|
-
try {
|
|
556
|
-
const result = await callBackend("register_discovered_endpoints", args);
|
|
557
|
-
return withFrontendUrl(result, "/try");
|
|
558
|
-
}
|
|
559
|
-
catch (e) {
|
|
560
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
561
|
-
next_actions: ["Run discover_endpoints_from_codebase first and pass its endpoints array here."],
|
|
562
|
-
related_tools: ["discover_endpoints_from_codebase", "start_test_campaign", "verify_endpoints_live"],
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
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.", {
|
|
567
|
-
endpoint_ids: z.array(z.string()).optional().describe("Registry ids from register_discovered_endpoints"),
|
|
568
|
-
allow_writes: z.boolean().optional().default(false).describe("Permit POST/PUT/PATCH functional jobs; DELETE stays skipped"),
|
|
569
|
-
}, async (args) => {
|
|
570
|
-
try {
|
|
571
|
-
const result = await callBackend("start_test_campaign", args);
|
|
572
|
-
return withFrontendUrl(result, "/try");
|
|
573
|
-
}
|
|
574
|
-
catch (e) {
|
|
575
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
576
|
-
next_actions: ["Call register_discovered_endpoints first, then pass its endpoint_ids."],
|
|
577
|
-
related_tools: ["register_discovered_endpoints", "get_endpoints"],
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
});
|
|
581
|
-
// ── create-then-delete contracts ──────────────────────────────────
|
|
582
|
-
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.", {
|
|
583
|
-
contracts: z
|
|
584
|
-
.array(z.object({
|
|
585
|
-
create_endpoint_id: z.string().describe("Registry id of the POST"),
|
|
586
|
-
cleanup_endpoint_id: z.string().describe("Registry id of the DELETE that undoes it"),
|
|
587
|
-
id_source: z.string().optional().describe("Where the new id sits in the create response, e.g. 'json.id' or 'json.data.id'"),
|
|
588
|
-
evidence: z.string().optional().describe("Why this pairing is correct (route/model you read)"),
|
|
589
|
-
}))
|
|
590
|
-
.describe("Pairings to propose; max 100"),
|
|
591
|
-
}, async (args) => {
|
|
592
|
-
try {
|
|
593
|
-
const result = await callBackend("propose_lifecycle_contracts", args);
|
|
594
|
-
return withFrontendUrl(result, "/try");
|
|
595
|
-
}
|
|
596
|
-
catch (e) {
|
|
597
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
598
|
-
next_actions: ["Register the endpoints first so both ids exist, then propose the pairing."],
|
|
599
|
-
related_tools: ["register_discovered_endpoints", "approve_lifecycle_contracts"],
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
});
|
|
603
|
-
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.", {
|
|
604
|
-
contract_ids: z.array(z.string()).describe("Ids from propose_lifecycle_contracts or register_discovered_endpoints' lifecycle_proposals"),
|
|
605
|
-
approve: z.boolean().optional().default(true).describe("False records a rejection so the pairing stops being re-proposed"),
|
|
606
|
-
}, async (args) => {
|
|
607
|
-
try {
|
|
608
|
-
const result = await callBackend("approve_lifecycle_contracts", args);
|
|
609
|
-
return withFrontendUrl(result, "/try");
|
|
610
|
-
}
|
|
611
|
-
catch (e) {
|
|
612
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
613
|
-
next_actions: ["Confirm with the user which POST endpoints they want exercised for real, then retry."],
|
|
614
|
-
related_tools: ["propose_lifecycle_contracts", "start_test_campaign"],
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
});
|
|
618
|
-
// ── get_endpoints ─────────────────────────────────────────────────
|
|
619
|
-
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.", {
|
|
620
|
-
status: z.string().optional().describe("Filter: tested, needed, draft"),
|
|
621
|
-
method: z.string().optional().describe("Filter by HTTP method"),
|
|
622
|
-
include_sessions: z.boolean().optional().default(false).describe("Include recent MCP session test results"),
|
|
623
|
-
include_collections: z.boolean().optional().default(false).describe("Include imported collection endpoints"),
|
|
624
|
-
include_schemas: z.boolean().optional().default(false).describe("Include request/response schemas per endpoint (default false to save tokens)"),
|
|
625
|
-
include_workbench: z.boolean().optional().default(false).describe("Also list the default workspace's saved runnable requests"),
|
|
626
|
-
limit: z.number().optional().default(50).describe("Max endpoints per page (default 50)"),
|
|
627
|
-
offset: z.number().optional().default(0).describe("Pagination offset"),
|
|
628
|
-
project_id: z.string().optional().describe("Scope to a specific project"),
|
|
629
|
-
format: z.enum(["json", "text"]).optional().default("json").describe("'json' (default, structured) or 'text' (deprecated human-friendly)"),
|
|
630
|
-
open_ui: z.boolean().optional().default(false).describe("Also open the visual dashboard (default: false)"),
|
|
631
|
-
}, async (args) => {
|
|
632
|
-
try {
|
|
633
|
-
emitActivity("navigate", { path: "/endpoints", query_params: { status: args.status, method: args.method } });
|
|
634
|
-
const result = await callBackend("get_endpoints", args);
|
|
635
|
-
const asRecord = result;
|
|
636
|
-
if (args.open_ui) {
|
|
637
|
-
return withFrontendUrl(enrichEndpointsBrowserUrl(asRecord), "/endpoints");
|
|
638
|
-
}
|
|
639
|
-
if (args.format === "text" && typeof result === "string") {
|
|
640
|
-
return { content: [{ type: "text", text: result }] };
|
|
641
|
-
}
|
|
642
|
-
const enriched = typeof result === "object" && result !== null && !Array.isArray(result)
|
|
643
|
-
? { ...enrichEndpointsBrowserUrl(asRecord), ...agentSessionPayload("/endpoints") }
|
|
644
|
-
: result;
|
|
645
|
-
return {
|
|
646
|
-
content: [{
|
|
647
|
-
type: "text",
|
|
648
|
-
text: typeof enriched === "string" ? enriched : JSON.stringify(enriched),
|
|
649
|
-
}],
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
catch (e) {
|
|
653
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
654
|
-
next_actions: ["If authentication failed, call preman_login first. Otherwise check that the backend is running."],
|
|
655
|
-
related_tools: ["preman_login"],
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
});
|
|
659
|
-
// ── generate_tests ────────────────────────────────────────────────
|
|
660
|
-
server.tool("generate_tests", "Generate a .ot.yaml test suite from a natural language description. Returns the YAML content, parsed collection spec, and a collection_id if saved to the dashboard. Use get_coverage afterwards to check gaps.", {
|
|
661
|
-
description: z.string().describe("What to test in natural language"),
|
|
662
|
-
endpoint_url: z.string().optional().describe("Optional: full URL of the endpoint to test"),
|
|
663
|
-
method: z.string().optional().describe("HTTP method (GET, POST, PUT, PATCH, DELETE)"),
|
|
664
|
-
coverage_level: z.enum(["standard", "comprehensive", "security"]).optional().default("standard").describe("Coverage depth"),
|
|
665
|
-
project_id: z.string().optional().describe("Scope generated collection to a project"),
|
|
666
|
-
endpoint_ids: z.array(z.string()).optional().describe("Registry endpoint UUIDs to generate tests for (overrides endpoint_url/method)"),
|
|
667
|
-
}, async (args) => {
|
|
668
|
-
try {
|
|
669
|
-
const result = await callBackend("generate_tests", args);
|
|
670
|
-
const payload = typeof result === "string" ? JSON.parse(result) : result;
|
|
671
|
-
const collectionId = payload?.collection_id ?? "";
|
|
672
|
-
const dashPath = collectionId ? `/collections?id=${collectionId}` : "/collections";
|
|
673
|
-
emitActivity("navigate", { path: dashPath });
|
|
674
|
-
emitActivity("data_refresh", { scope: "collections" });
|
|
675
|
-
return withFrontendUrl({ ...payload, ...agentSessionPayload(dashPath) }, dashPath);
|
|
676
|
-
}
|
|
677
|
-
catch (e) {
|
|
678
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
679
|
-
next_actions: ["Provide a more specific description, or use endpoint_ids for registry-based generation."],
|
|
680
|
-
related_tools: ["import_collection", "test_api"],
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
});
|
|
684
|
-
// ── get_coverage ──────────────────────────────────────────────────
|
|
685
|
-
server.tool("get_coverage", "Analyze API test coverage for a collection. Returns overall_score and top_priorities by default. Set include_endpoint_details=true for per-endpoint breakdown.", {
|
|
686
|
-
collection_yaml: z.string().optional().describe("Raw .ot.yaml content to analyze"),
|
|
687
|
-
collection_id: z.string().optional().describe("ID of a saved collection from the dashboard"),
|
|
688
|
-
include_endpoint_details: z.boolean().optional().default(false).describe("Include per-endpoint analysis (default false to save tokens)"),
|
|
689
|
-
}, async (args) => {
|
|
690
|
-
try {
|
|
691
|
-
const collectionId = args.collection_id ?? "";
|
|
692
|
-
const dashPath = collectionId ? `/collections?id=${collectionId}&view=coverage` : "/collections";
|
|
693
|
-
emitActivity("navigate", { path: dashPath });
|
|
694
|
-
const result = await callBackend("get_coverage", args);
|
|
695
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
|
|
696
|
-
}
|
|
697
|
-
catch (e) {
|
|
698
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
699
|
-
next_actions: ["Provide a collection_id or collection_yaml. Call list_collections to find available collections."],
|
|
700
|
-
related_tools: ["import_collection", "list_collections"],
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
// ── generate_sdk ──────────────────────────────────────────────────
|
|
705
|
-
//
|
|
706
|
-
// Straight to the REST API rather than through /mcp/call-tool, because the
|
|
707
|
-
// SDK endpoints take the same pm_live_ key and adding a backend tool wrapper
|
|
708
|
-
// would buy nothing. The spec is deliberately not a parameter: PreMan already
|
|
709
|
-
// holds one for every connected repository, and asking an agent to supply the
|
|
710
|
-
// document describing the API PreMan has been scanning is the exact chore
|
|
711
|
-
// this feature exists to remove.
|
|
712
|
-
async function sdkFetch(method, path, body) {
|
|
713
|
-
requireAuth();
|
|
714
|
-
const resp = await fetch(`${BACKEND_URL}${path}`, {
|
|
715
|
-
method,
|
|
716
|
-
headers: {
|
|
717
|
-
"Content-Type": "application/json",
|
|
718
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
719
|
-
},
|
|
720
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
721
|
-
});
|
|
722
|
-
const text = await resp.text();
|
|
723
|
-
let parsed = null;
|
|
724
|
-
try {
|
|
725
|
-
parsed = text ? JSON.parse(text) : null;
|
|
106
|
+
const parsed = JSON.parse(body);
|
|
107
|
+
detail = String(parsed.error_description || parsed.detail || detail);
|
|
726
108
|
}
|
|
727
109
|
catch {
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
if (!resp.ok) {
|
|
731
|
-
const detail = (parsed && (parsed.detail?.[0]?.msg || parsed.detail)) || text || resp.statusText;
|
|
732
|
-
throw new Error(`${resp.status} ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
|
|
733
|
-
}
|
|
734
|
-
return parsed;
|
|
735
|
-
}
|
|
736
|
-
/** Resolve a repo name like "acme/api" to a connected integration id. */
|
|
737
|
-
async function resolveIntegration(hint) {
|
|
738
|
-
const rows = await sdkFetch("GET", "/integrations/github");
|
|
739
|
-
if (!Array.isArray(rows) || rows.length === 0) {
|
|
740
|
-
throw new Error("no GitHub repository is connected; connect one before generating an SDK");
|
|
741
|
-
}
|
|
742
|
-
if (!hint) {
|
|
743
|
-
if (rows.length === 1)
|
|
744
|
-
return rows[0];
|
|
745
|
-
const names = rows.map((r) => r.repo_url).join(", ");
|
|
746
|
-
throw new Error(`several repositories are connected, so name one with repo: ${names}`);
|
|
110
|
+
// Never echo arbitrary HTML or credentials from an upstream failure.
|
|
747
111
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
rows.find((r) => String(r.repo_url || "").toLowerCase().includes(needle));
|
|
751
|
-
if (!hit) {
|
|
752
|
-
throw new Error(`no connected repository matches "${hint}"; connected: ${rows
|
|
753
|
-
.map((r) => r.repo_url)
|
|
754
|
-
.join(", ")}`);
|
|
755
|
-
}
|
|
756
|
-
return hit;
|
|
112
|
+
rpcError(message, -32000, detail);
|
|
113
|
+
return;
|
|
757
114
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
.default(false)
|
|
775
|
-
.describe("Open a pull request against the repository instead of returning an archive. Requires the repository to have opted in to automatic pull requests."),
|
|
776
|
-
wait_seconds: z
|
|
777
|
-
.number()
|
|
778
|
-
.optional()
|
|
779
|
-
.default(150)
|
|
780
|
-
.describe("How long to wait for the run before handing back an id to poll. 0 returns immediately."),
|
|
781
|
-
}, async (args) => {
|
|
782
|
-
try {
|
|
783
|
-
const integration = await resolveIntegration(args.repo);
|
|
784
|
-
const integrationId = String(integration.id);
|
|
785
|
-
// Read the spec first so an empty or fully excluded surface is reported
|
|
786
|
-
// as that, rather than as a generation which fails a minute later.
|
|
787
|
-
const preview = await sdkFetch("GET", `/sdk/integrations/${integrationId}/spec`);
|
|
788
|
-
const run = await sdkFetch("POST", "/sdk/generate", {
|
|
789
|
-
integration_id: integrationId,
|
|
790
|
-
languages: args.languages ?? ["python"],
|
|
791
|
-
halves: args.halves ?? ["client"],
|
|
792
|
-
open_pr: args.open_pr ?? false,
|
|
793
|
-
});
|
|
794
|
-
const dashPath = "/endpoints";
|
|
795
|
-
emitActivity("navigate", { path: dashPath });
|
|
796
|
-
const deadline = Date.now() + Math.max(0, args.wait_seconds ?? 150) * 1000;
|
|
797
|
-
let current = run;
|
|
798
|
-
while (Date.now() < deadline &&
|
|
799
|
-
!["succeeded", "failed", "cancelled"].includes(String(current.status))) {
|
|
800
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
801
|
-
current = await sdkFetch("GET", `/sdk/generations/${run.id}`);
|
|
802
|
-
}
|
|
803
|
-
const done = String(current.status) === "succeeded";
|
|
804
|
-
return withFrontendUrl({
|
|
805
|
-
...agentSessionPayload(dashPath),
|
|
806
|
-
generation_id: run.id,
|
|
807
|
-
status: current.status,
|
|
808
|
-
repo: integration.repo_url,
|
|
809
|
-
operations: preview.operation_count,
|
|
810
|
-
endpoints_published: preview.endpoint_count,
|
|
811
|
-
endpoints_excluded: preview.excluded_count,
|
|
812
|
-
spec_warnings: preview.warnings ?? [],
|
|
813
|
-
languages: current.languages,
|
|
814
|
-
files: current.files ?? [],
|
|
815
|
-
pr_url: current.pr_url ?? null,
|
|
816
|
-
error: current.error ?? null,
|
|
817
|
-
download_url: done
|
|
818
|
-
? `${BACKEND_URL}/sdk/generations/${run.id}/download`
|
|
819
|
-
: null,
|
|
820
|
-
_agent_hints: done
|
|
821
|
-
? {
|
|
822
|
-
next_actions: [
|
|
823
|
-
`Download the archive: curl -H "Authorization: Bearer $PREMAN_API_KEY" -o sdk.zip ${BACKEND_URL}/sdk/generations/${run.id}/download`,
|
|
824
|
-
],
|
|
825
|
-
}
|
|
826
|
-
: {
|
|
827
|
-
next_actions: [
|
|
828
|
-
`Still ${current.status}. Poll with get_sdk_generation(generation_id="${run.id}").`,
|
|
829
|
-
],
|
|
830
|
-
},
|
|
831
|
-
}, dashPath);
|
|
832
|
-
}
|
|
833
|
-
catch (e) {
|
|
834
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
835
|
-
next_actions: [
|
|
836
|
-
"Confirm a repository is connected and has discovered endpoints (get_endpoints).",
|
|
837
|
-
"If open_pr was true, the repository must have opted in to automatic pull requests.",
|
|
838
|
-
],
|
|
839
|
-
related_tools: ["get_endpoints", "get_sdk_languages", "get_sdk_generation"],
|
|
840
|
-
});
|
|
841
|
-
}
|
|
842
|
-
});
|
|
843
|
-
// ── get_sdk_generation ────────────────────────────────────────────
|
|
844
|
-
server.tool("get_sdk_generation", "Poll one SDK generation run started by generate_sdk. Returns status and, once finished, the download URL.", {
|
|
845
|
-
generation_id: z.string().describe("The generation_id returned by generate_sdk"),
|
|
846
|
-
}, async (args) => {
|
|
847
|
-
try {
|
|
848
|
-
const run = await sdkFetch("GET", `/sdk/generations/${args.generation_id}`);
|
|
849
|
-
const done = String(run.status) === "succeeded";
|
|
850
|
-
return {
|
|
851
|
-
content: [
|
|
852
|
-
{
|
|
853
|
-
type: "text",
|
|
854
|
-
text: JSON.stringify({
|
|
855
|
-
generation_id: run.id,
|
|
856
|
-
status: run.status,
|
|
857
|
-
attempts: run.attempts,
|
|
858
|
-
error: run.error ?? null,
|
|
859
|
-
pr_url: run.pr_url ?? null,
|
|
860
|
-
files: run.files ?? [],
|
|
861
|
-
download_url: done
|
|
862
|
-
? `${BACKEND_URL}/sdk/generations/${run.id}/download`
|
|
863
|
-
: null,
|
|
864
|
-
}, null, 2),
|
|
865
|
-
},
|
|
866
|
-
],
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
catch (e) {
|
|
870
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
871
|
-
related_tools: ["generate_sdk"],
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
// ── get_sdk_languages ─────────────────────────────────────────────
|
|
876
|
-
server.tool("get_sdk_languages", "Which languages this PreMan deployment can generate, and whether the generator toolchain is present at all.", {}, async () => {
|
|
877
|
-
try {
|
|
878
|
-
const roster = await sdkFetch("GET", "/sdk/languages");
|
|
879
|
-
return {
|
|
880
|
-
content: [
|
|
881
|
-
{ type: "text", text: JSON.stringify(roster, null, 2) },
|
|
882
|
-
],
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
catch (e) {
|
|
886
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
887
|
-
related_tools: ["generate_sdk"],
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
});
|
|
891
|
-
// ── import_collection ─────────────────────────────────────────────
|
|
892
|
-
server.tool("import_collection", "Import a Postman, OpenAPI, Bruno, or curl collection. Converts to .ot.yaml and saves. Returns slim summary (name, endpoint list, collection_id). Set include_yaml=true to get the full YAML string.", {
|
|
893
|
-
content: z.string().describe("Raw collection content (Postman JSON, OpenAPI YAML, Bruno .bru, curl command)"),
|
|
894
|
-
format_hint: z.enum(["postman", "openapi", "bruno", "curl"]).optional().describe("Format override (auto-detected if omitted)"),
|
|
895
|
-
include_yaml: z.boolean().optional().default(false).describe("Include the full .ot.yaml string in the response (default false to save tokens)"),
|
|
896
|
-
project_id: z.string().optional().describe("Scope imported collection to a project"),
|
|
897
|
-
}, async (args) => {
|
|
898
|
-
try {
|
|
899
|
-
const result = await callBackend("import_collection", args);
|
|
900
|
-
const collectionId = result?.collection_id ?? "";
|
|
901
|
-
const dashPath = collectionId ? `/collections?id=${collectionId}` : "/collections";
|
|
902
|
-
emitActivity("navigate", { path: dashPath });
|
|
903
|
-
emitActivity("data_refresh", { scope: "collections" });
|
|
904
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
|
|
905
|
-
}
|
|
906
|
-
catch (e) {
|
|
907
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
908
|
-
next_actions: ["Check that the content is valid Postman JSON, OpenAPI YAML, Bruno .bru, or curl. Try format_hint to force detection."],
|
|
909
|
-
related_tools: ["get_endpoints"],
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
});
|
|
913
|
-
// ── migrate_from_postman ──────────────────────────────────────────
|
|
914
|
-
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.", {
|
|
915
|
-
content: z.string().optional().describe("Raw Postman collection export (v2.x JSON)"),
|
|
916
|
-
collection_id: z.string().optional().describe("Pull from a connected Postman workspace instead of pasting content"),
|
|
917
|
-
environment: z.string().optional().describe("Raw Postman environment export (JSON)"),
|
|
918
|
-
name: z.string().optional().describe("Override the collection name"),
|
|
919
|
-
project_id: z.string().optional().describe("Scope the migration to a project"),
|
|
920
|
-
activate: z.boolean().optional().default(false).describe("Start the suites and create alert rules. Leave false to review first."),
|
|
921
|
-
channel_ids: z.array(z.string()).optional().describe("Alert channels to notify. Empty still records events and opens fix tasks."),
|
|
922
|
-
autofix_enabled: z.boolean().optional().default(false).describe("Let a failure dispatch to the coding agent automatically"),
|
|
923
|
-
include_yaml: z.boolean().optional().default(false).describe("Include the full .ot.yaml string (default false to save tokens)"),
|
|
924
|
-
}, async (args) => {
|
|
925
|
-
try {
|
|
926
|
-
const result = await callBackend("migrate_from_postman", args);
|
|
927
|
-
const collectionId = result?.collection_id ?? "";
|
|
928
|
-
const dashPath = collectionId ? `/collections?id=${collectionId}` : "/collections";
|
|
929
|
-
emitActivity("navigate", { path: dashPath });
|
|
930
|
-
emitActivity("data_refresh", { scope: "collections" });
|
|
931
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
|
|
932
|
-
}
|
|
933
|
-
catch (e) {
|
|
934
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
935
|
-
next_actions: [
|
|
936
|
-
"Provide either content (a Postman v2.x export) or collection_id from a connected Postman workspace.",
|
|
937
|
-
"Connect a workspace at /integrations/postman/connect to use collection_id.",
|
|
938
|
-
],
|
|
939
|
-
related_tools: ["import_collection", "get_endpoints"],
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
});
|
|
943
|
-
// ── recover_assertions ────────────────────────────────────────────
|
|
944
|
-
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).", {
|
|
945
|
-
collection_id: z.string().describe("The collection the import created"),
|
|
946
|
-
endpoint_id: z.string().describe("entries[].endpoint_id from the migration report (or the endpoint name)"),
|
|
947
|
-
assertions: z.array(z.record(z.any())).describe("The converted assertions"),
|
|
948
|
-
}, async (args) => {
|
|
949
|
-
try {
|
|
950
|
-
const result = await callBackend("recover_assertions", args);
|
|
951
|
-
emitActivity("data_refresh", { scope: "collections" });
|
|
952
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
953
|
-
}
|
|
954
|
-
catch (e) {
|
|
955
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
956
|
-
next_actions: [
|
|
957
|
-
"Check collection_id and endpoint_id against the migration_report entries.",
|
|
958
|
-
"Each assertion must use the documented fields: status, status_not, response_time_ms, json_paths.",
|
|
959
|
-
],
|
|
960
|
-
related_tools: ["get_collection", "migrate_from_postman"],
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
});
|
|
964
|
-
// ── detect_drift ──────────────────────────────────────────────────
|
|
965
|
-
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.", {
|
|
966
|
-
base_url: z.string().describe("Base URL to test against (e.g. https://api.example.com)"),
|
|
967
|
-
collection_yaml: z.string().optional().describe("Raw .ot.yaml content"),
|
|
968
|
-
collection_id: z.string().optional().describe("ID of a saved collection from the dashboard"),
|
|
969
|
-
headers: z.record(z.string()).optional().describe("Request headers applied to all drift-check requests (e.g. auth tokens)"),
|
|
970
|
-
}, async (args) => {
|
|
971
|
-
try {
|
|
972
|
-
emitActivity("navigate", { path: "/endpoints", query_params: { view: "drift" } });
|
|
973
|
-
const result = await callBackend("detect_drift", args);
|
|
974
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload("/endpoints?view=drift") }, "/endpoints?view=drift");
|
|
975
|
-
}
|
|
976
|
-
catch (e) {
|
|
977
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
978
|
-
next_actions: ["Provide a base_url and either a collection_id or collection_yaml. Call list_collections to find saved collections."],
|
|
979
|
-
related_tools: ["import_collection", "test_api", "list_collections"],
|
|
980
|
-
});
|
|
981
|
-
}
|
|
982
|
-
});
|
|
983
|
-
// ── run_tests ─────────────────────────────────────────────────────
|
|
984
|
-
server.tool("run_tests", "Batch-run multiple API endpoints in one call. More efficient than calling test_api N times. Returns per-endpoint results with pass/fail, status codes, and response times.", {
|
|
985
|
-
endpoints: z.array(z.object({
|
|
986
|
-
method: z.string().describe("HTTP method"),
|
|
987
|
-
path: z.string().describe("URL path or full URL"),
|
|
988
|
-
base_url: z.string().optional().describe("Base URL override for this endpoint"),
|
|
989
|
-
})).describe("List of endpoints to test"),
|
|
990
|
-
base_url: z.string().optional().describe("Fallback base URL for endpoints without one"),
|
|
991
|
-
headers: z.record(z.string()).optional().describe("Request headers applied to all requests"),
|
|
992
|
-
}, async (args) => {
|
|
993
|
-
try {
|
|
994
|
-
emitActivity("test_started", { tool: "run_tests", endpoint_count: args.endpoints.length });
|
|
995
|
-
const result = await callBackend("run_api_tests", args);
|
|
996
|
-
emitActivity("test_complete", { tool: "run_tests", total: result?.total, passed: result?.passed, failed: result?.failed });
|
|
997
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload("/endpoints") }, "/endpoints");
|
|
998
|
-
}
|
|
999
|
-
catch (e) {
|
|
1000
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1001
|
-
next_actions: ["Verify the endpoints array has valid method and path fields. Use test_api for individual endpoint debugging."],
|
|
1002
|
-
related_tools: ["test_api", "get_endpoints"],
|
|
1003
|
-
});
|
|
1004
|
-
}
|
|
1005
|
-
});
|
|
1006
|
-
// ── list_collections ──────────────────────────────────────────────
|
|
1007
|
-
server.tool("list_collections", "List saved collections (Postman, OpenAPI, curl imports). Returns id, name, format, created_at for each. Use get_collection for full details.", {
|
|
1008
|
-
project_id: z.string().optional().describe("Scope to a specific project"),
|
|
1009
|
-
}, async (args) => {
|
|
1010
|
-
try {
|
|
1011
|
-
const result = await callBackend("list_collections", args);
|
|
1012
|
-
emitActivity("navigate", { path: "/collections" });
|
|
1013
|
-
return {
|
|
1014
|
-
content: [{ type: "text", text: JSON.stringify({ ...result, ...agentSessionPayload("/collections") }) }],
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
catch (e) {
|
|
1018
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1019
|
-
next_actions: ["If no collections exist, import one with import_collection."],
|
|
1020
|
-
related_tools: ["import_collection"],
|
|
1021
|
-
});
|
|
1022
|
-
}
|
|
1023
|
-
});
|
|
1024
|
-
// ── get_collection ───────────────────────────────────────────────
|
|
1025
|
-
server.tool("get_collection", "Get details for a single collection by ID. Returns metadata and endpoint list. Set include_content=true to also get the raw YAML string.", {
|
|
1026
|
-
collection_id: z.string().describe("UUID of the collection"),
|
|
1027
|
-
include_content: z.boolean().optional().default(false).describe("Include the raw YAML content (default false to save tokens)"),
|
|
1028
|
-
}, async (args) => {
|
|
1029
|
-
try {
|
|
1030
|
-
const result = await callBackend("get_collection", args);
|
|
1031
|
-
const collectionId = result?.id ?? args.collection_id;
|
|
1032
|
-
const dashPath = `/collections?id=${collectionId}`;
|
|
1033
|
-
emitActivity("navigate", { path: dashPath });
|
|
1034
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
|
|
1035
|
-
}
|
|
1036
|
-
catch (e) {
|
|
1037
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1038
|
-
next_actions: ["Verify the collection_id by calling list_collections first."],
|
|
1039
|
-
related_tools: ["list_collections"],
|
|
1040
|
-
});
|
|
1041
|
-
}
|
|
1042
|
-
});
|
|
1043
|
-
// ── delete_collection ────────────────────────────────────────────
|
|
1044
|
-
server.tool("delete_collection", "Soft-delete a collection by ID. The collection is archived (is_active=false) and no longer appears in list_collections.", {
|
|
1045
|
-
collection_id: z.string().describe("UUID of the collection to delete"),
|
|
1046
|
-
}, async (args) => {
|
|
1047
|
-
try {
|
|
1048
|
-
const result = await callBackend("delete_collection", args);
|
|
1049
|
-
emitActivity("data_refresh", { scope: "collections" });
|
|
1050
|
-
return {
|
|
1051
|
-
content: [{ type: "text", text: JSON.stringify({ ...result, ...agentSessionPayload("/collections") }) }],
|
|
1052
|
-
};
|
|
1053
|
-
}
|
|
1054
|
-
catch (e) {
|
|
1055
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1056
|
-
next_actions: ["Verify the collection_id by calling list_collections first."],
|
|
1057
|
-
related_tools: ["list_collections"],
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
});
|
|
1061
|
-
// ── list_runs ────────────────────────────────────────────────────
|
|
1062
|
-
server.tool("list_runs", "List recent test runs with summary metadata. Heavy fields (logs, video paths) are stripped to save tokens.", {
|
|
1063
|
-
limit: z.number().optional().default(20).describe("Max runs to return (default 20)"),
|
|
1064
|
-
}, async (args) => {
|
|
1065
|
-
try {
|
|
1066
|
-
const result = await callBackend("list_runs", args);
|
|
1067
|
-
emitActivity("navigate", { path: "/runs" });
|
|
1068
|
-
return withFrontendUrl({ ...result, ...agentSessionPayload("/runs") }, "/runs");
|
|
1069
|
-
}
|
|
1070
|
-
catch (e) {
|
|
1071
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1072
|
-
next_actions: ["If no runs exist, create one with test_api or run_tests."],
|
|
1073
|
-
related_tools: ["test_api", "run_tests"],
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
});
|
|
1077
|
-
// ── endpoints_dashboard ───────────────────────────────────────────────
|
|
1078
|
-
server.tool("endpoints_dashboard", "[DEPRECATED -- use get_endpoints with include_sessions=true, include_collections=true instead] Return ALL endpoints aggregated from registry, MCP sessions, and collections.", {
|
|
1079
|
-
filter_method: z.string().optional().describe("Filter by HTTP method: GET, POST, PUT, PATCH, DELETE"),
|
|
1080
|
-
filter_source: z.string().optional().describe("Filter by source: 'collection', 'mcp', or 'project'"),
|
|
1081
|
-
project_id: z.string().optional().describe("Scope to a specific project"),
|
|
1082
|
-
format: z.enum(["json", "text"]).optional().default("json").describe("'json' (default, structured) or 'text' (deprecated terminal UI)"),
|
|
1083
|
-
open_ui: z.boolean().optional().default(false).describe("Also open the visual dashboard (default: false)"),
|
|
1084
|
-
}, async (args) => {
|
|
1085
|
-
try {
|
|
1086
|
-
emitActivity("navigate", { path: "/endpoints" });
|
|
1087
|
-
const result = await callBackend("endpoints_dashboard", args);
|
|
1088
|
-
const asRecord = result;
|
|
1089
|
-
if (args.open_ui) {
|
|
1090
|
-
return withFrontendUrl(enrichEndpointsBrowserUrl(asRecord), "/endpoints");
|
|
1091
|
-
}
|
|
1092
|
-
if (args.format === "text" && typeof result === "string") {
|
|
1093
|
-
return { content: [{ type: "text", text: result }] };
|
|
1094
|
-
}
|
|
1095
|
-
const enriched = typeof result === "object" && result !== null && !Array.isArray(result)
|
|
1096
|
-
? { ...enrichEndpointsBrowserUrl(asRecord), ...agentSessionPayload("/endpoints") }
|
|
1097
|
-
: result;
|
|
1098
|
-
return {
|
|
1099
|
-
content: [{
|
|
1100
|
-
type: "text",
|
|
1101
|
-
text: typeof enriched === "string" ? enriched : JSON.stringify(enriched),
|
|
1102
|
-
}],
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
catch (e) {
|
|
1106
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1107
|
-
next_actions: ["DEPRECATED: Use get_endpoints instead."],
|
|
1108
|
-
related_tools: ["get_endpoints"],
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1111
|
-
});
|
|
1112
|
-
// ── preman_create_api_key ──────────────────────────────────────────
|
|
1113
|
-
// Lets agents complete signup/login entirely in the IDE:
|
|
1114
|
-
// user_auth_* returns a JWT, then this tool mints and stores the pm_live_ key.
|
|
1115
|
-
server.tool("preman_create_api_key", "Mint and save a PreMan pm_live_ API key using a JWT from user_auth_login, user_auth_verify_otp, or user_auth_set_password. Use this to finish account setup without opening the website.", {
|
|
1116
|
-
access_token: z.string().describe("JWT returned by user_auth_login, user_auth_verify_otp, or user_auth_set_password"),
|
|
1117
|
-
name: z.string().optional().describe("API key name. Defaults to this MCP device name."),
|
|
1118
|
-
}, async (args) => {
|
|
1119
|
-
try {
|
|
1120
|
-
const name = args.name || `${os.hostname()} MCP`;
|
|
1121
|
-
const keyResp = await fetch(`${BACKEND_URL}/api-keys`, {
|
|
1122
|
-
method: "POST",
|
|
1123
|
-
headers: {
|
|
1124
|
-
"Content-Type": "application/json",
|
|
1125
|
-
Authorization: `Bearer ${args.access_token}`,
|
|
1126
|
-
},
|
|
1127
|
-
body: JSON.stringify({ name }),
|
|
1128
|
-
});
|
|
1129
|
-
const keyText = await keyResp.text();
|
|
1130
|
-
let keyData;
|
|
1131
|
-
try {
|
|
1132
|
-
keyData = keyText ? JSON.parse(keyText) : {};
|
|
1133
|
-
}
|
|
1134
|
-
catch {
|
|
1135
|
-
keyData = { raw: keyText };
|
|
1136
|
-
}
|
|
1137
|
-
if (!keyResp.ok) {
|
|
1138
|
-
throw new Error(`Create API key failed: ${keyResp.status} ${String(keyData.detail ?? keyData.raw ?? keyText)}`);
|
|
1139
|
-
}
|
|
1140
|
-
const apiKey = String(keyData.key || "");
|
|
1141
|
-
if (!apiKey.startsWith("pm_live_")) {
|
|
1142
|
-
throw new Error("Create API key response did not include a valid pm_live_ key.");
|
|
1143
|
-
}
|
|
1144
|
-
API_KEY = apiKey;
|
|
1145
|
-
let email;
|
|
1146
|
-
try {
|
|
1147
|
-
const meResp = await fetch(`${BACKEND_URL}/auth/me`, {
|
|
1148
|
-
headers: { Authorization: `Bearer ${args.access_token}` },
|
|
1149
|
-
});
|
|
1150
|
-
if (meResp.ok) {
|
|
1151
|
-
const me = await meResp.json();
|
|
1152
|
-
email = typeof me.email === "string" ? me.email : undefined;
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
catch {
|
|
1156
|
-
// Email is nice to have; the API key is the important credential.
|
|
1157
|
-
}
|
|
1158
|
-
await saveCredentials({
|
|
1159
|
-
api_key: apiKey,
|
|
1160
|
-
backend_url: BACKEND_URL,
|
|
1161
|
-
user_email: email,
|
|
1162
|
-
device_name: name,
|
|
1163
|
-
created_at: new Date().toISOString(),
|
|
1164
|
-
});
|
|
1165
|
-
return {
|
|
1166
|
-
content: [{
|
|
1167
|
-
type: "text",
|
|
1168
|
-
text: JSON.stringify({
|
|
1169
|
-
status: "authenticated",
|
|
1170
|
-
email,
|
|
1171
|
-
api_key: apiKey,
|
|
1172
|
-
key_prefix: keyData.key_prefix,
|
|
1173
|
-
key_id: keyData.id,
|
|
1174
|
-
saved_to: CREDENTIALS_FILE,
|
|
1175
|
-
message: "PreMan account and MCP credentials are ready. You can now scan endpoints, preview, deploy, test, and list hosted MCPs from the agent.",
|
|
1176
|
-
_agent_hints: {
|
|
1177
|
-
next_actions: [
|
|
1178
|
-
"Call discover_endpoints_from_codebase to scan this project.",
|
|
1179
|
-
"Call verify_endpoints_live if you have a base URL to test against.",
|
|
1180
|
-
"Call mcp_preview, then mcp_deploy to create a hosted MCP.",
|
|
1181
|
-
],
|
|
1182
|
-
related_tools: ["discover_endpoints_from_codebase", "verify_endpoints_live", "mcp_preview", "mcp_deploy"],
|
|
1183
|
-
},
|
|
1184
|
-
}),
|
|
1185
|
-
}],
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
catch (e) {
|
|
1189
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1190
|
-
next_actions: [
|
|
1191
|
-
"Get a fresh JWT by calling user_auth_login, user_auth_verify_otp, or user_auth_set_password.",
|
|
1192
|
-
"Or run `npm exec -y premanmcp@latest -- login` in the terminal.",
|
|
1193
|
-
],
|
|
1194
|
-
related_tools: ["user_auth_login", "user_auth_verify_otp", "user_auth_set_password"],
|
|
1195
|
-
});
|
|
1196
|
-
}
|
|
1197
|
-
});
|
|
1198
|
-
// ── preman_login ─────────────────────────────────────────────────
|
|
1199
|
-
server.tool("preman_login", "Authenticate with PreMan. Starts a one-time device authorization flow and returns a verification_url. Use Cursor Agent Browser (browser_navigate to verification_url) or open it manually to approve. Then run preman_login_complete with device_code.", {
|
|
1200
|
-
device_name: z.string().optional().describe("Friendly name for this device (e.g. 'My MacBook')"),
|
|
1201
|
-
}, async (args) => {
|
|
1202
|
-
if (API_KEY) {
|
|
1203
|
-
const check = await verifyApiKey(API_KEY);
|
|
1204
|
-
if (check.valid) {
|
|
1205
|
-
return {
|
|
1206
|
-
content: [{
|
|
1207
|
-
type: "text",
|
|
1208
|
-
text: JSON.stringify({
|
|
1209
|
-
status: "already_authenticated",
|
|
1210
|
-
email: check.email,
|
|
1211
|
-
message: `Already logged in as ${check.email ?? "unknown"}. Use preman_logout first if you want to switch accounts.`,
|
|
1212
|
-
}),
|
|
1213
|
-
}],
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
const deviceName = args.device_name || `${os.hostname()} MCP`;
|
|
1218
|
-
try {
|
|
1219
|
-
const reqResp = await fetch(`${BACKEND_URL}/auth/device/request`, {
|
|
1220
|
-
method: "POST",
|
|
1221
|
-
headers: { "Content-Type": "application/json" },
|
|
1222
|
-
body: JSON.stringify({ device_name: deviceName }),
|
|
1223
|
-
});
|
|
1224
|
-
if (!reqResp.ok) {
|
|
1225
|
-
const text = await reqResp.text();
|
|
1226
|
-
throw new Error(`Device auth request failed: ${reqResp.status} ${text}`);
|
|
1227
|
-
}
|
|
1228
|
-
const reqData = await reqResp.json();
|
|
1229
|
-
const deviceCode = reqData.device_code;
|
|
1230
|
-
const userCode = reqData.user_code;
|
|
1231
|
-
const verificationUrl = reqData.verification_url;
|
|
1232
|
-
const expiresIn = reqData.expires_in ?? 600;
|
|
1233
|
-
const pollInterval = reqData.poll_interval ?? 5;
|
|
1234
|
-
return {
|
|
1235
|
-
content: [{
|
|
1236
|
-
type: "text",
|
|
1237
|
-
text: JSON.stringify({
|
|
1238
|
-
status: "awaiting_approval",
|
|
1239
|
-
user_code: userCode,
|
|
1240
|
-
verification_url: verificationUrl,
|
|
1241
|
-
expires_in_seconds: expiresIn,
|
|
1242
|
-
poll_interval_seconds: pollInterval,
|
|
1243
|
-
_device_code: deviceCode,
|
|
1244
|
-
instructions: [
|
|
1245
|
-
`Open this URL to approve: ${verificationUrl}`,
|
|
1246
|
-
`Your code is: ${userCode}`,
|
|
1247
|
-
"Log in (if needed) and click Approve.",
|
|
1248
|
-
"Then run preman_login_complete to finish setup.",
|
|
1249
|
-
],
|
|
1250
|
-
_cursor_agent_steps: [
|
|
1251
|
-
`Use Cursor Agent Browser: browser_navigate to: ${verificationUrl}`,
|
|
1252
|
-
`The user code is ${userCode}. The user must log in and approve.`,
|
|
1253
|
-
`After the user approves, call preman_login_complete with device_code="${deviceCode}" to finish.`,
|
|
1254
|
-
],
|
|
1255
|
-
}),
|
|
1256
|
-
}],
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1259
|
-
catch (e) {
|
|
1260
|
-
return toolError(e.message, "backend_error", { next_actions: ["Retry preman_login"], related_tools: ["preman_login"] });
|
|
1261
|
-
}
|
|
1262
|
-
});
|
|
1263
|
-
// ── preman_login_complete ────────────────────────────────────────
|
|
1264
|
-
// Polls the backend for the approved API key after the user approves
|
|
1265
|
-
// the device request in the browser.
|
|
1266
|
-
server.tool("preman_login_complete", "Complete the login flow after the user approved the device request in the browser. Polls for the API key and stores it locally.", {
|
|
1267
|
-
device_code: z.string().describe("The device_code returned by preman_login"),
|
|
1268
|
-
}, async (args) => {
|
|
1269
|
-
const maxAttempts = 60;
|
|
1270
|
-
const pollMs = 5000;
|
|
1271
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1272
|
-
try {
|
|
1273
|
-
const resp = await fetch(`${BACKEND_URL}/auth/device/poll`, {
|
|
1274
|
-
method: "POST",
|
|
1275
|
-
headers: { "Content-Type": "application/json" },
|
|
1276
|
-
body: JSON.stringify({ device_code: args.device_code }),
|
|
1277
|
-
});
|
|
1278
|
-
if (!resp.ok) {
|
|
1279
|
-
const text = await resp.text();
|
|
1280
|
-
throw new Error(`Poll error: ${resp.status} ${text}`);
|
|
1281
|
-
}
|
|
1282
|
-
const data = await resp.json();
|
|
1283
|
-
const status = data.status;
|
|
1284
|
-
if (status === "approved" && data.api_key) {
|
|
1285
|
-
const apiKey = data.api_key;
|
|
1286
|
-
API_KEY = apiKey;
|
|
1287
|
-
const check = await verifyApiKey(apiKey);
|
|
1288
|
-
await saveCredentials({
|
|
1289
|
-
api_key: apiKey,
|
|
1290
|
-
backend_url: BACKEND_URL,
|
|
1291
|
-
user_email: check.email ?? data.user_email,
|
|
1292
|
-
created_at: new Date().toISOString(),
|
|
1293
|
-
});
|
|
1294
|
-
return {
|
|
1295
|
-
content: [{
|
|
1296
|
-
type: "text",
|
|
1297
|
-
text: JSON.stringify({
|
|
1298
|
-
status: "authenticated",
|
|
1299
|
-
email: check.email,
|
|
1300
|
-
message: `Successfully authenticated as ${check.email ?? "unknown"}. Credentials saved — you won't need to log in again.`,
|
|
1301
|
-
endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
|
|
1302
|
-
_agent_hints: {
|
|
1303
|
-
next_actions: [
|
|
1304
|
-
`browser_navigate to the URL in endpoints_page_url to open PreMan on the Endpoints page (sign in to the web app if needed).`,
|
|
1305
|
-
"Call get_endpoints to see your registered API endpoints.",
|
|
1306
|
-
"Call test_api to test an endpoint.",
|
|
1307
|
-
"Call import_collection to import a Postman/OpenAPI spec.",
|
|
1308
|
-
],
|
|
1309
|
-
related_tools: ["get_endpoints", "test_api", "import_collection"],
|
|
1310
|
-
},
|
|
1311
|
-
}),
|
|
1312
|
-
}],
|
|
1313
|
-
};
|
|
1314
|
-
}
|
|
1315
|
-
if (status === "expired") {
|
|
1316
|
-
return toolError("Device authorization request expired. Run preman_login again.", "auth_required", {
|
|
1317
|
-
next_actions: ["Re-run preman_login"],
|
|
1318
|
-
related_tools: ["preman_login"],
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
// Still pending — wait and retry
|
|
1322
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
1323
|
-
}
|
|
1324
|
-
catch (e) {
|
|
1325
|
-
return toolError(e.message, "backend_error", { next_actions: ["Re-run preman_login"], related_tools: ["preman_login"] });
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
return toolError("Timed out waiting for approval. Run preman_login again.", "auth_required", {
|
|
1329
|
-
next_actions: ["Re-run preman_login"],
|
|
1330
|
-
related_tools: ["preman_login"],
|
|
1331
|
-
});
|
|
1332
|
-
});
|
|
1333
|
-
// ── preman_status ────────────────────────────────────────────────
|
|
1334
|
-
server.tool("preman_status", "Check PreMan authentication status and connection info.", {}, async () => {
|
|
1335
|
-
if (!API_KEY) {
|
|
1336
|
-
return {
|
|
1337
|
-
content: [{
|
|
1338
|
-
type: "text",
|
|
1339
|
-
text: JSON.stringify({
|
|
1340
|
-
authenticated: false,
|
|
1341
|
-
backend_url: BACKEND_URL,
|
|
1342
|
-
frontend_base_url: FRONTEND_BASE,
|
|
1343
|
-
endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
|
|
1344
|
-
config: configSource(),
|
|
1345
|
-
message: "Not authenticated. Run `npm exec -y premanmcp@latest -- login`, or use user_auth_* then preman_create_api_key, or run preman_login.",
|
|
1346
|
-
}),
|
|
1347
|
-
}],
|
|
1348
|
-
};
|
|
1349
|
-
}
|
|
1350
|
-
const check = await verifyApiKey(API_KEY);
|
|
1351
|
-
if (!check.valid) {
|
|
1352
|
-
return {
|
|
1353
|
-
content: [{
|
|
1354
|
-
type: "text",
|
|
1355
|
-
text: JSON.stringify({
|
|
1356
|
-
authenticated: false,
|
|
1357
|
-
backend_url: BACKEND_URL,
|
|
1358
|
-
frontend_base_url: FRONTEND_BASE,
|
|
1359
|
-
endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
|
|
1360
|
-
config: configSource(),
|
|
1361
|
-
message: "Stored API key is no longer valid. Run `npm exec -y premanmcp@latest -- login`, or use user_auth_* then preman_create_api_key, or run preman_login.",
|
|
1362
|
-
}),
|
|
1363
|
-
}],
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
const workbench = await heartbeatWorkbenchLink();
|
|
1367
|
-
return {
|
|
1368
|
-
content: [{
|
|
1369
|
-
type: "text",
|
|
1370
|
-
text: JSON.stringify({
|
|
1371
|
-
authenticated: true,
|
|
1372
|
-
email: check.email,
|
|
1373
|
-
key_name: check.key_name,
|
|
1374
|
-
backend_url: BACKEND_URL,
|
|
1375
|
-
frontend_base_url: FRONTEND_BASE,
|
|
1376
|
-
endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
|
|
1377
|
-
config: configSource(),
|
|
1378
|
-
coding_agent: workbench,
|
|
1379
|
-
_agent_hints: {
|
|
1380
|
-
next_actions: [
|
|
1381
|
-
workbench && workbench.connected
|
|
1382
|
-
? "Coding agent is linked to PreMan workbench — call discover_endpoints_from_codebase or get_endpoints."
|
|
1383
|
-
: "If PreMan workbench shows pairing, set PREMAN_PAIR_CODE then call preman_status again.",
|
|
1384
|
-
"browser_navigate to `endpoints_page_url` (SPA shell + ot_agent_route) to open PreMan on the Endpoints page (sign in if prompted), then call get_endpoints.",
|
|
1385
|
-
"Call get_endpoints to see registered API endpoints.",
|
|
1386
|
-
"Call test_api to test an endpoint.",
|
|
1387
|
-
],
|
|
1388
|
-
related_tools: ["get_endpoints", "test_api", "import_collection", "discover_endpoints_from_codebase"],
|
|
1389
|
-
},
|
|
1390
|
-
}),
|
|
1391
|
-
}],
|
|
1392
|
-
};
|
|
1393
|
-
});
|
|
1394
|
-
// ── preman_logout ────────────────────────────────────────────────
|
|
1395
|
-
server.tool("preman_logout", "Log out of PreMan. Clears stored credentials so you can switch accounts or re-authenticate.", {}, async () => {
|
|
1396
|
-
const wasAuthenticated = !!API_KEY;
|
|
1397
|
-
API_KEY = "";
|
|
1398
|
-
await clearCredentials();
|
|
1399
|
-
return {
|
|
1400
|
-
content: [{
|
|
1401
|
-
type: "text",
|
|
1402
|
-
text: JSON.stringify({
|
|
1403
|
-
status: "logged_out",
|
|
1404
|
-
was_authenticated: wasAuthenticated,
|
|
1405
|
-
message: wasAuthenticated
|
|
1406
|
-
? "Logged out and credentials cleared. Run preman_login to sign in again."
|
|
1407
|
-
: "No credentials to clear. Already logged out.",
|
|
1408
|
-
}),
|
|
1409
|
-
}],
|
|
1410
|
-
};
|
|
1411
|
-
});
|
|
1412
|
-
// ── App user auth (JWT) — same server as preman-local / PREMAN_BACKEND
|
|
1413
|
-
registerUserAuthFlowTools(server, BACKEND_URL);
|
|
1414
|
-
server.tool("share_user_auth_flow_with_ui", "Stream signup, verify-otp, login, and resend-otp (send OTP) into the signed-in PreMan Playground with request/response JSON schemas. Requires preman_login or PREMAN_API_KEY so the session appears in the user's dashboard immediately. Pair with user_auth_* tools to execute flows.", {
|
|
1415
|
-
upstream_base_url: z
|
|
1416
|
-
.string()
|
|
1417
|
-
.optional()
|
|
1418
|
-
.describe("API under test, e.g. http://127.0.0.1:8000. Defaults to PREMAN_BACKEND."),
|
|
1419
|
-
session_id: z
|
|
1420
|
-
.string()
|
|
1421
|
-
.optional()
|
|
1422
|
-
.describe("Reuse an existing agent session id; omit to create a new session."),
|
|
1423
|
-
intent: z.string().optional().describe("Label shown in the Playground session list."),
|
|
1424
|
-
}, async (args) => {
|
|
1425
|
-
try {
|
|
1426
|
-
requireAuth();
|
|
1427
|
-
const result = await shareAuthFlowToUi({
|
|
1428
|
-
backendUrl: BACKEND_URL,
|
|
1429
|
-
frontendUrl: FRONTEND_BASE,
|
|
1430
|
-
upstreamBaseUrl: typeof args.upstream_base_url === "string"
|
|
1431
|
-
? args.upstream_base_url
|
|
1432
|
-
: undefined,
|
|
1433
|
-
sessionId: typeof args.session_id === "string" ? args.session_id : undefined,
|
|
1434
|
-
intent: typeof args.intent === "string" ? args.intent : undefined,
|
|
1435
|
-
apiKey: API_KEY || undefined,
|
|
1436
|
-
});
|
|
1437
|
-
return {
|
|
1438
|
-
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
1439
|
-
};
|
|
1440
|
-
}
|
|
1441
|
-
catch (e) {
|
|
1442
|
-
const m = e instanceof Error ? e.message : String(e);
|
|
1443
|
-
return toolError(m, inferErrorCode(m), {
|
|
1444
|
-
next_actions: [
|
|
1445
|
-
"Ensure the API is running (e.g. uv run api.py on port 8000).",
|
|
1446
|
-
"Set PREMAN_BACKEND to that URL in preman-mcp config.",
|
|
1447
|
-
],
|
|
1448
|
-
related_tools: [
|
|
1449
|
-
"user_auth_signup",
|
|
1450
|
-
"share_endpoints_with_ui",
|
|
1451
|
-
"preman_status",
|
|
1452
|
-
],
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
|
-
});
|
|
1456
|
-
// ── Hosted MCP platform tools ──────────────────────────────────────
|
|
1457
|
-
// Thin stdio proxies; all real logic lives in flowtest/mcp/hosted_mcp_tools.py
|
|
1458
|
-
// and is reached via the /mcp/call-tool HTTP bridge.
|
|
1459
|
-
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.", {
|
|
1460
|
-
action: z
|
|
1461
|
-
.enum(["brief", "connect", "push", "verify"])
|
|
1462
|
-
.optional()
|
|
1463
|
-
.describe("Omit for the guided brief; then connect | push | verify"),
|
|
1464
|
-
project_id: z.string().optional().describe("PreMan project the logs belong to"),
|
|
1465
|
-
source: z
|
|
1466
|
-
.enum(["cloudwatch", "s3", "posthog"])
|
|
1467
|
-
.optional()
|
|
1468
|
-
.describe("Where the logs already land; required for action='connect'"),
|
|
1469
|
-
config: z
|
|
1470
|
-
.record(z.any())
|
|
1471
|
-
.optional()
|
|
1472
|
-
.describe("Source config: cloudwatch {region, log_group}, s3 {region, bucket, prefix?}, posthog {project_id, host?, event_names?}"),
|
|
1473
|
-
aws_account_id: z
|
|
1474
|
-
.string()
|
|
1475
|
-
.optional()
|
|
1476
|
-
.describe("Customer's 12-digit AWS account id; used to predict the role ARN the template creates"),
|
|
1477
|
-
posthog_api_key: z.string().optional().describe("PostHog personal API key, read scope (posthog only)"),
|
|
1478
|
-
name: z.string().optional().describe("Display name for the connector"),
|
|
1479
|
-
env_name: z.string().optional().describe("Environment the logs belong to, e.g. production"),
|
|
1480
|
-
stream: z.enum(["frontend", "backend", "unknown"]).optional().describe("Which log rail these lines land on"),
|
|
1481
|
-
interval_seconds: z.number().optional().describe("Poll interval; defaults per source type"),
|
|
1482
|
-
connector_id: z.string().optional().describe("Connector to check; required for action='verify'"),
|
|
1483
|
-
}, async (args) => {
|
|
1484
|
-
try {
|
|
1485
|
-
const result = await callBackend("connect_logs", args);
|
|
1486
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1487
|
-
}
|
|
1488
|
-
catch (e) {
|
|
1489
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1490
|
-
next_actions: ["Call connect_logs with no arguments to see the supported actions."],
|
|
1491
|
-
related_tools: ["connect_logs"],
|
|
1492
|
-
});
|
|
1493
|
-
}
|
|
1494
|
-
});
|
|
1495
|
-
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.", {
|
|
1496
|
-
base_path: z.string().optional().describe("Directory to scan (defaults to CWD)"),
|
|
1497
|
-
framework_hint: z.string().optional().describe("fastapi | express | nestjs | rails | django | nextjs"),
|
|
1498
|
-
}, async (args) => {
|
|
1499
|
-
try {
|
|
1500
|
-
const result = await callBackend("discover_endpoints_from_codebase", args);
|
|
1501
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1502
|
-
}
|
|
1503
|
-
catch (e) {
|
|
1504
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1505
|
-
related_tools: ["verify_endpoints_live"],
|
|
1506
|
-
});
|
|
1507
|
-
}
|
|
1508
|
-
});
|
|
1509
|
-
server.tool("verify_endpoints_live", "Probe each proposed endpoint against the user's running local backend and classify the responses (confirmed / unconfirmed / unreachable / review). Collapses agent-extraction ambiguity into deterministic signal. Pass only the endpoints the agent proposed, with method + path_template at minimum.", {
|
|
1510
|
-
endpoints: z.array(z.any()).describe("Endpoints proposed by discover_endpoints_from_codebase"),
|
|
1511
|
-
local_base_url: z.string().describe("Where the backend is running, e.g. http://localhost:8000"),
|
|
1512
|
-
}, async (args) => {
|
|
1513
|
-
try {
|
|
1514
|
-
const result = await callBackend("verify_endpoints_live", args);
|
|
1515
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1516
|
-
}
|
|
1517
|
-
catch (e) {
|
|
1518
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1519
|
-
next_actions: ["Start the local backend, then retry."],
|
|
1520
|
-
related_tools: ["discover_endpoints_from_codebase", "mcp_preview"],
|
|
1521
|
-
});
|
|
1522
|
-
}
|
|
1523
|
-
});
|
|
1524
|
-
// ── Collections test generation ────────────────────────────────────
|
|
1525
|
-
// The dashboard's Generate Tests / Review flagged / Setup fixtures /
|
|
1526
|
-
// Enrich with agent buttons, so an agent can run them without clicking.
|
|
1527
|
-
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.", {
|
|
1528
|
-
action: z
|
|
1529
|
-
.enum(["generate", "status"])
|
|
1530
|
-
.optional()
|
|
1531
|
-
.describe("generate (default) runs the scan; status is read-only counts"),
|
|
1532
|
-
integration_id: z
|
|
1533
|
-
.string()
|
|
1534
|
-
.optional()
|
|
1535
|
-
.describe("Connected repo to scan. Omit to use the one with the most synced endpoints."),
|
|
1536
|
-
workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
|
|
1537
|
-
}, async (args) => {
|
|
1538
|
-
try {
|
|
1539
|
-
const result = await callBackend("generate_saved_tests", args);
|
|
1540
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1541
|
-
}
|
|
1542
|
-
catch (e) {
|
|
1543
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1544
|
-
next_actions: [
|
|
1545
|
-
"Connect a GitHub repository to this workspace in Settings → Integrations, then retry.",
|
|
1546
|
-
],
|
|
1547
|
-
related_tools: ["review_generated_tests", "setup_test_fixtures", "enrich_generated_tests"],
|
|
1548
|
-
});
|
|
1549
|
-
}
|
|
1550
|
-
});
|
|
1551
|
-
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.", {
|
|
1552
|
-
action: z
|
|
1553
|
-
.enum(["list", "approve", "approve_all"])
|
|
1554
|
-
.optional()
|
|
1555
|
-
.describe("list (default) | approve one request_id | approve_all (max 50)"),
|
|
1556
|
-
request_id: z.string().optional().describe("Saved request to approve; required for approve"),
|
|
1557
|
-
workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
|
|
1558
|
-
}, async (args) => {
|
|
1559
|
-
try {
|
|
1560
|
-
const result = await callBackend("review_generated_tests", args);
|
|
1561
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1562
|
-
}
|
|
1563
|
-
catch (e) {
|
|
1564
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1565
|
-
next_actions: ["Call review_generated_tests with action='list' to see the queue."],
|
|
1566
|
-
related_tools: ["generate_saved_tests", "enrich_generated_tests"],
|
|
1567
|
-
});
|
|
1568
|
-
}
|
|
1569
|
-
});
|
|
1570
|
-
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.", {
|
|
1571
|
-
action: z
|
|
1572
|
-
.enum(["setup", "list"])
|
|
1573
|
-
.optional()
|
|
1574
|
-
.describe("setup (default) harvests ids; list reports what is still missing"),
|
|
1575
|
-
workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
|
|
1576
|
-
}, async (args) => {
|
|
1577
|
-
try {
|
|
1578
|
-
const result = await callBackend("setup_test_fixtures", args);
|
|
1579
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1580
|
-
}
|
|
1581
|
-
catch (e) {
|
|
1582
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1583
|
-
next_actions: ["Save at least one endpoint with a {id} path parameter, then retry."],
|
|
1584
|
-
related_tools: ["generate_saved_tests", "review_generated_tests"],
|
|
1585
|
-
});
|
|
1586
|
-
}
|
|
1587
|
-
});
|
|
1588
|
-
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.", {
|
|
1589
|
-
action: z
|
|
1590
|
-
.enum(["enrich", "list"])
|
|
1591
|
-
.optional()
|
|
1592
|
-
.describe("enrich (default) runs the pass; list returns the eligible suites"),
|
|
1593
|
-
workspace_id: z.string().optional().describe("Defaults to the caller's own workspace"),
|
|
1594
|
-
}, async (args) => {
|
|
1595
|
-
try {
|
|
1596
|
-
const result = await callBackend("enrich_generated_tests", args);
|
|
1597
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1598
|
-
}
|
|
1599
|
-
catch (e) {
|
|
1600
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1601
|
-
next_actions: [
|
|
1602
|
-
"Run generate_saved_tests first, then clear the review queue with review_generated_tests.",
|
|
1603
|
-
],
|
|
1604
|
-
related_tools: ["generate_saved_tests", "review_generated_tests"],
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
});
|
|
1608
|
-
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.", {
|
|
1609
|
-
endpoints: z.array(z.any()).describe("Endpoints to push. Each item should include method plus path/path_template/url; include schemas when available."),
|
|
1610
|
-
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"),
|
|
1611
|
-
intent: z.string().optional().describe("Short label for the session, e.g. 'Login endpoint' or 'Auth endpoints'"),
|
|
1612
|
-
session_id: z.string().optional().describe("Optional existing session id to append/replace. Omit to create a new session."),
|
|
1613
|
-
}, async (args) => {
|
|
1614
|
-
try {
|
|
1615
|
-
const result = await callBackend("share_endpoints_with_ui", args);
|
|
1616
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1617
|
-
}
|
|
1618
|
-
catch (e) {
|
|
1619
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1620
|
-
next_actions: [
|
|
1621
|
-
"Make sure PREMAN_API_KEY is set or run preman_login, then retry.",
|
|
1622
|
-
"If the PreMan Playground opens but is empty, confirm PREMAN_BACKEND points to the same backend that received this push.",
|
|
1623
|
-
],
|
|
1624
|
-
related_tools: ["verify_endpoints_live", "mcp_preview", "preman_status"],
|
|
1625
|
-
});
|
|
1626
|
-
}
|
|
1627
|
-
});
|
|
1628
|
-
server.tool("mcp_preview", "Pick endpoints matching an intent, generate a tool-schema preview, and automatically share the selected endpoints into the PreMan Playground session so the user can see/test/deploy them in the UI. Also writes the same two-pane HTML to preman-mcp/mcp-preview-last.html and returns preview_file_url; use Cursor Simple Browser or `node preman-mcp/scripts/open-cursor-preview.mjs` if the inline MCP app panel does not appear. Endpoint selection uses deterministic keyword matching.", {
|
|
1629
|
-
intent: z.string().describe("Free text like 'auth endpoints' or 'everything related to orders'"),
|
|
1630
|
-
endpoints: z.array(z.any()).describe("Candidate endpoints (typically the confirmed bucket from verify_endpoints_live)"),
|
|
1631
|
-
upstream_base_url: z.string().optional().describe("Target business API base URL. Usually inferred from verify_endpoints_live results. Do not use PREMAN_BACKEND / api.preman.live unless the user's API is actually the PreMan backend."),
|
|
1632
|
-
}, async (args) => {
|
|
1633
|
-
try {
|
|
1634
|
-
const previewArgs = { ...args };
|
|
1635
|
-
const inferredUpstream = inferUpstreamBaseUrlFromEndpoints(previewArgs.endpoints);
|
|
1636
|
-
const requestedUpstream = typeof previewArgs.upstream_base_url === "string"
|
|
1637
|
-
? previewArgs.upstream_base_url.trim()
|
|
1638
|
-
: "";
|
|
1639
|
-
if (!requestedUpstream && inferredUpstream) {
|
|
1640
|
-
previewArgs.upstream_base_url = inferredUpstream;
|
|
1641
|
-
}
|
|
1642
|
-
else if (requestedUpstream &&
|
|
1643
|
-
isPreManControlPlaneUrl(requestedUpstream) &&
|
|
1644
|
-
inferredUpstream &&
|
|
1645
|
-
!isPreManControlPlaneUrl(inferredUpstream)) {
|
|
1646
|
-
previewArgs.upstream_base_url = inferredUpstream;
|
|
1647
|
-
}
|
|
1648
|
-
const result = await callBackend("mcp_preview", {
|
|
1649
|
-
...previewArgs,
|
|
1650
|
-
session_id: AGENT_SESSION_ID,
|
|
1651
|
-
});
|
|
1652
|
-
const previewData = (result && typeof result === "object" && !Array.isArray(result))
|
|
1653
|
-
? result
|
|
1654
|
-
: {};
|
|
1655
|
-
const panelHtml = buildConversionPanelHtml(previewData);
|
|
1656
|
-
const { absolutePath, fileUrl } = await writeMcpPreviewFile(panelHtml);
|
|
1657
|
-
const basePayload = result && typeof result === "object" && !Array.isArray(result)
|
|
1658
|
-
? result
|
|
1659
|
-
: { raw: result };
|
|
1660
|
-
const uiPayload = basePayload.ui && typeof basePayload.ui === "object" && !Array.isArray(basePayload.ui)
|
|
1661
|
-
? basePayload.ui
|
|
1662
|
-
: {};
|
|
1663
|
-
const withPreviewNav = {
|
|
1664
|
-
...basePayload,
|
|
1665
|
-
preview_html_path: absolutePath,
|
|
1666
|
-
preview_file_url: fileUrl,
|
|
1667
|
-
_agent_session: {
|
|
1668
|
-
session_id: typeof basePayload.session_id === "string" ? basePayload.session_id : AGENT_SESSION_ID,
|
|
1669
|
-
dashboard_url: typeof uiPayload.url === "string"
|
|
1670
|
-
? uiPayload.url
|
|
1671
|
-
: buildAgentDashboardUrl("/endpoints"),
|
|
1672
|
-
},
|
|
1673
|
-
_how_to_see_the_ui: [
|
|
1674
|
-
"The selected endpoints were automatically shared into the PreMan Playground session. Open ui.url or _agent_session.dashboard_url to see them.",
|
|
1675
|
-
"Cursor may not show the embedded MCP app (no new tab is opened by default).",
|
|
1676
|
-
"Run `node preman-mcp/scripts/open-cursor-preview.mjs` from the repo root; it copies the http:// URL to the clipboard (macOS) and tries cursor:// + vscode:// Simple Browser handlers.",
|
|
1677
|
-
"If no tab appears: Cmd+Shift+P → “Simple Browser: Show” → paste the http://127.0.0.1:… URL from the script output. In the integrated terminal, Cmd+Click that URL may open the in-editor browser.",
|
|
1678
|
-
"File → Open on preview_html_path shows source, not a rendered page.",
|
|
1679
|
-
],
|
|
1680
|
-
};
|
|
1681
|
-
return {
|
|
1682
|
-
content: [
|
|
1683
|
-
{ type: "text", text: JSON.stringify(withPreviewNav, null, 0) },
|
|
1684
|
-
{
|
|
1685
|
-
type: "resource",
|
|
1686
|
-
resource: {
|
|
1687
|
-
uri: MCP_PREVIEW_RESOURCE_URI,
|
|
1688
|
-
mimeType: "text/html;profile=mcp-app",
|
|
1689
|
-
text: panelHtml,
|
|
1690
|
-
},
|
|
1691
|
-
},
|
|
1692
|
-
{
|
|
1693
|
-
type: "resource_link",
|
|
1694
|
-
name: "preman-mcp-preview",
|
|
1695
|
-
title: "PreMan · MCP Preview",
|
|
1696
|
-
uri: MCP_PREVIEW_RESOURCE_URI,
|
|
1697
|
-
mimeType: "text/html;profile=mcp-app",
|
|
1698
|
-
},
|
|
1699
|
-
],
|
|
1700
|
-
_meta: { [RESOURCE_URI_META_KEY]: MCP_PREVIEW_RESOURCE_URI },
|
|
1701
|
-
};
|
|
1702
|
-
}
|
|
1703
|
-
catch (e) {
|
|
1704
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1705
|
-
related_tools: ["mcp_deploy"],
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1708
|
-
});
|
|
1709
|
-
server.tool("mcp_deploy", "Create and host an MCP server from a preview spec plus an upstream credential. Returns an install snippet to give to a coding agent. By default the MCP is public and NO consumer token is minted: pass initial_consumer_label to mint one, or call mcp_mint_consumer_token afterwards. Do not tell the user they have a token unless raw_consumer_token came back non-null. The upstream secret is encrypted at rest with a Fernet key; it is never returned to clients after this call.", {
|
|
1710
|
-
name: z.string().describe("Human-readable name, e.g. 'Acme Auth MCP'"),
|
|
1711
|
-
upstream_base_url: z.string().describe("Base URL of the business's real backend. Do not use PREMAN_BACKEND / api.preman.live unless deploying an MCP for PreMan itself."),
|
|
1712
|
-
spec: z.any().describe("The spec_preview object returned by mcp_preview"),
|
|
1713
|
-
initial_upstream_secret: z.string().describe("API key / bearer / basic value the MCP will use to reach the upstream backend"),
|
|
1714
|
-
upstream_auth_style: z.any().optional().describe("Override how the credential is injected. Default: { type:'header', name:'Authorization', prefix:'Bearer ' }"),
|
|
1715
|
-
initial_upstream_secret_type: z.string().optional().describe("bearer | api_key | basic | custom"),
|
|
1716
|
-
initial_consumer_label: z.string().optional().describe("Label for the first consumer token. Omit and no token is minted and the MCP is reachable by anyone holding the URL; pass a label to mint one and get a private, revocable install snippet."),
|
|
1717
|
-
}, async (args) => {
|
|
1718
|
-
try {
|
|
1719
|
-
const deployArgs = { ...args };
|
|
1720
|
-
const requestedUpstream = typeof deployArgs.upstream_base_url === "string"
|
|
1721
|
-
? deployArgs.upstream_base_url.trim()
|
|
1722
|
-
: "";
|
|
1723
|
-
const spec = deployArgs.spec && typeof deployArgs.spec === "object" && !Array.isArray(deployArgs.spec)
|
|
1724
|
-
? deployArgs.spec
|
|
1725
|
-
: {};
|
|
1726
|
-
const specUpstream = typeof spec.upstream_base_url === "string" ? spec.upstream_base_url.trim() : "";
|
|
1727
|
-
if (requestedUpstream &&
|
|
1728
|
-
isPreManControlPlaneUrl(requestedUpstream) &&
|
|
1729
|
-
specUpstream &&
|
|
1730
|
-
!isPreManControlPlaneUrl(specUpstream)) {
|
|
1731
|
-
deployArgs.upstream_base_url = specUpstream;
|
|
1732
|
-
}
|
|
1733
|
-
const result = await callBackend("mcp_deploy", deployArgs);
|
|
1734
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1735
|
-
}
|
|
1736
|
-
catch (e) {
|
|
1737
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1738
|
-
related_tools: ["mcp_mint_consumer_token", "mcp_list_deployed"],
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
});
|
|
1742
|
-
server.tool("mcp_list_deployed", "List the hosted MCPs the current user has deployed.", {}, async () => {
|
|
1743
|
-
try {
|
|
1744
|
-
const result = await callBackend("mcp_list_deployed", {});
|
|
1745
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1746
|
-
}
|
|
1747
|
-
catch (e) {
|
|
1748
|
-
return toolError(e.message, inferErrorCode(e.message));
|
|
1749
|
-
}
|
|
1750
|
-
});
|
|
1751
|
-
server.tool("mcp_mint_consumer_token", "Mint a consumer token for a hosted MCP. The raw token is returned ONCE — only its hash is stored. Pass upstream_credential_id to map this consumer to a specific upstream credential; omit it to use the MCP's default credential.", {
|
|
1752
|
-
hosted_mcp_id: z.string().describe("The hosted MCP's id"),
|
|
1753
|
-
consumer_label: z.string().describe("Human-readable label for this consumer, e.g. 'acme-beta-bob'"),
|
|
1754
|
-
upstream_credential_id: z.string().optional().describe("Optional credential mapping for this consumer token"),
|
|
1755
|
-
}, async (args) => {
|
|
1756
|
-
try {
|
|
1757
|
-
const result = await callBackend("mcp_mint_consumer_token", args);
|
|
1758
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1759
|
-
}
|
|
1760
|
-
catch (e) {
|
|
1761
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1762
|
-
related_tools: ["mcp_revoke_consumer_token", "mcp_list_deployed"],
|
|
1763
|
-
});
|
|
1764
|
-
}
|
|
1765
|
-
});
|
|
1766
|
-
server.tool("mcp_revoke_consumer_token", "Revoke a consumer token so future invocations by that consumer are rejected. Idempotent — revoking an already-revoked token returns ok:true with already_revoked:true.", {
|
|
1767
|
-
hosted_mcp_id: z.string().describe("The hosted MCP's id"),
|
|
1768
|
-
token_id: z.string().describe("The consumer token's id (from mcp_mint_consumer_token's token.id)"),
|
|
1769
|
-
}, async (args) => {
|
|
1770
|
-
try {
|
|
1771
|
-
const result = await callBackend("mcp_revoke_consumer_token", args);
|
|
1772
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1773
|
-
}
|
|
1774
|
-
catch (e) {
|
|
1775
|
-
return toolError(e.message, inferErrorCode(e.message));
|
|
1776
|
-
}
|
|
1777
|
-
});
|
|
1778
|
-
// ── preman_get_fix_task ───────────────────────────────────────────
|
|
1779
|
-
server.tool("preman_get_fix_task", "Pull pending coding-agent fix tasks built from fired PreMan alerts. Each task packages a failing endpoint: title, expected vs actual, failure stats, and a reproducible curl (package.repro.curl). Fix the endpoint using the curl, then call preman_complete_fix_task with the fix_task_id. Check package.auto_pr: when eligible is true, push your fix on the branch it names and call preman_open_fix_pr.", {
|
|
1780
|
-
status: z.enum(["open", "delivered", "resolved"]).optional().default("open").describe("'open' (default) hands out new tasks and marks them delivered; 'delivered' re-fetches ones already pulled; 'resolved' for history"),
|
|
1781
|
-
limit: z.number().optional().default(5).describe("Max tasks to return (capped at 20)"),
|
|
1782
|
-
}, async (args) => {
|
|
1783
|
-
try {
|
|
1784
|
-
const result = await callBackend("preman_get_fix_task", args);
|
|
1785
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1786
|
-
}
|
|
1787
|
-
catch (e) {
|
|
1788
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1789
|
-
next_actions: ["No open fix tasks means no alerts have been handed off. Create a handoff from a fired alert in the dashboard."],
|
|
1790
|
-
});
|
|
1791
|
-
}
|
|
1792
|
-
});
|
|
1793
|
-
// ── preman_complete_fix_task ──────────────────────────────────────
|
|
1794
|
-
server.tool("preman_complete_fix_task", "Mark a fix task resolved once its endpoint failure is fixed.", {
|
|
1795
|
-
fix_task_id: z.string().describe("The id from a preman_get_fix_task result"),
|
|
1796
|
-
resolution_note: z.string().optional().default("").describe("Optional note on what was fixed"),
|
|
1797
|
-
}, async (args) => {
|
|
1798
|
-
try {
|
|
1799
|
-
const result = await callBackend("preman_complete_fix_task", args);
|
|
1800
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1801
|
-
}
|
|
1802
|
-
catch (e) {
|
|
1803
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1804
|
-
related_tools: ["preman_get_fix_task"],
|
|
1805
|
-
});
|
|
1806
|
-
}
|
|
1807
|
-
});
|
|
1808
|
-
// ── preman_open_fix_pr ────────────────────────────────────────────
|
|
1809
|
-
server.tool("preman_open_fix_pr", "Open a pull request for a fix branch you already pushed (Auto-PR, Tier 2). Only for fix tasks whose package.auto_pr.eligible is true. Patch and push the preman/fix-* branch with your own git credentials first — PreMan never pushes code. PreMan then verifies the branch exists, re-checks the endpoint in production, and opens a PR with that evidence. PreMan never merges automatically — it merges only its own preman/fix-* branches, only on repositories that turned merging on, and only after you approve.", {
|
|
1810
|
-
fix_task_id: z.string().describe("The id from a preman_get_fix_task result"),
|
|
1811
|
-
branch: z.string().describe("The branch you pushed — must match package.auto_pr.branch"),
|
|
1812
|
-
summary: z.string().optional().default("").describe("Short description of the fix, included in the PR body"),
|
|
1813
|
-
local_rerun: z.string().optional().default("").describe("Your local test/re-run output, included as agent-reported evidence"),
|
|
1814
|
-
}, async (args) => {
|
|
1815
|
-
try {
|
|
1816
|
-
const result = await callBackend("preman_open_fix_pr", args);
|
|
1817
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1818
|
-
}
|
|
1819
|
-
catch (e) {
|
|
1820
|
-
return toolError(e.message, inferErrorCode(e.message), {
|
|
1821
|
-
related_tools: ["preman_get_fix_task", "preman_complete_fix_task"],
|
|
1822
|
-
next_actions: ["Auto-PR requires the repo to have opted in and the fix task to be an API_BUG failure mapped to that repo."],
|
|
1823
|
-
});
|
|
1824
|
-
}
|
|
1825
|
-
});
|
|
1826
|
-
return server;
|
|
1827
|
-
}
|
|
1828
|
-
// ── Start ─────────────────────────────────────────────────────────────
|
|
1829
|
-
/**
|
|
1830
|
-
* A pre-push hook PreMan wrote can stop working — the machine gained a `preman`
|
|
1831
|
-
* belonging to another package, or lost the one it pinned — and the only symptom
|
|
1832
|
-
* is one skipped line per push. This process starting is proof PreMan runs here,
|
|
1833
|
-
* so it is a good moment to check. Detached with stdio ignored: the check shells
|
|
1834
|
-
* out to npm, and this process's stdout is the MCP protocol.
|
|
1835
|
-
*/
|
|
1836
|
-
function repairPushHookInBackground() {
|
|
115
|
+
if (!body.trim())
|
|
116
|
+
return;
|
|
117
|
+
const contentType = response.headers.get("content-type") || "";
|
|
118
|
+
const replies = contentType.includes("text/event-stream")
|
|
119
|
+
? parseSse(body)
|
|
120
|
+
: [JSON.parse(body)];
|
|
121
|
+
for (const reply of replies)
|
|
122
|
+
write(reply);
|
|
123
|
+
}
|
|
124
|
+
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
125
|
+
let queue = Promise.resolve();
|
|
126
|
+
input.on("line", (line) => {
|
|
127
|
+
const raw = line.trim();
|
|
128
|
+
if (!raw)
|
|
129
|
+
return;
|
|
130
|
+
let message;
|
|
1837
131
|
try {
|
|
1838
|
-
|
|
1839
|
-
if (!existsSync(cli))
|
|
1840
|
-
return;
|
|
1841
|
-
const child = spawn(process.execPath, [cli, "hook", "repair", "--if-stale"], {
|
|
1842
|
-
detached: true,
|
|
1843
|
-
stdio: "ignore",
|
|
1844
|
-
});
|
|
1845
|
-
child.unref();
|
|
132
|
+
message = JSON.parse(raw);
|
|
1846
133
|
}
|
|
1847
134
|
catch {
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
}
|
|
1851
|
-
async function main() {
|
|
1852
|
-
await initAuth();
|
|
1853
|
-
const server = createServer();
|
|
1854
|
-
const transport = new StdioServerTransport();
|
|
1855
|
-
await server.connect(transport);
|
|
1856
|
-
repairPushHookInBackground();
|
|
1857
|
-
// Starting the configured MCP server is itself proof that the coding agent
|
|
1858
|
-
// is live. Previously the workbench remained in `connecting` until someone
|
|
1859
|
-
// explicitly asked the agent to call `preman_status`, even when this process
|
|
1860
|
-
// already had valid stored credentials and the pairing code in its env.
|
|
1861
|
-
// Keep startup non-blocking so a slow control plane cannot delay MCP setup.
|
|
1862
|
-
if (API_KEY) {
|
|
1863
|
-
void heartbeatWorkbenchLink().then((link) => {
|
|
1864
|
-
if (link?.ok) {
|
|
1865
|
-
console.error("[PreMan] Coding-agent connection heartbeat sent");
|
|
1866
|
-
}
|
|
1867
|
-
else if (link) {
|
|
1868
|
-
console.error(`[PreMan] Coding-agent heartbeat not accepted: ${String(link.detail || link.status || "unknown error")}`);
|
|
1869
|
-
}
|
|
1870
|
-
});
|
|
135
|
+
write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
136
|
+
return;
|
|
1871
137
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
138
|
+
queue = queue.then(() => forward(message)).catch((error) => {
|
|
139
|
+
rpcError(message, -32603, error instanceof Error ? error.message : "Internal proxy error");
|
|
140
|
+
});
|
|
141
|
+
});
|