@andinolabs/nebula-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +134 -0
  2. package/dist/index.js +515 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # nebula-mcp
2
+
3
+ MCP server for the **Nebula Control Plane API**. Install via npm — no clone or local build required.
4
+
5
+ ## Quick start
6
+
7
+ Add one entry to your MCP config (Claude Desktop, Claude Code `.mcp.json`, or Cursor):
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "nebula": {
13
+ "command": "npx",
14
+ "args": ["-y", "@andinolabs/nebula-mcp"],
15
+ "env": {
16
+ "NEBULA_API_URL": "https://admin.nebula.andinolabs.ai"
17
+ }
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ `npx -y` fetches and caches the package on first use.
24
+
25
+ Or via Claude Code CLI:
26
+
27
+ ```bash
28
+ claude mcp add nebula -- npx -y @andinolabs/nebula-mcp
29
+ ```
30
+
31
+ Set `NEBULA_API_URL` in the environment when adding if your control plane URL differs.
32
+
33
+ ## Environment variables
34
+
35
+ | Var | Default | Description |
36
+ |---|---|---|
37
+ | `NEBULA_API_URL` | `http://localhost:8080` | Nebula Control Plane base URL |
38
+ | `NEBULA_API_TOKEN` | _(empty)_ | Bearer token for headless/CI (optional) |
39
+
40
+ ## Authentication
41
+
42
+ ### Interactive login (default)
43
+
44
+ When `NEBULA_API_TOKEN` is not set, the server opens your browser to complete an
45
+ OAuth 2.0 + PKCE login on the first request. Tokens are persisted to
46
+ `~/.config/nebula/tokens.json` and refreshed automatically. The browser only
47
+ re-opens when the refresh token expires (~30–90 days).
48
+
49
+ ### Headless / CI
50
+
51
+ Set `NEBULA_API_TOKEN` to a long-lived API key. This bypasses the browser flow
52
+ entirely and no token file is read or written.
53
+
54
+ ## Tools exposed
55
+
56
+ | Tool | Domain |
57
+ |---|---|
58
+ | `login` | Auth |
59
+ | `health_check` | Health |
60
+ | `list_apps` / `get_app` | Platform apps |
61
+ | `list_clusters` / `get_cluster` | Platform clusters |
62
+ | `list_tenants` / `get_tenant` / `create_tenant` | Catalog – Tenants |
63
+ | `get_tenant_github_status` / `list_tenant_github_repos` | Catalog – GitHub |
64
+ | `get_tenant_jira_status` / `list_tenant_jira_projects` | Catalog – Jira |
65
+ | `list_environments` / `get_environment` | Catalog – Environments |
66
+ | `list_applications` / `get_application` | Catalog – Applications |
67
+ | `list_application_jira_issues` | Catalog – Jira Issues |
68
+ | `list_cd_clusters` / `get_cd_cluster` | CD – Clusters |
69
+ | `list_cd_pipelines` / `get_cd_pipeline` | CD – Pipelines |
70
+ | `list_cloud_providers` | Cloud – Providers |
71
+ | `list_cloud_resources` / `provision_cloud_resource` / `preview_terraform` | Cloud – Resources |
72
+ | `list_capture_topics` / `get_capture_topic` | Capture – Topics |
73
+ | `list_capture_sessions` / `get_capture_session_summary` | Capture – Sessions |
74
+
75
+ ## Development
76
+
77
+ For contributors working from this repository:
78
+
79
+ ```bash
80
+ pnpm install
81
+ pnpm run build
82
+ ```
83
+
84
+ Register a local build in MCP config:
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "nebula": {
90
+ "command": "node",
91
+ "args": ["/absolute/path/to/claude-mcp/dist/index.js"],
92
+ "env": {
93
+ "NEBULA_API_URL": "http://localhost:8080"
94
+ }
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ Hot-reload during development:
101
+
102
+ ```bash
103
+ NEBULA_API_URL=http://localhost:8080 pnpm run dev
104
+ ```
105
+
106
+ ## Publishing
107
+
108
+ Published to the public npm registry under **@andinolabs** (`@andinolabs/nebula-mcp`).
109
+
110
+ Publishing uses an **npm access token** only — no interactive login or 2FA OTP at publish time.
111
+
112
+ ### Create a publish token (one-time)
113
+
114
+ 1. Join the [andinolabs](https://www.npmjs.com/org/andinolabs) org on npm with publish permission.
115
+ 2. Open [Access Tokens](https://www.npmjs.com/settings/~/tokens) → **Generate New Token** → **Granular Access Token**.
116
+ 3. Configure:
117
+ - **Organizations:** `andinolabs` — **Read and write**
118
+ - **Packages:** `@andinolabs/nebula-mcp` (or all org packages)
119
+ - **Expiration:** per your security policy
120
+ - **Bypass 2FA for automation:** enabled (required for non-interactive publish)
121
+ 4. Copy the token (`npm_…`) — it is shown only once.
122
+
123
+ Store the token in your password manager or CI secret `NPM_TOKEN`. Never commit it.
124
+
125
+ ### Publish locally
126
+
127
+ ```bash
128
+ export NPM_TOKEN=npm_xxxxxxxx
129
+ just publish
130
+ ```
131
+
132
+ ### CI
133
+
134
+ Set the same granular token as the `NPM_TOKEN` repository/organization secret; the publish recipe writes a temporary `.npmrc` and removes it on exit.
package/dist/index.js ADDED
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { createServer } from "node:http";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { execFile } from "node:child_process";
11
+ // ---------------------------------------------------------------------------
12
+ // Config
13
+ // ---------------------------------------------------------------------------
14
+ const BASE_URL = process.env.NEBULA_API_URL ?? "http://localhost:8080";
15
+ const API_TOKEN = process.env.NEBULA_API_TOKEN ?? "";
16
+ const TOKEN_FILE = join(homedir(), ".config", "nebula", "tokens.json");
17
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
18
+ let cachedTokens = null;
19
+ // ---------------------------------------------------------------------------
20
+ // PKCE helpers
21
+ // ---------------------------------------------------------------------------
22
+ function generateCodeVerifier() {
23
+ return randomBytes(32).toString("base64url");
24
+ }
25
+ function codeChallenge(verifier) {
26
+ return createHash("sha256").update(verifier).digest("base64url");
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // Token persistence
30
+ // ---------------------------------------------------------------------------
31
+ async function loadTokens() {
32
+ try {
33
+ const raw = await readFile(TOKEN_FILE, "utf-8");
34
+ return JSON.parse(raw);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ async function saveTokens(tokens) {
41
+ await mkdir(join(homedir(), ".config", "nebula"), { recursive: true });
42
+ await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2), "utf-8");
43
+ cachedTokens = tokens;
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Token refresh
47
+ // ---------------------------------------------------------------------------
48
+ async function refreshAccessToken(refreshToken) {
49
+ const res = await fetch(`${BASE_URL}/v1/auth/oauth/token`, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken }),
53
+ });
54
+ if (!res.ok)
55
+ throw new Error(`Refresh failed: ${res.status}`);
56
+ const data = await res.json();
57
+ const tokens = {
58
+ access_token: data.access_token,
59
+ refresh_token: data.refresh_token ?? refreshToken,
60
+ expires_at: data.expires_in
61
+ ? new Date(Date.now() + data.expires_in * 1000).toISOString()
62
+ : undefined,
63
+ };
64
+ await saveTokens(tokens);
65
+ return tokens;
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Browser login (OAuth 2.0 + PKCE)
69
+ // ---------------------------------------------------------------------------
70
+ async function openBrowser(url) {
71
+ const [cmd, ...args] = process.platform === "darwin" ? ["open", url] :
72
+ process.platform === "win32" ? ["cmd", "/c", "start", "", url] :
73
+ ["xdg-open", url];
74
+ try {
75
+ await new Promise((resolve, reject) => execFile(cmd, args, (err) => (err ? reject(err) : resolve())));
76
+ }
77
+ catch {
78
+ // Non-fatal: URL is printed to stderr for manual visit
79
+ }
80
+ }
81
+ function escapeHtml(text) {
82
+ return text
83
+ .replace(/&/g, "&")
84
+ .replace(/</g, "&lt;")
85
+ .replace(/>/g, "&gt;")
86
+ .replace(/"/g, "&quot;");
87
+ }
88
+ /** Loopback OAuth callback page styled like the Control Plane login screen. */
89
+ function oauthCallbackHtml(message, isError) {
90
+ const safeMessage = escapeHtml(message);
91
+ const messageColor = isError ? "#fecaca" : "#cbd5e1";
92
+ return `<!DOCTYPE html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="UTF-8" />
96
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
97
+ <title>Nebula Control Plane</title>
98
+ <style>
99
+ * { box-sizing: border-box; margin: 0; padding: 0; }
100
+ body {
101
+ min-height: 100vh;
102
+ font-family: "Familjen Grotesk", system-ui, -apple-system, sans-serif;
103
+ background: #020617;
104
+ color: #e2e8f0;
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ padding: 1.5rem;
109
+ }
110
+ .cosmos {
111
+ position: fixed;
112
+ inset: 0;
113
+ pointer-events: none;
114
+ background:
115
+ radial-gradient(ellipse 60% 50% at 80% 20%, rgba(99, 102, 241, 0.12), transparent 60%),
116
+ radial-gradient(ellipse 50% 40% at 15% 85%, rgba(59, 130, 246, 0.08), transparent 60%);
117
+ }
118
+ .card {
119
+ position: relative;
120
+ z-index: 1;
121
+ width: 100%;
122
+ max-width: 28rem;
123
+ padding: 2rem;
124
+ border-radius: 1rem;
125
+ border: 1px solid rgba(51, 65, 85, 0.8);
126
+ background: rgba(2, 6, 23, 0.8);
127
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
128
+ text-align: center;
129
+ }
130
+ h1 {
131
+ font-size: 1.25rem;
132
+ font-weight: 600;
133
+ letter-spacing: 0.025em;
134
+ color: #f1f5f9;
135
+ margin-bottom: 1rem;
136
+ }
137
+ p {
138
+ font-size: 0.875rem;
139
+ line-height: 1.625;
140
+ color: ${messageColor};
141
+ }
142
+ </style>
143
+ </head>
144
+ <body>
145
+ <div class="cosmos" aria-hidden="true"></div>
146
+ <div class="card">
147
+ <h1>Nebula Control Plane</h1>
148
+ <p role="${isError ? "alert" : "status"}">${safeMessage}</p>
149
+ </div>
150
+ </body>
151
+ </html>`;
152
+ }
153
+ async function browserLogin() {
154
+ const verifier = generateCodeVerifier();
155
+ const challenge = codeChallenge(verifier);
156
+ const state = randomBytes(16).toString("base64url");
157
+ // Start a local callback server and capture its port before building the auth URL
158
+ const { port, waitForCode } = await new Promise((resolveSetup) => {
159
+ let resolveCode;
160
+ let rejectCode;
161
+ const codePromise = new Promise((res, rej) => {
162
+ resolveCode = res;
163
+ rejectCode = rej;
164
+ });
165
+ const server = createServer((req, httpRes) => {
166
+ const parsed = new URL(req.url ?? "/", "http://localhost");
167
+ const code = parsed.searchParams.get("code");
168
+ const returnedState = parsed.searchParams.get("state");
169
+ const error = parsed.searchParams.get("error");
170
+ httpRes.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
171
+ if (code) {
172
+ if (returnedState !== state) {
173
+ httpRes.end(oauthCallbackHtml("Authentication failed: state mismatch.", true));
174
+ server.close();
175
+ rejectCode(new Error("OAuth state mismatch"));
176
+ return;
177
+ }
178
+ httpRes.end(oauthCallbackHtml("Authentication successful. You can close this tab.", false));
179
+ server.close();
180
+ resolveCode(code);
181
+ }
182
+ else {
183
+ httpRes.end(oauthCallbackHtml(`Authentication failed: ${error ?? "unknown"}.`, true));
184
+ server.close();
185
+ rejectCode(new Error(`OAuth error: ${error ?? "no code returned"}`));
186
+ }
187
+ });
188
+ server.listen(0, "localhost", () => {
189
+ const { port } = server.address();
190
+ resolveSetup({
191
+ port,
192
+ waitForCode: () => {
193
+ const timeout = setTimeout(() => {
194
+ server.close();
195
+ rejectCode(new Error("Login timed out after 5 minutes"));
196
+ }, LOGIN_TIMEOUT_MS);
197
+ return codePromise.finally(() => clearTimeout(timeout));
198
+ },
199
+ });
200
+ });
201
+ });
202
+ const redirectUri = `http://localhost:${port}/callback`;
203
+ const authUrl = new URL(`${BASE_URL}/login`);
204
+ authUrl.searchParams.set("redirect_uri", redirectUri);
205
+ authUrl.searchParams.set("code_challenge", challenge);
206
+ authUrl.searchParams.set("code_challenge_method", "S256");
207
+ authUrl.searchParams.set("state", state);
208
+ console.error(`\nOpening browser for Nebula authentication...`);
209
+ console.error(`If the browser does not open, visit:\n ${authUrl.toString()}\n`);
210
+ await openBrowser(authUrl.toString());
211
+ const code = await waitForCode();
212
+ const res = await fetch(`${BASE_URL}/v1/auth/oauth/token`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({
216
+ grant_type: "authorization_code",
217
+ code,
218
+ redirect_uri: redirectUri,
219
+ code_verifier: verifier,
220
+ }),
221
+ });
222
+ if (!res.ok)
223
+ throw new Error(`Token exchange failed: ${res.status}`);
224
+ const data = await res.json();
225
+ const tokens = {
226
+ access_token: data.access_token,
227
+ refresh_token: data.refresh_token,
228
+ expires_at: data.expires_in
229
+ ? new Date(Date.now() + data.expires_in * 1000).toISOString()
230
+ : undefined,
231
+ };
232
+ await saveTokens(tokens);
233
+ console.error("Authentication successful.\n");
234
+ return tokens;
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Token resolution
238
+ // ---------------------------------------------------------------------------
239
+ async function getAccessToken() {
240
+ // Static env token (CI / headless) — bypass file-based auth entirely
241
+ if (API_TOKEN)
242
+ return API_TOKEN;
243
+ if (!cachedTokens)
244
+ cachedTokens = await loadTokens();
245
+ // Return cached token if it's still valid (with 60s buffer before expiry)
246
+ if (cachedTokens?.access_token) {
247
+ if (!cachedTokens.expires_at)
248
+ return cachedTokens.access_token;
249
+ const expiresAt = new Date(cachedTokens.expires_at).getTime();
250
+ if (Date.now() < expiresAt - 60_000)
251
+ return cachedTokens.access_token;
252
+ }
253
+ // Try refresh before falling back to browser
254
+ if (cachedTokens?.refresh_token) {
255
+ try {
256
+ const refreshed = await refreshAccessToken(cachedTokens.refresh_token);
257
+ return refreshed.access_token;
258
+ }
259
+ catch {
260
+ console.error("Token refresh failed, opening browser for re-authentication...");
261
+ }
262
+ }
263
+ const tokens = await browserLogin();
264
+ return tokens.access_token;
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // HTTP helper
268
+ // ---------------------------------------------------------------------------
269
+ async function call(method, path, body) {
270
+ const makeRequest = async (token) => {
271
+ const headers = {
272
+ "Content-Type": "application/json",
273
+ Accept: "application/json",
274
+ };
275
+ if (token)
276
+ headers["Authorization"] = `Bearer ${token}`;
277
+ return fetch(`${BASE_URL}${path}`, {
278
+ method,
279
+ headers,
280
+ body: body !== undefined ? JSON.stringify(body) : undefined,
281
+ });
282
+ };
283
+ const isHtmlResponse = (text) => {
284
+ const t = text.trimStart();
285
+ return t.startsWith("<!DOCTYPE") || t.startsWith("<html");
286
+ };
287
+ let res = await makeRequest(await getAccessToken());
288
+ // On 401, force browser re-auth (skip refresh — server already rejected the token) and retry once
289
+ if (res.status === 401 && !API_TOKEN) {
290
+ console.error("Session expired, re-authenticating...");
291
+ cachedTokens = null;
292
+ const tokens = await browserLogin();
293
+ res = await makeRequest(tokens.access_token);
294
+ }
295
+ const text = await res.text();
296
+ // The API returns the SPA's index.html (200) instead of 401 when the session expires.
297
+ // Treat HTML responses as auth failures and retry once with a fresh browser login.
298
+ if (isHtmlResponse(text) && !API_TOKEN) {
299
+ console.error("Received HTML response (likely auth redirect), re-authenticating...");
300
+ cachedTokens = null;
301
+ const tokens = await browserLogin();
302
+ const retryRes = await makeRequest(tokens.access_token);
303
+ const retryText = await retryRes.text();
304
+ if (isHtmlResponse(retryText)) {
305
+ throw new Error(`Nebula API ${method} ${path} → ${retryRes.status}: received HTML after re-auth (endpoint may not exist)`);
306
+ }
307
+ try {
308
+ return JSON.parse(retryText);
309
+ }
310
+ catch {
311
+ return retryText;
312
+ }
313
+ }
314
+ let data;
315
+ try {
316
+ data = JSON.parse(text);
317
+ }
318
+ catch {
319
+ data = text;
320
+ }
321
+ if (!res.ok) {
322
+ throw new Error(`Nebula API ${method} ${path} → ${res.status}: ${JSON.stringify(data)}`);
323
+ }
324
+ return data;
325
+ }
326
+ // ---------------------------------------------------------------------------
327
+ // Server
328
+ // ---------------------------------------------------------------------------
329
+ const server = new McpServer({
330
+ name: "nebula",
331
+ version: "1.0.0",
332
+ description: "MCP server for the Nebula Control Plane API",
333
+ });
334
+ // ── Auth ─────────────────────────────────────────────────────────────────────
335
+ server.tool("login", "Authenticate with Nebula via browser-based OAuth login. Call this when you get a 401 error.", {}, async () => {
336
+ cachedTokens = null;
337
+ await browserLogin();
338
+ return { content: [{ type: "text", text: "Authentication successful. You can now call other Nebula tools." }] };
339
+ });
340
+ // ── Health ──────────────────────────────────────────────────────────────────
341
+ server.tool("health_check", "Check if the Nebula API is alive", {}, async () => {
342
+ const data = await call("GET", "/healthz");
343
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
344
+ });
345
+ // ── Platform / Apps ─────────────────────────────────────────────────────────
346
+ server.tool("list_apps", "List all platform applications (ArgoCD/platform layer)", {}, async () => {
347
+ const data = await call("GET", "/v1/apps");
348
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
349
+ });
350
+ server.tool("get_app", "Get a platform application by name", { name: z.string().describe("Application name") }, async ({ name }) => {
351
+ const data = await call("GET", `/v1/apps/${encodeURIComponent(name)}`);
352
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
353
+ });
354
+ // ── Platform / Clusters ──────────────────────────────────────────────────────
355
+ server.tool("list_clusters", "List all platform clusters (hub + spokes)", {}, async () => {
356
+ const data = await call("GET", "/v1/clusters");
357
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
358
+ });
359
+ server.tool("get_cluster", "Get a platform cluster by name", { name: z.string().describe("Cluster name") }, async ({ name }) => {
360
+ const data = await call("GET", `/v1/clusters/${encodeURIComponent(name)}`);
361
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
362
+ });
363
+ // ── Catalog / Tenants ────────────────────────────────────────────────────────
364
+ server.tool("list_tenants", "List all Nebula tenants (Miinsys, Serhafen, Busko…)", {}, async () => {
365
+ const data = await call("GET", "/v1/catalog/tenants");
366
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
367
+ });
368
+ server.tool("get_tenant", "Get a tenant by UUID", { id: z.string().uuid().describe("Tenant UUID") }, async ({ id }) => {
369
+ const data = await call("GET", `/v1/catalog/tenants/${id}`);
370
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
371
+ });
372
+ server.tool("create_tenant", "Create a new tenant in the Nebula catalog", {
373
+ name: z.string(),
374
+ slug: z.string().optional(),
375
+ cloud_provider: z.string().optional(),
376
+ }, async (body) => {
377
+ const data = await call("POST", "/v1/catalog/tenants", body);
378
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
379
+ });
380
+ server.tool("get_tenant_github_status", "Check if a tenant has GitHub tokens configured", { id: z.string().uuid().describe("Tenant UUID") }, async ({ id }) => {
381
+ const data = await call("GET", `/v1/catalog/tenants/${id}/github`);
382
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
383
+ });
384
+ server.tool("list_tenant_github_repos", "List GitHub repositories accessible to a tenant's token", { id: z.string().uuid().describe("Tenant UUID") }, async ({ id }) => {
385
+ const data = await call("GET", `/v1/catalog/tenants/${id}/github/repositories`);
386
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
387
+ });
388
+ server.tool("get_tenant_jira_status", "Check if a tenant has Jira credentials configured", { id: z.string().uuid().describe("Tenant UUID") }, async ({ id }) => {
389
+ const data = await call("GET", `/v1/catalog/tenants/${id}/jira`);
390
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
391
+ });
392
+ server.tool("list_tenant_jira_projects", "List Jira projects accessible to a tenant", { id: z.string().uuid().describe("Tenant UUID") }, async ({ id }) => {
393
+ const data = await call("GET", `/v1/catalog/tenants/${id}/jira/projects`);
394
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
395
+ });
396
+ // ── Catalog / Environments ───────────────────────────────────────────────────
397
+ server.tool("list_environments", "List environments for a tenant", { tenantId: z.string().uuid().describe("Tenant UUID") }, async ({ tenantId }) => {
398
+ const data = await call("GET", `/v1/catalog/tenants/${tenantId}/environments`);
399
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
400
+ });
401
+ server.tool("get_environment", "Get a specific environment", {
402
+ tenantId: z.string().uuid(),
403
+ id: z.string().uuid().describe("Environment UUID"),
404
+ }, async ({ tenantId, id }) => {
405
+ const data = await call("GET", `/v1/catalog/tenants/${tenantId}/environments/${id}`);
406
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
407
+ });
408
+ // ── Catalog / Applications ───────────────────────────────────────────────────
409
+ server.tool("list_applications", "List catalog applications (optionally filter by tenant)", {
410
+ tenant_id: z.string().uuid().optional().describe("Filter by tenant UUID"),
411
+ include_shared: z.boolean().optional().default(false),
412
+ include_archived: z.boolean().optional().default(false),
413
+ }, async ({ tenant_id, include_shared, include_archived }) => {
414
+ const params = new URLSearchParams();
415
+ if (tenant_id)
416
+ params.set("tenant_id", tenant_id);
417
+ if (include_shared)
418
+ params.set("include_shared", "true");
419
+ if (include_archived)
420
+ params.set("include_archived", "true");
421
+ const qs = params.toString();
422
+ const data = await call("GET", `/v1/catalog/applications${qs ? `?${qs}` : ""}`);
423
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
424
+ });
425
+ server.tool("get_application", "Get a catalog application by UUID", { id: z.string().uuid() }, async ({ id }) => {
426
+ const data = await call("GET", `/v1/catalog/applications/${id}`);
427
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
428
+ });
429
+ server.tool("list_application_jira_issues", "List Jira issues bound to a catalog application", {
430
+ id: z.string().uuid().describe("Application UUID"),
431
+ q: z.string().optional().describe("Search query"),
432
+ }, async ({ id, q }) => {
433
+ const qs = q ? `?q=${encodeURIComponent(q)}` : "";
434
+ const data = await call("GET", `/v1/catalog/applications/${id}/jira/issues${qs}`);
435
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
436
+ });
437
+ // ── CD / Clusters ────────────────────────────────────────────────────────────
438
+ server.tool("list_cd_clusters", "List CD clusters for a tenant (ArgoCD destinations)", { tenantId: z.string().uuid() }, async ({ tenantId }) => {
439
+ const data = await call("GET", `/v1/cd/tenants/${tenantId}/clusters`);
440
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
441
+ });
442
+ server.tool("get_cd_cluster", "Get a CD cluster by id", { tenantId: z.string().uuid(), id: z.string().uuid() }, async ({ tenantId, id }) => {
443
+ const data = await call("GET", `/v1/cd/tenants/${tenantId}/clusters/${id}`);
444
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
445
+ });
446
+ // ── CD / Pipelines ───────────────────────────────────────────────────────────
447
+ server.tool("list_cd_pipelines", "List CD pipelines for a tenant", { tenantId: z.string().uuid() }, async ({ tenantId }) => {
448
+ const data = await call("GET", `/v1/cd/tenants/${tenantId}/pipelines`);
449
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
450
+ });
451
+ server.tool("get_cd_pipeline", "Get a CD pipeline by id", { tenantId: z.string().uuid(), id: z.string().uuid() }, async ({ tenantId, id }) => {
452
+ const data = await call("GET", `/v1/cd/tenants/${tenantId}/pipelines/${id}`);
453
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
454
+ });
455
+ // ── Cloud / Providers ────────────────────────────────────────────────────────
456
+ server.tool("list_cloud_providers", "List cloud provider connections for a tenant (Azure, GCP, AWS)", { id: z.string().uuid().describe("Tenant UUID") }, async ({ id }) => {
457
+ const data = await call("GET", `/v1/cloud/tenants/${id}/cloud-providers`);
458
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
459
+ });
460
+ // ── Cloud / Resources ────────────────────────────────────────────────────────
461
+ server.tool("list_cloud_resources", "List cloud resources (AKS, GKE, Postgres, ECR…) for a tenant", {
462
+ tenantId: z.string().uuid(),
463
+ provider_id: z.string().uuid().optional(),
464
+ kind: z.string().optional().describe("e.g. container_registry, postgres_database, gcp_gke"),
465
+ }, async ({ tenantId, provider_id, kind }) => {
466
+ const params = new URLSearchParams();
467
+ if (provider_id)
468
+ params.set("provider_id", provider_id);
469
+ if (kind)
470
+ params.set("kind", kind);
471
+ const qs = params.toString();
472
+ const data = await call("GET", `/v1/cloud/tenants/${tenantId}/resources${qs ? `?${qs}` : ""}`);
473
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
474
+ });
475
+ server.tool("provision_cloud_resource", "Trigger Terraform provisioning for pending cloud resources (fires Argo Workflow)", { tenantId: z.string().uuid() }, async ({ tenantId }) => {
476
+ const data = await call("POST", `/v1/cloud/tenants/${tenantId}/resources/terraform-provision`, {});
477
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
478
+ });
479
+ server.tool("preview_terraform", "Preview generated Terraform for all pending cloud resources for a tenant", { tenantId: z.string().uuid() }, async ({ tenantId }) => {
480
+ const data = await call("GET", `/v1/cloud/tenants/${tenantId}/resources/terraform-preview`);
481
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
482
+ });
483
+ // ── Capture / Topics ─────────────────────────────────────────────────────────
484
+ server.tool("list_capture_topics", "List Capture topics for a tenant (Discovery stakeholder interview topics)", { tenantId: z.string().uuid() }, async ({ tenantId }) => {
485
+ const data = await call("GET", `/v1/capture/tenants/${tenantId}/topics`);
486
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
487
+ });
488
+ server.tool("get_capture_topic", "Get a Capture topic and its sessions", { tenantId: z.string().uuid(), id: z.string().uuid() }, async ({ tenantId, id }) => {
489
+ const data = await call("GET", `/v1/capture/tenants/${tenantId}/topics/${id}`);
490
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
491
+ });
492
+ server.tool("list_capture_sessions", "List Capture sessions for a topic", { tenantId: z.string().uuid(), topicId: z.string().uuid() }, async ({ tenantId, topicId }) => {
493
+ const data = await call("GET", `/v1/capture/tenants/${tenantId}/topics/${topicId}/sessions`);
494
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
495
+ });
496
+ server.tool("get_capture_session_summary", "Get the AI-generated summary of a Capture session", {
497
+ tenantId: z.string().uuid(),
498
+ topicId: z.string().uuid(),
499
+ sessionId: z.string().uuid(),
500
+ }, async ({ tenantId, topicId, sessionId }) => {
501
+ const data = await call("GET", `/v1/capture/tenants/${tenantId}/topics/${topicId}/sessions/${sessionId}/summary`);
502
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
503
+ });
504
+ // ---------------------------------------------------------------------------
505
+ // Bootstrap
506
+ // ---------------------------------------------------------------------------
507
+ async function main() {
508
+ const transport = new StdioServerTransport();
509
+ await server.connect(transport);
510
+ console.error("Nebula MCP server running on stdio");
511
+ }
512
+ main().catch((err) => {
513
+ console.error("Fatal:", err);
514
+ process.exit(1);
515
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@andinolabs/nebula-mcp",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "MCP server for the Nebula Control Plane API",
8
+ "type": "module",
9
+ "main": "dist/index.js",
10
+ "bin": { "nebula-mcp": "dist/index.js" },
11
+ "files": ["dist/"],
12
+ "engines": { "node": ">=18" },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/andinolabs-tech/ai-agent-army"
16
+ },
17
+ "license": "MIT",
18
+ "keywords": ["mcp", "nebula", "claude"],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "prepublishOnly": "pnpm run build",
22
+ "dev": "tsx index.ts",
23
+ "start": "node dist/index.js"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.12.0",
27
+ "zod": "^3.23.8"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "tsx": "^4.19.0",
32
+ "typescript": "^5.5.0"
33
+ }
34
+ }