@vibeinfra/mcp-server 0.1.0 → 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/README.md CHANGED
@@ -38,6 +38,8 @@ The server speaks stdio and requires Node 18+.
38
38
 
39
39
  ## Tools
40
40
 
41
+ ### 🔍 Discovery & Catalog Tools (Public, Read-Only)
42
+
41
43
  | Tool | What it does |
42
44
  | ---------------------------- | ---------------------------------------------------------------------------- |
43
45
  | `list_incidents` | Every incident and course in the catalog, with tier, stack, duration and link. |
@@ -46,19 +48,59 @@ The server speaks stdio and requires Node 18+.
46
48
  | `get_platform_capacity` | Live sandbox usage and queue length. |
47
49
  | `lookup_infrastructure_term` | The VibeInfra infrastructure/SRE glossary. |
48
50
 
51
+ ### ⚡ Interactive SRE Incident Drills, Diagnostics & Headless Troubleshooting
52
+
53
+ | Tool | What it does |
54
+ | ---------------------------- | ---------------------------------------------------------------------------- |
55
+ | `start_drill` | Spawns a live disposable sandbox and returns session ID and web terminal. |
56
+ | `exec_in_sandbox` | Runs diagnostic/remediation commands (`kubectl`, `df`, `systemctl`) in sandbox. |
57
+ | `get_drill_hint` | Provides spoiler-free progressive guidance and diagnostic nudges. |
58
+ | `grade_drill` | Runs automated system-state evaluation against the live sandbox to score fixes. |
59
+ | `get_drill_status` | Returns real-time telemetry, node health status, and live topology. |
60
+ | `stop_drill` | Cleanly terminates and destroys the disposable sandbox container. |
61
+
62
+ ## Authentication
63
+
64
+ ### Zero-Config CLI Login (Recommended)
65
+
66
+ Authenticate your terminal or agent host in 5 seconds without touching JSON config files:
67
+
68
+ ```bash
69
+ npx -y @vibeinfra/mcp-server login
70
+ ```
71
+
72
+ This opens your browser to authorize your account and securely saves your Personal Access Token to `~/.vibeinfra/credentials.json` (POSIX `0600` permissions). Subsequent runs of `@vibeinfra/mcp-server` automatically discover this token.
73
+
74
+ To log out:
75
+ ```bash
76
+ npx -y @vibeinfra/mcp-server logout
77
+ ```
78
+
79
+ ### 1-Click Dashboard Token
80
+
81
+ You can also generate a Personal Access Token from your VibeInfra Profile:
82
+ 1. Navigate to **[https://vibeinfra.id/profile](https://vibeinfra.id/profile)**.
83
+ 2. Under **AI Agents & MCP Integration**, click **Generate Agent Token**.
84
+ 3. Click **Copy Configuration** and paste it directly into your `.mcp.json` or Cursor / Claude Code settings.
85
+
49
86
  ## Configuration
50
87
 
51
88
  | Environment variable | Default | Purpose |
52
89
  | ------------------------ | -------------------------- | ------------------------------------ |
53
90
  | `VIBEINFRA_API_BASE_URL` | `https://api.vibeinfra.id` | Point the server at a local backend. |
91
+ | `VIBEINFRA_API_KEY` | `""` | Bearer token / API key for authorized lab access. |
54
92
 
55
93
  ## Try it
56
94
 
57
95
  Ask your agent:
58
96
 
59
- > Find me an expert-tier Kafka incident on VibeInfra and summarise what it teaches.
97
+ > Find me a beginner-friendly Kubernetes incident on VibeInfra.
98
+
99
+ > Start an incident drill for incident-40-deployment-never-rolled-out.
100
+
101
+ > I'm stuck on this drill — give me a spoiler-free hint.
60
102
 
61
- > Is VibeInfra busy right now, or can I start a lab straight away?
103
+ > I applied my fix to the Kubernetes deployment grade my drill now.
62
104
 
63
105
  ## Related
64
106
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vibeinfra/mcp-server",
3
- "version": "0.1.0",
4
- "description": "Model Context Protocol server for VibeInfra — lets an AI agent browse the SRE incident catalog, read live sandbox capacity and look up infrastructure terms.",
3
+ "version": "0.3.0",
4
+ "description": "Model Context Protocol server for VibeInfra — enables AI agents to browse the SRE incident catalog, start live disposable sandboxes, coach learners with hints, and grade remediations.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "modelcontextprotocol",
package/src/api.js CHANGED
@@ -22,12 +22,21 @@ export class ApiError extends Error {
22
22
  }
23
23
 
24
24
  export class VibeInfraApi {
25
- constructor({ baseUrl = DEFAULT_BASE_URL, timeoutMs = DEFAULT_TIMEOUT_MS, fetch: fetchImpl } = {}) {
25
+ constructor({ baseUrl = DEFAULT_BASE_URL, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS, fetch: fetchImpl } = {}) {
26
26
  this.baseUrl = baseUrl.replace(/\/+$/, "");
27
+ this.apiKey = apiKey;
27
28
  this.timeoutMs = timeoutMs;
28
29
  this.fetch = fetchImpl ?? globalThis.fetch;
29
30
  }
30
31
 
32
+ getHeaders() {
33
+ const headers = { Accept: "application/json" };
34
+ if (this.apiKey) {
35
+ headers.Authorization = `Bearer ${this.apiKey}`;
36
+ }
37
+ return headers;
38
+ }
39
+
31
40
  async get(path, query) {
32
41
  const params = new URLSearchParams();
33
42
  for (const [key, value] of Object.entries(query ?? {})) {
@@ -41,7 +50,7 @@ export class VibeInfraApi {
41
50
  let response;
42
51
  try {
43
52
  response = await this.fetch(url, {
44
- headers: { Accept: "application/json" },
53
+ headers: this.getHeaders(),
45
54
  signal: controller.signal,
46
55
  });
47
56
  } finally {
@@ -54,6 +63,42 @@ export class VibeInfraApi {
54
63
  return response.json();
55
64
  }
56
65
 
66
+ async post(path, body) {
67
+ const url = `${this.baseUrl}/api/${API_VERSION}${path}`;
68
+ const headers = {
69
+ ...this.getHeaders(),
70
+ "Content-Type": "application/json",
71
+ };
72
+
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
75
+ let response;
76
+ try {
77
+ response = await this.fetch(url, {
78
+ method: "POST",
79
+ headers,
80
+ body: JSON.stringify(body ?? {}),
81
+ signal: controller.signal,
82
+ });
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+
87
+ if (!response.ok) {
88
+ let errorMsg = `POST ${url} failed with ${response.status}`;
89
+ try {
90
+ const errorBody = await response.json();
91
+ if (errorBody && errorBody.message) {
92
+ errorMsg = errorBody.message;
93
+ }
94
+ } catch {
95
+ // Fall back to default errorMsg if response body is not JSON
96
+ }
97
+ throw new ApiError(response.status, url, errorMsg);
98
+ }
99
+ return response.json();
100
+ }
101
+
57
102
  listCourses(locale) {
58
103
  return this.get("/courses", { locale });
59
104
  }
@@ -73,4 +118,36 @@ export class VibeInfraApi {
73
118
  getLeaderboard() {
74
119
  return this.get("/labs/leaderboard");
75
120
  }
121
+
122
+ startLab(courseId, forceFresh = false) {
123
+ return this.post("/labs/start", { course_id: courseId, force_fresh: forceFresh });
124
+ }
125
+
126
+ checkLab(sessionId, courseId) {
127
+ return this.post("/labs/check", { session_id: sessionId, course_id: courseId });
128
+ }
129
+
130
+ getHint(sessionId) {
131
+ return this.post("/labs/hint", { session_id: sessionId });
132
+ }
133
+
134
+ destroyLab(sessionId) {
135
+ return this.post("/labs/destroy", { session_id: sessionId });
136
+ }
137
+
138
+ getLabTelemetry(sessionId) {
139
+ return this.get("/labs/telemetry", { session_id: sessionId });
140
+ }
141
+
142
+ getLabTopology(sessionId) {
143
+ return this.get("/labs/topology", { session_id: sessionId });
144
+ }
145
+
146
+ execInSandbox(sessionId, command, timeoutSec = 15) {
147
+ return this.post("/labs/exec", {
148
+ session_id: sessionId,
149
+ command,
150
+ timeout_sec: timeoutSec,
151
+ });
152
+ }
76
153
  }
package/src/cli.js CHANGED
@@ -9,7 +9,34 @@
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
 
11
11
  import { createServer } from "./server.js";
12
+ import { getStoredApiKey, runLogin, runLogout } from "./login.js";
12
13
 
13
- const server = createServer({ baseUrl: process.env.VIBEINFRA_API_BASE_URL });
14
+ const command = process.argv[2];
15
+
16
+ if (command === "login") {
17
+ try {
18
+ await runLogin({
19
+ webBaseUrl: process.env.VIBEINFRA_WEB_URL || "https://vibeinfra.id",
20
+ });
21
+ process.exit(0);
22
+ } catch (err) {
23
+ console.error("❌ Login failed:", err.message || err);
24
+ process.exit(1);
25
+ }
26
+ }
27
+
28
+ if (command === "logout") {
29
+ runLogout();
30
+ process.exit(0);
31
+ }
32
+
33
+ // Automatically resolve API key from environment or ~/.vibeinfra/credentials.json
34
+ const resolvedApiKey = process.env.VIBEINFRA_API_KEY || getStoredApiKey();
35
+
36
+ const server = createServer({
37
+ baseUrl: process.env.VIBEINFRA_API_BASE_URL,
38
+ apiKey: resolvedApiKey,
39
+ });
14
40
 
15
41
  await server.connect(new StdioServerTransport());
42
+
package/src/login.js ADDED
@@ -0,0 +1,142 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import crypto from "node:crypto";
6
+ import { exec } from "node:child_process";
7
+
8
+ const CONFIG_DIR = path.join(os.homedir(), ".vibeinfra");
9
+ const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
10
+
11
+ /**
12
+ * Read the stored API token from ~/.vibeinfra/credentials.json
13
+ */
14
+ export function getStoredApiKey() {
15
+ try {
16
+ if (fs.existsSync(CREDENTIALS_FILE)) {
17
+ const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8"));
18
+ if (data && data.api_key) {
19
+ return data.api_key;
20
+ }
21
+ }
22
+ } catch {
23
+ // Ignore unreadable or corrupted config
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Persist the API token with strict POSIX 0600 permissions
30
+ */
31
+ export function saveApiKey(apiKey) {
32
+ if (!fs.existsSync(CONFIG_DIR)) {
33
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
34
+ }
35
+ fs.writeFileSync(
36
+ CREDENTIALS_FILE,
37
+ JSON.stringify(
38
+ {
39
+ api_key: apiKey,
40
+ saved_at: new Date().toISOString(),
41
+ },
42
+ null,
43
+ 2
44
+ ),
45
+ { mode: 0o600 }
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Wipe stored credentials
51
+ */
52
+ export function clearApiKey() {
53
+ try {
54
+ if (fs.existsSync(CREDENTIALS_FILE)) {
55
+ fs.unlinkSync(CREDENTIALS_FILE);
56
+ }
57
+ } catch {
58
+ // Ignore error
59
+ }
60
+ }
61
+
62
+ function openBrowser(url) {
63
+ const platform = os.platform();
64
+ if (platform === "darwin") {
65
+ exec(`open "${url}"`);
66
+ } else if (platform === "win32") {
67
+ exec(`start "" "${url}"`);
68
+ } else {
69
+ exec(`xdg-open "${url}"`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Run interactive OAuth loopback flow
75
+ */
76
+ export function runLogin({ webBaseUrl = "https://vibeinfra.id", port = 49152 } = {}) {
77
+ return new Promise((resolve, reject) => {
78
+ const stateNonce = crypto.randomBytes(16).toString("hex");
79
+
80
+ const server = http.createServer((req, res) => {
81
+ const reqUrl = new URL(req.url, `http://127.0.0.1:${port}`);
82
+
83
+ if (reqUrl.pathname === "/callback") {
84
+ const token = reqUrl.searchParams.get("token");
85
+ const state = reqUrl.searchParams.get("state");
86
+
87
+ if (!token || state !== stateNonce) {
88
+ res.writeHead(400, { "Content-Type": "text/html" });
89
+ res.end("<h3>Invalid authentication request or state mismatch.</h3>");
90
+ server.close();
91
+ return reject(new Error("State nonce mismatch or missing token"));
92
+ }
93
+
94
+ saveApiKey(token);
95
+
96
+ res.writeHead(200, { "Content-Type": "text/html" });
97
+ res.end(`
98
+ <!DOCTYPE html>
99
+ <html>
100
+ <head><title>VibeInfra CLI Authorized</title></head>
101
+ <body style="font-family: system-ui, -apple-system, sans-serif; background: #09090b; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;">
102
+ <div style="text-align: center; max-width: 440px; padding: 2rem; border-radius: 1rem; background: #18181b; border: 1px solid #27272a;">
103
+ <h2 style="color: #34d399; margin-top: 0;">✓ Successfully Authorized!</h2>
104
+ <p style="color: #a1a1aa; font-size: 0.95rem; line-height: 1.5;">
105
+ Your Personal Access Token has been saved securely to <code style="color: #e4e4e7; background: #27272a; padding: 2px 6px; border-radius: 4px;">~/.vibeinfra/credentials.json</code>.
106
+ </p>
107
+ <p style="color: #71717a; font-size: 0.85rem; margin-top: 1.5rem;">You can close this tab and return to your terminal.</p>
108
+ </div>
109
+ </body>
110
+ </html>
111
+ `);
112
+
113
+ server.close();
114
+ console.error("\n✅ Successfully authenticated with VibeInfra!");
115
+ console.error(`Token securely saved to ${CREDENTIALS_FILE} (mode 0600).\n`);
116
+ return resolve();
117
+ }
118
+
119
+ res.writeHead(404);
120
+ res.end("Not found");
121
+ });
122
+
123
+ server.on("error", (err) => {
124
+ reject(err);
125
+ });
126
+
127
+ server.listen(port, "127.0.0.1", () => {
128
+ const authUrl = `${webBaseUrl.replace(/\/+$/, "")}/auth/cli?port=${port}&state=${stateNonce}`;
129
+ console.error("\n🚀 Opening browser to authenticate with VibeInfra...");
130
+ console.error(`If the browser does not open automatically, visit:\n${authUrl}\n`);
131
+ openBrowser(authUrl);
132
+ });
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Handle logout CLI command
138
+ */
139
+ export function runLogout() {
140
+ clearApiKey();
141
+ console.error("\n👋 Successfully logged out. Removed credentials from ~/.vibeinfra/credentials.json\n");
142
+ }
package/src/server.js CHANGED
@@ -10,27 +10,30 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { VibeInfraApi } from "./api.js";
11
11
  import { tools } from "./tools.js";
12
12
 
13
- export const SERVER_NAME = "id.vibeinfra/catalog";
14
- export const SERVER_VERSION = "0.1.0";
13
+ export const SERVER_NAME = "id.vibeinfra/platform";
14
+ export const SERVER_VERSION = "0.2.0";
15
15
 
16
16
  /**
17
17
  * @param {object} [options]
18
18
  * @param {string} [options.baseUrl] API origin. Defaults to production.
19
+ * @param {string} [options.apiKey] API key / Bearer token. Defaults to VIBEINFRA_API_KEY env.
19
20
  * @param {VibeInfraApi} [options.api] Pre-built API client, for tests.
20
21
  */
21
22
  export function createServer(options = {}) {
22
- const api = options.api ?? new VibeInfraApi({ baseUrl: options.baseUrl });
23
+ const apiKey = options.apiKey ?? process.env.VIBEINFRA_API_KEY;
24
+ const api = options.api ?? new VibeInfraApi({ baseUrl: options.baseUrl, apiKey });
23
25
 
24
26
  const server = new McpServer(
25
- { name: SERVER_NAME, title: "VibeInfra Catalog", version: SERVER_VERSION },
27
+ { name: SERVER_NAME, title: "VibeInfra Platform", version: SERVER_VERSION },
26
28
  {
27
29
  instructions:
28
- "VibeInfra is a learn-by-doing infrastructure engineering platform: engineers " +
30
+ "VibeInfra is an incident-readiness platform for infrastructure engineers: engineers " +
29
31
  "troubleshoot realistic production incidents (Linux kernel, Docker, Kubernetes, Nginx, " +
30
- "PostgreSQL, Kafka, AWS) in live disposable sandboxes with automated grading. Use these " +
31
- "tools to find an incident that matches what someone wants to learn, to read its " +
32
- "blueprint, and to check whether the platform has capacity right now. Every tool is " +
33
- "read-only and needs no credentials.",
32
+ "PostgreSQL, Kafka, AWS) in live disposable sandboxes with automated grading. " +
33
+ "Use these tools to discover incidents, start live incident drills (start_drill), " +
34
+ "coach learners with progressive hints without spoilers (get_drill_hint), " +
35
+ "inspect live telemetry/topology (get_drill_status), and grade fixes against the live " +
36
+ "system state (grade_drill).",
34
37
  },
35
38
  );
36
39
 
package/src/tools.js CHANGED
@@ -185,6 +185,174 @@ export const tools = [
185
185
  return jsonContent({ term, count: matches.length, entries: matches });
186
186
  },
187
187
  },
188
+ {
189
+ name: "start_drill",
190
+ config: {
191
+ title: "Start an SRE incident drill",
192
+ description:
193
+ "Provision and launch a live disposable sandbox for an SRE incident simulation. " +
194
+ "Returns the active session_id, web terminal URL, objectives, and scenario briefing. " +
195
+ "When finished, evaluate your fix with grade_drill.",
196
+ inputSchema: {
197
+ course_id: z
198
+ .string()
199
+ .describe("Catalog ID of the incident simulation, e.g. 'incident-40-deployment-never-rolled-out'."),
200
+ force_fresh: z
201
+ .boolean()
202
+ .optional()
203
+ .describe("If true, terminates any existing sandbox and boots a fresh container."),
204
+ },
205
+ annotations: { readOnlyHint: false, openWorldHint: true },
206
+ },
207
+ async handler(api, { course_id: courseId, force_fresh: forceFresh } = {}) {
208
+ const result = await api.startLab(courseId, forceFresh);
209
+ let blueprint = null;
210
+ try {
211
+ blueprint = await api.getCourse(courseId);
212
+ } catch {
213
+ // Blueprint fetch is best-effort enrichment
214
+ }
215
+
216
+ return jsonContent({
217
+ status: result.status ?? "success",
218
+ session_id: result.session_id || result.SessionID,
219
+ course_id: courseId,
220
+ title: blueprint?.title ?? courseId,
221
+ difficulty: blueprint?.difficulty,
222
+ tasks: blueprint?.tasks ?? [],
223
+ web_terminal_url: `https://vibeinfra.id/labs/overview/?course=${encodeURIComponent(courseId)}&session=${encodeURIComponent(result.session_id || result.SessionID || "")}`,
224
+ message: result.message ?? "Ephemeral incident sandbox provisioned successfully.",
225
+ });
226
+ },
227
+ },
228
+ {
229
+ name: "get_drill_hint",
230
+ config: {
231
+ title: "Get progressive hint for active drill",
232
+ description:
233
+ "Fetch spoiler-free progressive guidance for an active incident session. " +
234
+ "Provides diagnostic nudges without revealing the direct solution.",
235
+ inputSchema: {
236
+ session_id: z.string().describe("Active session ID returned by start_drill."),
237
+ },
238
+ annotations: { readOnlyHint: true, openWorldHint: true },
239
+ },
240
+ async handler(api, { session_id: sessionId } = {}) {
241
+ const hintResult = await api.getHint(sessionId);
242
+ return jsonContent(hintResult);
243
+ },
244
+ },
245
+ {
246
+ name: "grade_drill",
247
+ config: {
248
+ title: "Grade and evaluate an incident drill fix",
249
+ description:
250
+ "Run automated system-state evaluation against the live sandbox to verify if the " +
251
+ "incident has been resolved. Inspects kernel sockets, container states, and ingress health. " +
252
+ "Returns pass/fail status, completed steps, and earned XP.",
253
+ inputSchema: {
254
+ session_id: z.string().describe("Active session ID returned by start_drill."),
255
+ course_id: z.string().describe("Catalog ID of the incident, e.g. 'incident-40-deployment-never-rolled-out'."),
256
+ },
257
+ annotations: { readOnlyHint: false, openWorldHint: true },
258
+ },
259
+ async handler(api, { session_id: sessionId, course_id: courseId } = {}) {
260
+ const evalResult = await api.checkLab(sessionId, courseId);
261
+ return jsonContent({
262
+ passed: evalResult.passed ?? evalResult.Passed ?? false,
263
+ status: evalResult.status ?? (evalResult.passed ? "passed" : "failed"),
264
+ message: evalResult.message ?? evalResult.Message ?? "",
265
+ steps_completed: evalResult.steps_completed ?? evalResult.StepsCompleted ?? 0,
266
+ steps_total: evalResult.steps_total ?? evalResult.StepsTotal ?? 0,
267
+ score_xp: evalResult.score_xp ?? evalResult.ScoreXP ?? 0,
268
+ completion_time_sec: evalResult.completion_time_sec ?? evalResult.CompletionTimeSec ?? 0,
269
+ });
270
+ },
271
+ },
272
+ {
273
+ name: "get_drill_status",
274
+ config: {
275
+ title: "Get live drill telemetry and topology status",
276
+ description:
277
+ "Inspect real-time telemetry, node health statuses, and network topology for an active drill session.",
278
+ inputSchema: {
279
+ session_id: z.string().describe("Active session ID returned by start_drill."),
280
+ },
281
+ annotations: { readOnlyHint: true, openWorldHint: true },
282
+ },
283
+ async handler(api, { session_id: sessionId } = {}) {
284
+ let telemetry = null;
285
+ let topology = null;
286
+ try {
287
+ telemetry = await api.getLabTelemetry(sessionId);
288
+ } catch {
289
+ // Best effort
290
+ }
291
+ try {
292
+ topology = await api.getLabTopology(sessionId);
293
+ } catch {
294
+ // Best effort
295
+ }
296
+ return jsonContent({
297
+ session_id: sessionId,
298
+ telemetry,
299
+ topology,
300
+ });
301
+ },
302
+ },
303
+ {
304
+ name: "stop_drill",
305
+ config: {
306
+ title: "Stop and destroy an incident sandbox",
307
+ description:
308
+ "Permanently terminates and tears down the disposable sandbox container for a completed or aborted session.",
309
+ inputSchema: {
310
+ session_id: z.string().describe("Active session ID to terminate."),
311
+ },
312
+ annotations: { readOnlyHint: false, openWorldHint: true },
313
+ },
314
+ async handler(api, { session_id: sessionId } = {}) {
315
+ const destroyResult = await api.destroyLab(sessionId);
316
+ return jsonContent({
317
+ session_id: sessionId,
318
+ status: destroyResult?.status ?? "destroyed",
319
+ message: destroyResult?.message ?? "Sandbox destroyed successfully.",
320
+ });
321
+ },
322
+ },
323
+ {
324
+ name: "exec_in_sandbox",
325
+ config: {
326
+ title: "Execute shell command inside incident sandbox",
327
+ description:
328
+ "Run an arbitrary diagnostic, triage, or remediation command directly inside the live container sandbox. " +
329
+ "Returns stdout, stderr, and exit_code. Enables headless incident troubleshooting without opening a browser.",
330
+ inputSchema: {
331
+ session_id: z.string().describe("Active session ID returned by start_drill."),
332
+ command: z
333
+ .string()
334
+ .describe("Shell command to execute, e.g. 'kubectl get pods -A', 'df -h', 'systemctl status nginx'."),
335
+ timeout_sec: z
336
+ .number()
337
+ .optional()
338
+ .describe("Command execution timeout in seconds (default 15, max 30)."),
339
+ },
340
+ annotations: { readOnlyHint: false, openWorldHint: true },
341
+ },
342
+ async handler(api, { session_id: sessionId, command, timeout_sec: timeoutSec } = {}) {
343
+ const result = await api.execInSandbox(sessionId, command, timeoutSec);
344
+ return jsonContent({
345
+ session_id: sessionId,
346
+ command,
347
+ exit_code: result.exit_code ?? 0,
348
+ stdout: result.stdout ?? "",
349
+ stderr: result.stderr ?? "",
350
+ status: result.status ?? (result.exit_code === 0 ? "success" : "failed"),
351
+ error: result.error,
352
+ });
353
+ },
354
+ },
188
355
  ];
189
356
 
190
357
  export { filterCourses, summarise };
358
+