@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.
- package/README.md +134 -0
- package/dist/index.js +515 -0
- 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, "<")
|
|
85
|
+
.replace(/>/g, ">")
|
|
86
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|