opentestmcp 0.3.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/dist/server.js ADDED
@@ -0,0 +1,980 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { registerUserAuthFlowTools } from "./user_auth_flow.js";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import { randomUUID } from "node:crypto";
10
+ import { MCP_PREVIEW_RESOURCE_URI, RESOURCE_URI_META_KEY, buildConversionPanelHtml, writeMcpPreviewFile, } from "./mcp-preview-panel.js";
11
+ /**
12
+ * Load ``OPENTEST_*`` from repo JSON so project URLs win over stale **global** Cursor MCP env
13
+ * (which often sets the wrong ``OPENTEST_BACKEND``, e.g. Flow port instead of API port).
14
+ * First match wins: ``.cursor/opentest-mcp.config.json``, then ``opentest-mcp.config.json`` (cwd).
15
+ */
16
+ function applyRepoOpentestConfig() {
17
+ const candidates = [
18
+ path.join(process.cwd(), ".cursor", "opentest-mcp.config.json"),
19
+ path.join(process.cwd(), "opentest-mcp.config.json"),
20
+ ];
21
+ for (const p of candidates) {
22
+ try {
23
+ if (!existsSync(p))
24
+ continue;
25
+ const j = JSON.parse(readFileSync(p, "utf8"));
26
+ for (const [key, val] of Object.entries(j)) {
27
+ if (!key.startsWith("OPENTEST_"))
28
+ continue;
29
+ if (typeof val === "string" && val.trim()) {
30
+ process.env[key] = val.trim();
31
+ }
32
+ }
33
+ return;
34
+ }
35
+ catch {
36
+ /* try next path */
37
+ }
38
+ }
39
+ }
40
+ applyRepoOpentestConfig();
41
+ const BACKEND_URL = process.env.OPENTEST_BACKEND || "https://flow.opentest.live";
42
+ const FRONTEND_URL = process.env.OPENTEST_FRONTEND || "https://www.flowtest.opentest.live";
43
+ let API_KEY = process.env.OPENTEST_API_KEY || "";
44
+ function normalizeFrontendBaseUrl(url) {
45
+ return url.replace(/\/+$/, "");
46
+ }
47
+ /** Origin only (no path). */
48
+ const FRONTEND_BASE = normalizeFrontendBaseUrl(FRONTEND_URL);
49
+ /**
50
+ * Static hosts (S3/CloudFront) often 404 on `/endpoints` because only `/` maps to index.html.
51
+ * Cold-load the SPA shell at `/` and pass the client route in the query so the first request always hits index.html.
52
+ */
53
+ const AGENT_ROUTE_PARAM = "ot_agent_route";
54
+ function buildAgentDashboardUrlForBase(baseUrl, routePath) {
55
+ const base = normalizeFrontendBaseUrl(baseUrl);
56
+ const u = new URL(`${base}/`);
57
+ u.searchParams.set("agent_session", AGENT_SESSION_ID);
58
+ const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
59
+ u.searchParams.set(AGENT_ROUTE_PARAM, p);
60
+ return u.toString();
61
+ }
62
+ function buildAgentDashboardUrl(routePath) {
63
+ return buildAgentDashboardUrlForBase(FRONTEND_BASE, routePath);
64
+ }
65
+ /** Stable per-process ID the dashboard subscribes to for live follow-along. */
66
+ const AGENT_SESSION_ID = randomUUID();
67
+ // ── Credential Storage ────────────────────────────────────────────────
68
+ const CREDENTIALS_DIR = path.join(os.homedir(), ".opentest");
69
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
70
+ async function loadStoredCredentials() {
71
+ try {
72
+ const raw = await fs.readFile(CREDENTIALS_FILE, "utf-8");
73
+ const creds = JSON.parse(raw);
74
+ if (creds.api_key && creds.api_key.startsWith("ot_live_"))
75
+ return creds;
76
+ return null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ async function saveCredentials(creds) {
83
+ await fs.mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
84
+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
85
+ console.error(`[OpenTest] Credentials saved to ${CREDENTIALS_FILE}`);
86
+ }
87
+ async function clearCredentials() {
88
+ try {
89
+ await fs.unlink(CREDENTIALS_FILE);
90
+ console.error("[OpenTest] Credentials cleared");
91
+ }
92
+ catch {
93
+ // file didn't exist, that's fine
94
+ }
95
+ }
96
+ async function verifyApiKey(key) {
97
+ try {
98
+ const resp = await fetch(`${BACKEND_URL}/mcp/whoami`, {
99
+ headers: { Authorization: `Bearer ${key}` },
100
+ });
101
+ if (!resp.ok)
102
+ return { valid: false, reachable: true };
103
+ const data = await resp.json();
104
+ return { valid: true, reachable: true, email: data.email, key_name: data.key_name };
105
+ }
106
+ catch {
107
+ return { valid: false, reachable: false };
108
+ }
109
+ }
110
+ async function initAuth() {
111
+ if (API_KEY) {
112
+ console.error("[OpenTest] Using API key from OPENTEST_API_KEY env var");
113
+ return;
114
+ }
115
+ const creds = await loadStoredCredentials();
116
+ if (!creds) {
117
+ console.error("[OpenTest] No stored credentials. Use opentest_login to authenticate.");
118
+ return;
119
+ }
120
+ const check = await verifyApiKey(creds.api_key);
121
+ if (check.valid) {
122
+ API_KEY = creds.api_key;
123
+ console.error(`[OpenTest] Authenticated as ${creds.user_email ?? "unknown"} (stored credentials)`);
124
+ }
125
+ else if (!check.reachable) {
126
+ API_KEY = creds.api_key;
127
+ console.error("[OpenTest] Backend unreachable — using stored credentials (will verify on first call)");
128
+ }
129
+ else {
130
+ console.error("[OpenTest] Stored credentials are invalid or expired. Use opentest_login to re-authenticate.");
131
+ await clearCredentials();
132
+ }
133
+ }
134
+ // Panel HTML: ./mcp-preview-panel.ts (also used by scripts/emit-mcp-preview.mjs)
135
+ // ── Backend proxy ─────────────────────────────────────────────────────
136
+ function requireAuth() {
137
+ if (!API_KEY) {
138
+ throw new Error("Not authenticated. Run the opentest_login tool first to connect your OpenTest account.");
139
+ }
140
+ }
141
+ async function callBackend(toolName, args) {
142
+ requireAuth();
143
+ const headers = {
144
+ "Content-Type": "application/json",
145
+ Authorization: `Bearer ${API_KEY}`,
146
+ };
147
+ const resp = await fetch(`${BACKEND_URL}/mcp/call-tool`, {
148
+ method: "POST",
149
+ headers,
150
+ body: JSON.stringify({ tool: toolName, arguments: args }),
151
+ });
152
+ if (!resp.ok) {
153
+ const text = await resp.text();
154
+ throw new Error(`Backend error: ${resp.status} ${text}`);
155
+ }
156
+ return resp.json();
157
+ }
158
+ function emitActivity(eventType, data) {
159
+ fetch(`${BACKEND_URL}/agent/activity/${AGENT_SESSION_ID}`, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({ event_type: eventType, data }),
163
+ }).catch((err) => {
164
+ console.error(`[OpenTest] emitActivity(${eventType}) failed: ${err.message}`);
165
+ });
166
+ }
167
+ /** Build the `_agent_session` block included in every tool response. */
168
+ function agentSessionPayload(dashboardPath = "/endpoints") {
169
+ return {
170
+ _agent_session: {
171
+ session_id: AGENT_SESSION_ID,
172
+ dashboard_url: buildAgentDashboardUrl(dashboardPath),
173
+ },
174
+ };
175
+ }
176
+ /**
177
+ * Return a structured MCP error response that agents can reliably parse.
178
+ *
179
+ * Every error has the same shape: ``{ error, error_code, _agent_hints }``.
180
+ */
181
+ function toolError(message, code = "backend_error", hints) {
182
+ const payload = {
183
+ error: message,
184
+ error_code: code,
185
+ };
186
+ if (hints) {
187
+ payload._agent_hints = hints;
188
+ }
189
+ return {
190
+ content: [{ type: "text", text: JSON.stringify(payload) }],
191
+ isError: true,
192
+ };
193
+ }
194
+ /** Infer an ``ErrorCode`` from an error message for common patterns. */
195
+ function inferErrorCode(msg) {
196
+ const lower = msg.toLowerCase();
197
+ if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("not authenticated"))
198
+ return "auth_required";
199
+ if (lower.includes("404") || lower.includes("not found"))
200
+ return "not_found";
201
+ if (lower.includes("400") || lower.includes("invalid") || lower.includes("missing"))
202
+ return "invalid_input";
203
+ return "backend_error";
204
+ }
205
+ // ── Helper: attach OpenTest frontend URL to every tool response ────────
206
+ /** Merge backend payload with `ui.url` pointing at the Endpoints page (for get_endpoints JSON without a separate panel). */
207
+ function enrichEndpointsBrowserUrl(payload) {
208
+ const prevUi = typeof payload.ui === "object" && payload.ui !== null
209
+ ? payload.ui
210
+ : {};
211
+ return {
212
+ ...payload,
213
+ ui: {
214
+ ...prevUi,
215
+ url: buildAgentDashboardUrl("/endpoints"),
216
+ note: "Use Agent Browser: browser_navigate to `ui.url` to open the Flow Endpoints page (sign in if prompted). Prefer this over the site homepage.",
217
+ },
218
+ };
219
+ }
220
+ function withFrontendUrl(payload, path) {
221
+ const p = path ?? "/endpoints";
222
+ const prevUi = typeof payload.ui === "object" && payload.ui !== null
223
+ ? payload.ui
224
+ : {};
225
+ return {
226
+ content: [{
227
+ type: "text",
228
+ text: JSON.stringify({
229
+ ...payload,
230
+ ui: {
231
+ ...prevUi,
232
+ url: buildAgentDashboardUrl(p),
233
+ note: "Open this URL in Agent Browser (browser_navigate) to see Flow — Endpoints page first (sign in if prompted). Prefer this link over the site homepage.",
234
+ },
235
+ }),
236
+ }],
237
+ };
238
+ }
239
+ // ── MCP Server ────────────────────────────────────────────────────────
240
+ export function createServer() {
241
+ const server = new McpServer({ name: "OpenTest", version: "1.0.0" }, {
242
+ instructions: [
243
+ "OpenTest is an API testing platform. It discovers, tests, and monitors API endpoints.",
244
+ "",
245
+ "## Tool taxonomy",
246
+ "- **Read tools** (no side effects): get_endpoints, get_coverage, detect_drift, opentest_status, list_collections, get_collection, list_runs",
247
+ "- **Action tools** (execute tests / mutate state): test_api, test_flow, generate_tests, import_collection, test_endpoint_by_id, run_tests, delete_collection",
248
+ "- **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",
249
+ "- **Auth (API key / OpenTest)**: opentest_login, opentest_login_complete, opentest_logout",
250
+ "- **Auth (app JWT — email/OTP/password on your API)**: 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 OPENTEST_BACKEND /auth/*; no ot_live_ key required)",
251
+ "",
252
+ "## Defaults for agents",
253
+ '- Always use format="json" (the default) for structured data. Only use format="text" if the user explicitly asks for human-readable output.',
254
+ "- Do NOT set open_ui=true unless the user asks to see the dashboard.",
255
+ "- endpoints_dashboard is DEPRECATED. Use get_endpoints(include_sessions=true, include_collections=true) instead.",
256
+ "- When opening the Flow web 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.",
257
+ "",
258
+ "## Common workflows",
259
+ "1. **Discover then test**: get_endpoints -> pick untested/failing -> test_api for each.",
260
+ "2. **Import then generate**: import_collection (Postman/OpenAPI/curl) -> generate_tests -> test_api to run them.",
261
+ '3. **Coverage audit**: get_coverage on a collection -> generate_tests(coverage_level="comprehensive") for gaps.',
262
+ "4. **Drift detection**: detect_drift with a collection + base_url -> test_api on drifted endpoints.",
263
+ "",
264
+ "## Auth",
265
+ "OpenTest API tools (get_endpoints, test_api, …) need an ot_live_ key: run opentest_login, then opentest_login_complete.",
266
+ "To drive the backend's **email/JWT** auth (signup, OTP, login, /auth/me), use the user_auth_* tools — they call OPENTEST_BACKEND /auth/* directly and do not use the API key.",
267
+ ].join("\n"),
268
+ });
269
+ // ── test_api ──────────────────────────────────────────────────────
270
+ 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 OpenTest registry.", {
271
+ method: z.string().describe("HTTP method (GET, POST, PUT, PATCH, DELETE)"),
272
+ url: z.string().describe("Full URL to test"),
273
+ headers: z.record(z.string()).optional().describe("Request headers"),
274
+ body: z.any().optional().describe("JSON request body"),
275
+ assertions: z.array(z.any()).optional().describe("List of assertions"),
276
+ project_id: z.string().optional().describe("Scope endpoint registration to a project"),
277
+ }, async (args) => {
278
+ try {
279
+ emitActivity("test_started", { method: args.method, url: args.url });
280
+ const result = await callBackend("test_api", args);
281
+ const runId = result?.run_id != null ? String(result.run_id) : "";
282
+ const dashPath = `/endpoints?id=${runId}`;
283
+ emitActivity("test_complete", {
284
+ run_id: runId,
285
+ method: args.method,
286
+ url: args.url,
287
+ status_code: result?.status_code,
288
+ passed: result?.status_code >= 200 && result?.status_code < 300,
289
+ response_time_ms: result?.response_time_ms,
290
+ path: dashPath,
291
+ });
292
+ return withFrontendUrl({
293
+ ...result,
294
+ ...agentSessionPayload(dashPath),
295
+ _request: { method: args.method, url: args.url, headers: args.headers, body: args.body },
296
+ }, dashPath);
297
+ }
298
+ catch (e) {
299
+ return toolError(e.message, inferErrorCode(e.message), {
300
+ next_actions: ["Verify the URL and method are correct, then retry. Call get_endpoints to check available endpoints."],
301
+ related_tools: ["get_endpoints"],
302
+ });
303
+ }
304
+ });
305
+ // ── test_endpoint_by_id ───────────────────────────────────────────
306
+ 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.", {
307
+ endpoint_id: z.string().describe("UUID from get_endpoints endpoints[].id"),
308
+ headers: z.record(z.string()).optional().describe("Request headers"),
309
+ body: z.any().optional().describe("JSON request body (e.g. login credentials)"),
310
+ assertions: z.array(z.any()).optional().describe("List of assertions"),
311
+ extract: z.record(z.string()).optional().describe("json_path -> variable name for chaining"),
312
+ }, async (args) => {
313
+ try {
314
+ emitActivity("test_started", { endpoint_id: args.endpoint_id });
315
+ const result = await callBackend("test_endpoint_by_id", args);
316
+ const runId = result?.run_id != null ? String(result.run_id) : "";
317
+ const dashPath = runId ? `/endpoints?id=${runId}` : "/endpoints";
318
+ emitActivity("test_complete", {
319
+ run_id: runId,
320
+ endpoint_id: args.endpoint_id,
321
+ method: result?.method,
322
+ url: result?.resolved_url,
323
+ status_code: result?.status_code,
324
+ passed: result?.status_code >= 200 && result?.status_code < 300,
325
+ response_time_ms: result?.response_time_ms,
326
+ path: dashPath,
327
+ });
328
+ return withFrontendUrl({
329
+ ...result,
330
+ ...agentSessionPayload(dashPath),
331
+ _request: {
332
+ endpoint_id: args.endpoint_id,
333
+ resolved_url: result?.resolved_url,
334
+ headers: args.headers,
335
+ body: args.body,
336
+ },
337
+ }, dashPath);
338
+ }
339
+ catch (e) {
340
+ return toolError(e.message, inferErrorCode(e.message), {
341
+ next_actions: ["Verify the endpoint_id exists by calling get_endpoints first."],
342
+ related_tools: ["get_endpoints", "test_api"],
343
+ });
344
+ }
345
+ });
346
+ // ── get_endpoints ─────────────────────────────────────────────────
347
+ 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.", {
348
+ status: z.string().optional().describe("Filter: tested, needed, draft"),
349
+ method: z.string().optional().describe("Filter by HTTP method"),
350
+ include_sessions: z.boolean().optional().default(false).describe("Include recent MCP session test results"),
351
+ include_collections: z.boolean().optional().default(false).describe("Include imported collection endpoints"),
352
+ include_schemas: z.boolean().optional().default(false).describe("Include request/response schemas per endpoint (default false to save tokens)"),
353
+ limit: z.number().optional().default(50).describe("Max endpoints per page (default 50)"),
354
+ offset: z.number().optional().default(0).describe("Pagination offset"),
355
+ project_id: z.string().optional().describe("Scope to a specific project"),
356
+ format: z.enum(["json", "text"]).optional().default("json").describe("'json' (default, structured) or 'text' (deprecated human-friendly)"),
357
+ open_ui: z.boolean().optional().default(false).describe("Also open the visual dashboard (default: false)"),
358
+ }, async (args) => {
359
+ try {
360
+ emitActivity("navigate", { path: "/endpoints", query_params: { status: args.status, method: args.method } });
361
+ const result = await callBackend("get_endpoints", args);
362
+ const asRecord = result;
363
+ if (args.open_ui) {
364
+ return withFrontendUrl(enrichEndpointsBrowserUrl(asRecord), "/endpoints");
365
+ }
366
+ if (args.format === "text" && typeof result === "string") {
367
+ return { content: [{ type: "text", text: result }] };
368
+ }
369
+ const enriched = typeof result === "object" && result !== null && !Array.isArray(result)
370
+ ? { ...enrichEndpointsBrowserUrl(asRecord), ...agentSessionPayload("/endpoints") }
371
+ : result;
372
+ return {
373
+ content: [{
374
+ type: "text",
375
+ text: typeof enriched === "string" ? enriched : JSON.stringify(enriched),
376
+ }],
377
+ };
378
+ }
379
+ catch (e) {
380
+ return toolError(e.message, inferErrorCode(e.message), {
381
+ next_actions: ["If authentication failed, call opentest_login first. Otherwise check that the backend is running."],
382
+ related_tools: ["opentest_login"],
383
+ });
384
+ }
385
+ });
386
+ // ── test_flow ─────────────────────────────────────────────────────
387
+ server.tool("test_flow", "Run a browser-based UI flow test. Navigates to the URL and executes the task using a headless browser. Returns structured results with pass/fail, steps taken, and screenshots.", {
388
+ url: z.string().describe("Starting URL to test"),
389
+ task: z.string().describe("What to test"),
390
+ context: z.string().optional().describe("Extra context (credentials, form data)"),
391
+ }, async (args) => {
392
+ try {
393
+ emitActivity("test_started", { url: args.url, task: args.task, tool: "test_flow" });
394
+ const result = await callBackend("test_flow", args);
395
+ emitActivity("test_complete", { url: args.url, task: args.task, tool: "test_flow", status: result?.status, path: "/runs" });
396
+ return withFrontendUrl({ ...result, ...agentSessionPayload("/runs") }, "/runs");
397
+ }
398
+ catch (e) {
399
+ return toolError(e.message, inferErrorCode(e.message), {
400
+ next_actions: ["If the URL is unreachable, verify it. For simpler tests, use test_api instead."],
401
+ related_tools: ["test_api"],
402
+ });
403
+ }
404
+ });
405
+ // ── generate_tests ────────────────────────────────────────────────
406
+ 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.", {
407
+ description: z.string().describe("What to test in natural language"),
408
+ endpoint_url: z.string().optional().describe("Optional: full URL of the endpoint to test"),
409
+ method: z.string().optional().describe("HTTP method (GET, POST, PUT, PATCH, DELETE)"),
410
+ coverage_level: z.enum(["standard", "comprehensive", "security"]).optional().default("standard").describe("Coverage depth"),
411
+ project_id: z.string().optional().describe("Scope generated collection to a project"),
412
+ endpoint_ids: z.array(z.string()).optional().describe("Registry endpoint UUIDs to generate tests for (overrides endpoint_url/method)"),
413
+ }, async (args) => {
414
+ try {
415
+ const result = await callBackend("generate_tests", args);
416
+ const payload = typeof result === "string" ? JSON.parse(result) : result;
417
+ const collectionId = payload?.collection_id ?? "";
418
+ const dashPath = collectionId ? `/collections?id=${collectionId}` : "/collections";
419
+ emitActivity("navigate", { path: dashPath });
420
+ emitActivity("data_refresh", { scope: "collections" });
421
+ return withFrontendUrl({ ...payload, ...agentSessionPayload(dashPath) }, dashPath);
422
+ }
423
+ catch (e) {
424
+ return toolError(e.message, inferErrorCode(e.message), {
425
+ next_actions: ["Provide a more specific description, or use endpoint_ids for registry-based generation."],
426
+ related_tools: ["import_collection", "test_api"],
427
+ });
428
+ }
429
+ });
430
+ // ── get_coverage ──────────────────────────────────────────────────
431
+ 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.", {
432
+ collection_yaml: z.string().optional().describe("Raw .ot.yaml content to analyze"),
433
+ collection_id: z.string().optional().describe("ID of a saved collection from the dashboard"),
434
+ include_endpoint_details: z.boolean().optional().default(false).describe("Include per-endpoint analysis (default false to save tokens)"),
435
+ }, async (args) => {
436
+ try {
437
+ const collectionId = args.collection_id ?? "";
438
+ const dashPath = collectionId ? `/collections?id=${collectionId}&view=coverage` : "/collections";
439
+ emitActivity("navigate", { path: dashPath });
440
+ const result = await callBackend("get_coverage", args);
441
+ return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
442
+ }
443
+ catch (e) {
444
+ return toolError(e.message, inferErrorCode(e.message), {
445
+ next_actions: ["Provide a collection_id or collection_yaml. Call list_collections to find available collections."],
446
+ related_tools: ["import_collection", "list_collections"],
447
+ });
448
+ }
449
+ });
450
+ // ── import_collection ─────────────────────────────────────────────
451
+ 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.", {
452
+ content: z.string().describe("Raw collection content (Postman JSON, OpenAPI YAML, Bruno .bru, curl command)"),
453
+ format_hint: z.enum(["postman", "openapi", "bruno", "curl"]).optional().describe("Format override (auto-detected if omitted)"),
454
+ include_yaml: z.boolean().optional().default(false).describe("Include the full .ot.yaml string in the response (default false to save tokens)"),
455
+ project_id: z.string().optional().describe("Scope imported collection to a project"),
456
+ }, async (args) => {
457
+ try {
458
+ const result = await callBackend("import_collection", args);
459
+ const collectionId = result?.collection_id ?? "";
460
+ const dashPath = collectionId ? `/collections?id=${collectionId}` : "/collections";
461
+ emitActivity("navigate", { path: dashPath });
462
+ emitActivity("data_refresh", { scope: "collections" });
463
+ return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
464
+ }
465
+ catch (e) {
466
+ return toolError(e.message, inferErrorCode(e.message), {
467
+ next_actions: ["Check that the content is valid Postman JSON, OpenAPI YAML, Bruno .bru, or curl. Try format_hint to force detection."],
468
+ related_tools: ["get_endpoints"],
469
+ });
470
+ }
471
+ });
472
+ // ── detect_drift ──────────────────────────────────────────────────
473
+ 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.", {
474
+ base_url: z.string().describe("Base URL to test against (e.g. https://api.example.com)"),
475
+ collection_yaml: z.string().optional().describe("Raw .ot.yaml content"),
476
+ collection_id: z.string().optional().describe("ID of a saved collection from the dashboard"),
477
+ headers: z.record(z.string()).optional().describe("Request headers applied to all drift-check requests (e.g. auth tokens)"),
478
+ }, async (args) => {
479
+ try {
480
+ emitActivity("navigate", { path: "/endpoints", query_params: { view: "drift" } });
481
+ const result = await callBackend("detect_drift", args);
482
+ return withFrontendUrl({ ...result, ...agentSessionPayload("/endpoints?view=drift") }, "/endpoints?view=drift");
483
+ }
484
+ catch (e) {
485
+ return toolError(e.message, inferErrorCode(e.message), {
486
+ next_actions: ["Provide a base_url and either a collection_id or collection_yaml. Call list_collections to find saved collections."],
487
+ related_tools: ["import_collection", "test_api", "list_collections"],
488
+ });
489
+ }
490
+ });
491
+ // ── run_tests ─────────────────────────────────────────────────────
492
+ 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.", {
493
+ endpoints: z.array(z.object({
494
+ method: z.string().describe("HTTP method"),
495
+ path: z.string().describe("URL path or full URL"),
496
+ base_url: z.string().optional().describe("Base URL override for this endpoint"),
497
+ })).describe("List of endpoints to test"),
498
+ base_url: z.string().optional().describe("Fallback base URL for endpoints without one"),
499
+ headers: z.record(z.string()).optional().describe("Request headers applied to all requests"),
500
+ }, async (args) => {
501
+ try {
502
+ emitActivity("test_started", { tool: "run_tests", endpoint_count: args.endpoints.length });
503
+ const result = await callBackend("run_api_tests", args);
504
+ emitActivity("test_complete", { tool: "run_tests", total: result?.total, passed: result?.passed, failed: result?.failed });
505
+ return withFrontendUrl({ ...result, ...agentSessionPayload("/endpoints") }, "/endpoints");
506
+ }
507
+ catch (e) {
508
+ return toolError(e.message, inferErrorCode(e.message), {
509
+ next_actions: ["Verify the endpoints array has valid method and path fields. Use test_api for individual endpoint debugging."],
510
+ related_tools: ["test_api", "get_endpoints"],
511
+ });
512
+ }
513
+ });
514
+ // ── list_collections ──────────────────────────────────────────────
515
+ 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.", {
516
+ project_id: z.string().optional().describe("Scope to a specific project"),
517
+ }, async (args) => {
518
+ try {
519
+ const result = await callBackend("list_collections", args);
520
+ emitActivity("navigate", { path: "/collections" });
521
+ return {
522
+ content: [{ type: "text", text: JSON.stringify({ ...result, ...agentSessionPayload("/collections") }) }],
523
+ };
524
+ }
525
+ catch (e) {
526
+ return toolError(e.message, inferErrorCode(e.message), {
527
+ next_actions: ["If no collections exist, import one with import_collection."],
528
+ related_tools: ["import_collection"],
529
+ });
530
+ }
531
+ });
532
+ // ── get_collection ───────────────────────────────────────────────
533
+ 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.", {
534
+ collection_id: z.string().describe("UUID of the collection"),
535
+ include_content: z.boolean().optional().default(false).describe("Include the raw YAML content (default false to save tokens)"),
536
+ }, async (args) => {
537
+ try {
538
+ const result = await callBackend("get_collection", args);
539
+ const collectionId = result?.id ?? args.collection_id;
540
+ const dashPath = `/collections?id=${collectionId}`;
541
+ emitActivity("navigate", { path: dashPath });
542
+ return withFrontendUrl({ ...result, ...agentSessionPayload(dashPath) }, dashPath);
543
+ }
544
+ catch (e) {
545
+ return toolError(e.message, inferErrorCode(e.message), {
546
+ next_actions: ["Verify the collection_id by calling list_collections first."],
547
+ related_tools: ["list_collections"],
548
+ });
549
+ }
550
+ });
551
+ // ── delete_collection ────────────────────────────────────────────
552
+ server.tool("delete_collection", "Soft-delete a collection by ID. The collection is archived (is_active=false) and no longer appears in list_collections.", {
553
+ collection_id: z.string().describe("UUID of the collection to delete"),
554
+ }, async (args) => {
555
+ try {
556
+ const result = await callBackend("delete_collection", args);
557
+ emitActivity("data_refresh", { scope: "collections" });
558
+ return {
559
+ content: [{ type: "text", text: JSON.stringify({ ...result, ...agentSessionPayload("/collections") }) }],
560
+ };
561
+ }
562
+ catch (e) {
563
+ return toolError(e.message, inferErrorCode(e.message), {
564
+ next_actions: ["Verify the collection_id by calling list_collections first."],
565
+ related_tools: ["list_collections"],
566
+ });
567
+ }
568
+ });
569
+ // ── list_runs ────────────────────────────────────────────────────
570
+ server.tool("list_runs", "List recent test runs with summary metadata. Heavy fields (logs, video paths) are stripped to save tokens.", {
571
+ limit: z.number().optional().default(20).describe("Max runs to return (default 20)"),
572
+ }, async (args) => {
573
+ try {
574
+ const result = await callBackend("list_runs", args);
575
+ emitActivity("navigate", { path: "/runs" });
576
+ return withFrontendUrl({ ...result, ...agentSessionPayload("/runs") }, "/runs");
577
+ }
578
+ catch (e) {
579
+ return toolError(e.message, inferErrorCode(e.message), {
580
+ next_actions: ["If no runs exist, create one with test_api or run_tests."],
581
+ related_tools: ["test_api", "run_tests", "test_flow"],
582
+ });
583
+ }
584
+ });
585
+ // ── endpoints_dashboard ───────────────────────────────────────────────
586
+ 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.", {
587
+ filter_method: z.string().optional().describe("Filter by HTTP method: GET, POST, PUT, PATCH, DELETE"),
588
+ filter_source: z.string().optional().describe("Filter by source: 'collection', 'mcp', or 'project'"),
589
+ project_id: z.string().optional().describe("Scope to a specific project"),
590
+ format: z.enum(["json", "text"]).optional().default("json").describe("'json' (default, structured) or 'text' (deprecated terminal UI)"),
591
+ open_ui: z.boolean().optional().default(false).describe("Also open the visual dashboard (default: false)"),
592
+ }, async (args) => {
593
+ try {
594
+ emitActivity("navigate", { path: "/endpoints" });
595
+ const result = await callBackend("endpoints_dashboard", args);
596
+ const asRecord = result;
597
+ if (args.open_ui) {
598
+ return withFrontendUrl(enrichEndpointsBrowserUrl(asRecord), "/endpoints");
599
+ }
600
+ if (args.format === "text" && typeof result === "string") {
601
+ return { content: [{ type: "text", text: result }] };
602
+ }
603
+ const enriched = typeof result === "object" && result !== null && !Array.isArray(result)
604
+ ? { ...enrichEndpointsBrowserUrl(asRecord), ...agentSessionPayload("/endpoints") }
605
+ : result;
606
+ return {
607
+ content: [{
608
+ type: "text",
609
+ text: typeof enriched === "string" ? enriched : JSON.stringify(enriched),
610
+ }],
611
+ };
612
+ }
613
+ catch (e) {
614
+ return toolError(e.message, inferErrorCode(e.message), {
615
+ next_actions: ["DEPRECATED: Use get_endpoints instead."],
616
+ related_tools: ["get_endpoints"],
617
+ });
618
+ }
619
+ });
620
+ // ── opentest_login ─────────────────────────────────────────────────
621
+ server.tool("opentest_login", "Authenticate with OpenTest. 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 opentest_login_complete with device_code.", {
622
+ device_name: z.string().optional().describe("Friendly name for this device (e.g. 'My MacBook')"),
623
+ }, async (args) => {
624
+ if (API_KEY) {
625
+ const check = await verifyApiKey(API_KEY);
626
+ if (check.valid) {
627
+ return {
628
+ content: [{
629
+ type: "text",
630
+ text: JSON.stringify({
631
+ status: "already_authenticated",
632
+ email: check.email,
633
+ message: `Already logged in as ${check.email ?? "unknown"}. Use opentest_logout first if you want to switch accounts.`,
634
+ }),
635
+ }],
636
+ };
637
+ }
638
+ }
639
+ const deviceName = args.device_name || `${os.hostname()} MCP`;
640
+ try {
641
+ const reqResp = await fetch(`${BACKEND_URL}/auth/device/request`, {
642
+ method: "POST",
643
+ headers: { "Content-Type": "application/json" },
644
+ body: JSON.stringify({ device_name: deviceName }),
645
+ });
646
+ if (!reqResp.ok) {
647
+ const text = await reqResp.text();
648
+ throw new Error(`Device auth request failed: ${reqResp.status} ${text}`);
649
+ }
650
+ const reqData = await reqResp.json();
651
+ const deviceCode = reqData.device_code;
652
+ const userCode = reqData.user_code;
653
+ const verificationUrl = reqData.verification_url;
654
+ const expiresIn = reqData.expires_in ?? 600;
655
+ const pollInterval = reqData.poll_interval ?? 5;
656
+ return {
657
+ content: [{
658
+ type: "text",
659
+ text: JSON.stringify({
660
+ status: "awaiting_approval",
661
+ user_code: userCode,
662
+ verification_url: verificationUrl,
663
+ expires_in_seconds: expiresIn,
664
+ poll_interval_seconds: pollInterval,
665
+ _device_code: deviceCode,
666
+ instructions: [
667
+ `Open this URL to approve: ${verificationUrl}`,
668
+ `Your code is: ${userCode}`,
669
+ "Log in (if needed) and click Approve.",
670
+ "Then run opentest_login_complete to finish setup.",
671
+ ],
672
+ _cursor_agent_steps: [
673
+ `Use Cursor Agent Browser: browser_navigate to: ${verificationUrl}`,
674
+ `The user code is ${userCode}. The user must log in and approve.`,
675
+ `After the user approves, call opentest_login_complete with device_code="${deviceCode}" to finish.`,
676
+ ],
677
+ }),
678
+ }],
679
+ };
680
+ }
681
+ catch (e) {
682
+ return toolError(e.message, "backend_error", { next_actions: ["Retry opentest_login"], related_tools: ["opentest_login"] });
683
+ }
684
+ });
685
+ // ── opentest_login_complete ────────────────────────────────────────
686
+ // Polls the backend for the approved API key after the user approves
687
+ // the device request in the browser.
688
+ server.tool("opentest_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.", {
689
+ device_code: z.string().describe("The device_code returned by opentest_login"),
690
+ }, async (args) => {
691
+ const maxAttempts = 60;
692
+ const pollMs = 5000;
693
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
694
+ try {
695
+ const resp = await fetch(`${BACKEND_URL}/auth/device/poll`, {
696
+ method: "POST",
697
+ headers: { "Content-Type": "application/json" },
698
+ body: JSON.stringify({ device_code: args.device_code }),
699
+ });
700
+ if (!resp.ok) {
701
+ const text = await resp.text();
702
+ throw new Error(`Poll error: ${resp.status} ${text}`);
703
+ }
704
+ const data = await resp.json();
705
+ const status = data.status;
706
+ if (status === "approved" && data.api_key) {
707
+ const apiKey = data.api_key;
708
+ API_KEY = apiKey;
709
+ const check = await verifyApiKey(apiKey);
710
+ await saveCredentials({
711
+ api_key: apiKey,
712
+ backend_url: BACKEND_URL,
713
+ user_email: check.email ?? data.user_email,
714
+ created_at: new Date().toISOString(),
715
+ });
716
+ return {
717
+ content: [{
718
+ type: "text",
719
+ text: JSON.stringify({
720
+ status: "authenticated",
721
+ email: check.email,
722
+ message: `Successfully authenticated as ${check.email ?? "unknown"}. Credentials saved — you won't need to log in again.`,
723
+ endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
724
+ _agent_hints: {
725
+ next_actions: [
726
+ `browser_navigate to the URL in endpoints_page_url to open Flow on the Endpoints page (sign in to the web app if needed).`,
727
+ "Call get_endpoints to see your registered API endpoints.",
728
+ "Call test_api to test an endpoint.",
729
+ "Call import_collection to import a Postman/OpenAPI spec.",
730
+ ],
731
+ related_tools: ["get_endpoints", "test_api", "import_collection"],
732
+ },
733
+ }),
734
+ }],
735
+ };
736
+ }
737
+ if (status === "expired") {
738
+ return toolError("Device authorization request expired. Run opentest_login again.", "auth_required", {
739
+ next_actions: ["Re-run opentest_login"],
740
+ related_tools: ["opentest_login"],
741
+ });
742
+ }
743
+ // Still pending — wait and retry
744
+ await new Promise((r) => setTimeout(r, pollMs));
745
+ }
746
+ catch (e) {
747
+ return toolError(e.message, "backend_error", { next_actions: ["Re-run opentest_login"], related_tools: ["opentest_login"] });
748
+ }
749
+ }
750
+ return toolError("Timed out waiting for approval. Run opentest_login again.", "auth_required", {
751
+ next_actions: ["Re-run opentest_login"],
752
+ related_tools: ["opentest_login"],
753
+ });
754
+ });
755
+ // ── opentest_status ────────────────────────────────────────────────
756
+ server.tool("opentest_status", "Check OpenTest authentication status and connection info.", {}, async () => {
757
+ if (!API_KEY) {
758
+ return {
759
+ content: [{
760
+ type: "text",
761
+ text: JSON.stringify({
762
+ authenticated: false,
763
+ backend_url: BACKEND_URL,
764
+ frontend_base_url: FRONTEND_BASE,
765
+ endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
766
+ message: "Not authenticated. Run opentest_login to connect your account.",
767
+ }),
768
+ }],
769
+ };
770
+ }
771
+ const check = await verifyApiKey(API_KEY);
772
+ if (!check.valid) {
773
+ return {
774
+ content: [{
775
+ type: "text",
776
+ text: JSON.stringify({
777
+ authenticated: false,
778
+ backend_url: BACKEND_URL,
779
+ frontend_base_url: FRONTEND_BASE,
780
+ endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
781
+ message: "Stored API key is no longer valid. Run opentest_login to re-authenticate.",
782
+ }),
783
+ }],
784
+ };
785
+ }
786
+ return {
787
+ content: [{
788
+ type: "text",
789
+ text: JSON.stringify({
790
+ authenticated: true,
791
+ email: check.email,
792
+ key_name: check.key_name,
793
+ backend_url: BACKEND_URL,
794
+ frontend_base_url: FRONTEND_BASE,
795
+ endpoints_page_url: buildAgentDashboardUrl("/endpoints"),
796
+ _agent_hints: {
797
+ next_actions: [
798
+ "browser_navigate to `endpoints_page_url` (SPA shell + ot_agent_route) to open Flow on the Endpoints page (sign in if prompted), then call get_endpoints.",
799
+ "Call get_endpoints to see registered API endpoints.",
800
+ "Call test_api to test an endpoint.",
801
+ ],
802
+ related_tools: ["get_endpoints", "test_api", "import_collection"],
803
+ },
804
+ }),
805
+ }],
806
+ };
807
+ });
808
+ // ── opentest_logout ────────────────────────────────────────────────
809
+ server.tool("opentest_logout", "Log out of OpenTest. Clears stored credentials so you can switch accounts or re-authenticate.", {}, async () => {
810
+ const wasAuthenticated = !!API_KEY;
811
+ API_KEY = "";
812
+ await clearCredentials();
813
+ return {
814
+ content: [{
815
+ type: "text",
816
+ text: JSON.stringify({
817
+ status: "logged_out",
818
+ was_authenticated: wasAuthenticated,
819
+ message: wasAuthenticated
820
+ ? "Logged out and credentials cleared. Run opentest_login to sign in again."
821
+ : "No credentials to clear. Already logged out.",
822
+ }),
823
+ }],
824
+ };
825
+ });
826
+ // ── App user auth (JWT) — same server as opentest-local / OPENTEST_BACKEND
827
+ registerUserAuthFlowTools(server, BACKEND_URL);
828
+ // ── Hosted MCP platform tools ──────────────────────────────────────
829
+ // Thin stdio proxies; all real logic lives in flowtest/mcp/hosted_mcp_tools.py
830
+ // and is reached via the /mcp/call-tool HTTP bridge.
831
+ 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.", {
832
+ base_path: z.string().optional().describe("Directory to scan (defaults to CWD)"),
833
+ framework_hint: z.string().optional().describe("fastapi | express | nestjs | rails | django | nextjs"),
834
+ }, async (args) => {
835
+ try {
836
+ const result = await callBackend("discover_endpoints_from_codebase", args);
837
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
838
+ }
839
+ catch (e) {
840
+ return toolError(e.message, inferErrorCode(e.message), {
841
+ related_tools: ["verify_endpoints_live"],
842
+ });
843
+ }
844
+ });
845
+ 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.", {
846
+ endpoints: z.array(z.any()).describe("Endpoints proposed by discover_endpoints_from_codebase"),
847
+ local_base_url: z.string().describe("Where the backend is running, e.g. http://localhost:8000"),
848
+ }, async (args) => {
849
+ try {
850
+ const result = await callBackend("verify_endpoints_live", args);
851
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
852
+ }
853
+ catch (e) {
854
+ return toolError(e.message, inferErrorCode(e.message), {
855
+ next_actions: ["Start the local backend, then retry."],
856
+ related_tools: ["discover_endpoints_from_codebase", "mcp_preview"],
857
+ });
858
+ }
859
+ });
860
+ server.tool("mcp_preview", "Pick endpoints matching an intent and generate a tool-schema preview. Also writes the same two-pane HTML to opentest-mcp/mcp-preview-last.html and returns preview_file_url — use Cursor Simple Browser or `node opentest-mcp/scripts/open-cursor-preview.mjs` if the inline MCP app panel does not appear. LLM selection when OPENAI_API_KEY is set on the backend; else keyword match. Does NOT write to the DB.", {
861
+ intent: z.string().describe("Free text like 'auth endpoints' or 'everything related to orders'"),
862
+ endpoints: z.array(z.any()).describe("Candidate endpoints (typically the confirmed bucket from verify_endpoints_live)"),
863
+ upstream_base_url: z.string().describe("Base URL the deployed MCP will proxy to"),
864
+ }, async (args) => {
865
+ try {
866
+ const result = await callBackend("mcp_preview", args);
867
+ const previewData = (result && typeof result === "object" && !Array.isArray(result))
868
+ ? result
869
+ : {};
870
+ const panelHtml = buildConversionPanelHtml(previewData);
871
+ const { absolutePath, fileUrl } = await writeMcpPreviewFile(panelHtml);
872
+ const basePayload = result && typeof result === "object" && !Array.isArray(result)
873
+ ? result
874
+ : { raw: result };
875
+ const withPreviewNav = {
876
+ ...basePayload,
877
+ preview_html_path: absolutePath,
878
+ preview_file_url: fileUrl,
879
+ _how_to_see_the_ui: [
880
+ "Cursor may not show the embedded MCP app (no new tab is opened by default).",
881
+ "Run `node opentest-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.",
882
+ "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.",
883
+ "File → Open on preview_html_path shows source, not a rendered page.",
884
+ ],
885
+ };
886
+ return {
887
+ content: [
888
+ { type: "text", text: JSON.stringify(withPreviewNav, null, 0) },
889
+ {
890
+ type: "resource",
891
+ resource: {
892
+ uri: MCP_PREVIEW_RESOURCE_URI,
893
+ mimeType: "text/html;profile=mcp-app",
894
+ text: panelHtml,
895
+ },
896
+ },
897
+ {
898
+ type: "resource_link",
899
+ name: "opentest-mcp-preview",
900
+ title: "OpenTest · MCP Preview",
901
+ uri: MCP_PREVIEW_RESOURCE_URI,
902
+ mimeType: "text/html;profile=mcp-app",
903
+ },
904
+ ],
905
+ _meta: { [RESOURCE_URI_META_KEY]: MCP_PREVIEW_RESOURCE_URI },
906
+ };
907
+ }
908
+ catch (e) {
909
+ return toolError(e.message, inferErrorCode(e.message), {
910
+ related_tools: ["mcp_deploy"],
911
+ });
912
+ }
913
+ });
914
+ server.tool("mcp_deploy", "Create and host an MCP server from a preview spec plus an upstream credential. By default this also mints the first consumer token and returns one complete install snippet or hosted URL to give to a coding agent. The upstream secret is encrypted at rest with a Fernet key; it is never returned to clients after this call.", {
915
+ name: z.string().describe("Human-readable name, e.g. 'Acme Auth MCP'"),
916
+ upstream_base_url: z.string().describe("Base URL of the business's real backend"),
917
+ spec: z.any().describe("The spec_preview object returned by mcp_preview"),
918
+ initial_upstream_secret: z.string().describe("API key / bearer / basic value the MCP will use to reach the upstream backend"),
919
+ upstream_auth_style: z.any().optional().describe("Override how the credential is injected. Default: { type:'header', name:'Authorization', prefix:'Bearer ' }"),
920
+ initial_upstream_secret_type: z.string().optional().describe("bearer | api_key | basic | custom"),
921
+ initial_consumer_label: z.string().optional().describe("Optional label for the first consumer token. Defaults to default-consumer; pass empty only if you want to mint a token later."),
922
+ }, async (args) => {
923
+ try {
924
+ const result = await callBackend("mcp_deploy", args);
925
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
926
+ }
927
+ catch (e) {
928
+ return toolError(e.message, inferErrorCode(e.message), {
929
+ related_tools: ["mcp_mint_consumer_token", "mcp_list_deployed"],
930
+ });
931
+ }
932
+ });
933
+ server.tool("mcp_list_deployed", "List the hosted MCPs the current user has deployed.", {}, async () => {
934
+ try {
935
+ const result = await callBackend("mcp_list_deployed", {});
936
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
937
+ }
938
+ catch (e) {
939
+ return toolError(e.message, inferErrorCode(e.message));
940
+ }
941
+ });
942
+ 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.", {
943
+ hosted_mcp_id: z.string().describe("The hosted MCP's id"),
944
+ consumer_label: z.string().describe("Human-readable label for this consumer, e.g. 'acme-beta-bob'"),
945
+ upstream_credential_id: z.string().optional().describe("Optional credential mapping for this consumer token"),
946
+ }, async (args) => {
947
+ try {
948
+ const result = await callBackend("mcp_mint_consumer_token", args);
949
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
950
+ }
951
+ catch (e) {
952
+ return toolError(e.message, inferErrorCode(e.message), {
953
+ related_tools: ["mcp_revoke_consumer_token", "mcp_list_deployed"],
954
+ });
955
+ }
956
+ });
957
+ 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.", {
958
+ hosted_mcp_id: z.string().describe("The hosted MCP's id"),
959
+ token_id: z.string().describe("The consumer token's id (from mcp_mint_consumer_token's token.id)"),
960
+ }, async (args) => {
961
+ try {
962
+ const result = await callBackend("mcp_revoke_consumer_token", args);
963
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
964
+ }
965
+ catch (e) {
966
+ return toolError(e.message, inferErrorCode(e.message));
967
+ }
968
+ });
969
+ return server;
970
+ }
971
+ // ── Start ─────────────────────────────────────────────────────────────
972
+ async function main() {
973
+ await initAuth();
974
+ const server = createServer();
975
+ const transport = new StdioServerTransport();
976
+ await server.connect(transport);
977
+ }
978
+ main().catch(console.error);
979
+ process.on("SIGINT", () => { process.exit(0); });
980
+ process.on("SIGTERM", () => { process.exit(0); });