@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 +44 -2
- package/package.json +2 -2
- package/src/api.js +79 -2
- package/src/cli.js +28 -1
- package/src/login.js +142 -0
- package/src/server.js +12 -9
- package/src/tools.js +168 -0
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
|
|
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
|
-
>
|
|
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.
|
|
4
|
-
"description": "Model Context Protocol server for VibeInfra —
|
|
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:
|
|
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
|
|
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/
|
|
14
|
-
export const SERVER_VERSION = "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
|
|
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
|
|
27
|
+
{ name: SERVER_NAME, title: "VibeInfra Platform", version: SERVER_VERSION },
|
|
26
28
|
{
|
|
27
29
|
instructions:
|
|
28
|
-
"VibeInfra is
|
|
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.
|
|
31
|
-
"tools to
|
|
32
|
-
"
|
|
33
|
-
"
|
|
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
|
+
|