@velixir/mcp 0.2.0 → 0.3.1

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 +19 -2
  2. package/package.json +1 -1
  3. package/src/index.js +144 -9
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @velixir/mcp
2
2
 
3
- Deploy web apps in **any language** to [velixir](https://velixir.net), the European cloud, straight from your AI agent. `deploy` points at a source directory, packs the code, builds it with no Dockerfile, and returns a live URL plus a claim link the human opens to keep the app on a free, card-free account. `logs` fetches the build and runtime logs so the agent can debug what it shipped. No velixir account is needed.
3
+ Deploy web apps in **any language** to [velixir](https://velixir.net), the European cloud, straight from your AI agent. `deploy` points at a source directory, packs the code, builds it with no Dockerfile, and returns a live URL plus a claim link the human opens to keep the app on a free, card-free account. `logs` fetches the build and runtime logs, and `access_logs` the HTTP requests the app served, so the agent can debug what it shipped. No velixir account is needed.
4
4
 
5
5
  ## Why
6
6
 
@@ -39,7 +39,7 @@ Point the editor's MCP settings at the same command (`npx -y @velixir/mcp`).
39
39
  | --- | --- | --- |
40
40
  | `directory` | yes | Absolute path to the app's source directory. |
41
41
  | `name` | no | Short name for the app; becomes part of the URL. |
42
- | `language` | no | node, python, go, ruby, php, java, elixir, rust, dotnet, deno, fsharp. Auto-detected if omitted. |
42
+ | `language` | no | node, python, go, ruby, php, java, elixir, rust, dotnet, deno, fsharp. Detected from the project files if omitted; pass it when the folder mixes languages. |
43
43
  | `port` | no | Only if the app hard-codes a port instead of reading the `PORT` env var. |
44
44
 
45
45
  It returns the live URL, a claim URL (give this to the user), when the claim window closes, a **deployment ID** (pass it to `logs`), and a status URL to poll.
@@ -57,11 +57,28 @@ Fetch recent build and runtime logs for an app you deployed, so you can debug wi
57
57
 
58
58
  Works for unclaimed (agent-deployed) apps addressed by deployment ID. Once an app is claimed it moves to the owner's account; use the dashboard or the authenticated API for a claimed app's logs.
59
59
 
60
+ ## The `access_logs` tool
61
+
62
+ The HTTP requests an app served, newest first, with a summary of status codes and the slowest request: use it to check the app is getting traffic and to find failing requests.
63
+
64
+ | Input | Required | Description |
65
+ | --- | --- | --- |
66
+ | `deployment_id` | one of these | From the `deploy` response. Works until the user claims the app. No account needed. |
67
+ | `app_id` | one of these | An app the user owns. Needs `VELIXIR_API_KEY`. |
68
+ | `status` | no | `2xx`, `3xx`, `4xx`, `5xx`, `errors`, or a code like `404`. |
69
+ | `path` | no | Only paths containing this text. |
70
+ | `method` | no | HTTP method, e.g. `POST`. |
71
+ | `since_minutes` | no | Only the last N minutes. |
72
+ | `limit` | no | How many requests (default 50, max 200). |
73
+
74
+ Once an app is claimed its access logs belong to the owner (they include visitors' IP addresses), so the deployment ID stops working for them; use `app_id` with an API key.
75
+
60
76
  ## Config
61
77
 
62
78
  | Env var | Default | Description |
63
79
  | --- | --- | --- |
64
80
  | `VELIXIR_API_URL` | `https://velixir.net` | Override for staging or self-host. |
81
+ | `VELIXIR_API_KEY` | (none) | Optional. Lets `access_logs` read apps the user owns, by `app_id`. Create one at velixir.net under Settings, API keys. |
65
82
 
66
83
  ## Requirements
67
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velixir/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Deploy web apps in any language to Velixir (the European cloud) straight from your AI agent: point deploy at a directory for a live URL and claim link, then read logs to debug what you shipped.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- // Velixir MCP server. Exposes two tools:
2
+ // Velixir MCP server. Exposes three tools:
3
3
  // - `deploy`: pack a source directory and POST it to Velixir's account-less deploy
4
4
  // endpoint (POST /api/v1/deploy), returning a live URL + a single-use claim link
5
5
  // the human opens to keep the app on a free, card-free account.
6
6
  // - `logs`: fetch recent build + runtime logs for a deployment by its ID
7
7
  // (GET /api/v1/deploy/{id}/logs), so the agent can debug what it just shipped.
8
- // No Velixir account is needed for either.
8
+ // - `access_logs`: the HTTP requests an app served (GET /api/v1/deploy/{id}/access-logs for an
9
+ // unclaimed deploy, or /api/v1/apps/{id}/access-logs with VELIXIR_API_KEY for an owned app).
10
+ // No Velixir account is needed for deploy, logs, or access_logs on an unclaimed deploy.
9
11
  //
10
12
  // Zero setup: run with `npx -y @velixir/mcp`. Point VELIXIR_API_URL at a different
11
13
  // host for staging/self-host (default https://velixir.net).
@@ -28,25 +30,59 @@ const IGNORED_DIRS = new Set([
28
30
  ".venv", "venv", "__pycache__", ".pytest_cache", ".gradle", ".idea", ".vs",
29
31
  ]);
30
32
 
33
+ // Folders that hold credentials, never source.
34
+ const SECRET_DIRS = new Set([".ssh", ".aws", ".gnupg", ".docker", ".kube", ".azure"]);
35
+
36
+ // Files that hold secrets. An agent deploys whatever folder it was working in, and that folder
37
+ // usually has the user's .env in it. An unclaimed deploy is public for its whole window, so a
38
+ // secret uploaded here is a secret leaked. Example files (.env.example) are kept: they document
39
+ // variables without values and some apps read them at start-up.
40
+ const SECRET_FILE = [
41
+ /^\.env$/i,
42
+ /^\.env\.(?!example$|sample$|template$|dist$|defaults$).+/i,
43
+ /\.(pem|key|p12|pfx|jks|keystore|kdbx|ppk)$/i,
44
+ /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
45
+ /^\.(npmrc|pypirc|netrc|git-credentials|pgpass)$/i,
46
+ /^(credentials|service[-_]?account.*)\.json$/i,
47
+ ];
48
+
49
+ function isSecret(segments) {
50
+ const file = segments[segments.length - 1] ?? "";
51
+ return segments.some((seg) => SECRET_DIRS.has(seg)) || SECRET_FILE.some((re) => re.test(file));
52
+ }
53
+
31
54
  async function tarballOf(dir) {
55
+ const skippedSecrets = [];
32
56
  const stream = tarCreate(
33
57
  {
34
58
  gzip: true,
35
59
  cwd: dir,
36
60
  portable: true,
37
61
  filter: (entryPath) => {
38
- const segments = entryPath.split(/[\\/]/).filter(Boolean);
39
- return !segments.some((seg) => IGNORED_DIRS.has(seg));
62
+ const segments = entryPath.split(/[\\/]/).filter((s) => s && s !== ".");
63
+ if (segments.some((seg) => IGNORED_DIRS.has(seg))) return false;
64
+ if (isSecret(segments)) {
65
+ skippedSecrets.push(segments.join("/"));
66
+ return false;
67
+ }
68
+ return true;
40
69
  },
41
70
  },
42
71
  ["."],
43
72
  );
44
73
  const chunks = [];
45
74
  for await (const chunk of stream) chunks.push(chunk);
46
- return Buffer.concat(chunks);
75
+ return { buffer: Buffer.concat(chunks), skippedSecrets };
76
+ }
77
+
78
+ // Anonymous reads are rate limited per client (a burst, then one every two seconds). Tell the
79
+ // agent to wait instead of handing it a raw 429 it might retry in a tight loop.
80
+ function rateLimited(res) {
81
+ const wait = Number(res.headers.get("retry-after")) || 60;
82
+ return { isError: true, content: [{ type: "text", text: `Rate limited by Velixir. Wait about ${wait} seconds before calling this again, and avoid polling in a tight loop.` }] };
47
83
  }
48
84
 
49
- const server = new McpServer({ name: "velixir", version: "0.2.0" });
85
+ const server = new McpServer({ name: "velixir", version: "0.3.1" });
50
86
 
51
87
  server.tool(
52
88
  "deploy",
@@ -62,7 +98,7 @@ server.tool(
62
98
  language: z
63
99
  .string()
64
100
  .optional()
65
- .describe("Primary language (node, python, go, ruby, php, java, elixir, rust, dotnet, deno, fsharp). Auto-detected from the source if omitted."),
101
+ .describe("Primary language (node, python, go, ruby, php, java, elixir, rust, dotnet, deno, fsharp). Detected from the project files (package.json, go.mod, *.csproj and so on) if omitted; pass it when the folder mixes languages."),
66
102
  port: z
67
103
  .number()
68
104
  .int()
@@ -76,9 +112,9 @@ server.tool(
76
112
  return { isError: true, content: [{ type: "text", text: `Not a directory: ${dir}` }] };
77
113
  }
78
114
 
79
- let tar;
115
+ let tar, skippedSecrets;
80
116
  try {
81
- tar = await tarballOf(dir);
117
+ ({ buffer: tar, skippedSecrets } = await tarballOf(dir));
82
118
  } catch (err) {
83
119
  return { isError: true, content: [{ type: "text", text: `Could not package the source: ${err?.message ?? err}` }] };
84
120
  }
@@ -115,6 +151,9 @@ server.tool(
115
151
  ? `Deployment ID: ${data.deployment_id}\n(Use the "logs" tool with this ID to see build/runtime logs and debug if the app doesn't come up.)`
116
152
  : null,
117
153
  data.status_url ? `Deployment status: ${data.status_url}` : null,
154
+ skippedSecrets.length
155
+ ? `Not uploaded, because they look like secrets: ${skippedSecrets.slice(0, 10).join(", ")}${skippedSecrets.length > 10 ? ` and ${skippedSecrets.length - 10} more` : ""}. If the app needs these values, the user can set them as environment variables after claiming it.`
156
+ : null,
118
157
  ].filter(Boolean);
119
158
 
120
159
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -146,6 +185,7 @@ server.tool(
146
185
  return { isError: true, content: [{ type: "text", text: `Could not reach Velixir at ${API_URL}: ${err?.message ?? err}` }] };
147
186
  }
148
187
 
188
+ if (res.status === 429) return rateLimited(res);
149
189
  if (res.status === 404) {
150
190
  return { isError: true, content: [{ type: "text", text: `No logs for deployment ${deployment_id}. It may not exist, may already be claimed (use the dashboard for claimed apps), or its unclaimed window may have passed.` }] };
151
191
  }
@@ -172,5 +212,100 @@ server.tool(
172
212
  },
173
213
  );
174
214
 
215
+ // Optional: an API key (velixir.net → Settings → API keys) lets `access_logs` read apps the user
216
+ // owns, by app ID. Without it the tool still works for unclaimed deploys, by deployment ID.
217
+ const API_KEY = process.env.VELIXIR_API_KEY || "";
218
+
219
+ function statusClass(s) {
220
+ return s >= 500 ? "5xx" : s >= 400 ? "4xx" : s >= 300 ? "3xx" : "2xx";
221
+ }
222
+
223
+ server.tool(
224
+ "access_logs",
225
+ "Read the HTTP access logs for an app on Velixir: one line per request it served (time, status, method, path, duration, size, visitor IP), newest first, with a summary of status codes. Use this to see whether the app is getting traffic and to debug failing requests (filter with status: \"5xx\" or \"errors\"). For an app you deployed with the `deploy` tool, pass its deployment_id (works until the user claims it). For an app the user owns, pass app_id; that needs a VELIXIR_API_KEY in the MCP server's environment.",
226
+ {
227
+ deployment_id: z
228
+ .string()
229
+ .optional()
230
+ .describe("The deployment ID returned by the `deploy` tool (unclaimed apps, no account needed)."),
231
+ app_id: z
232
+ .string()
233
+ .optional()
234
+ .describe("The app's ID, for an app the user owns. Requires VELIXIR_API_KEY."),
235
+ status: z
236
+ .string()
237
+ .optional()
238
+ .describe("Filter: 2xx, 3xx, 4xx, 5xx, errors (4xx and 5xx), or an exact code like 404."),
239
+ path: z.string().optional().describe("Filter: only paths containing this text (case-insensitive)."),
240
+ method: z.string().optional().describe("Filter: HTTP method, e.g. POST."),
241
+ since_minutes: z.number().int().positive().optional().describe("Only requests from the last N minutes."),
242
+ limit: z.number().int().optional().describe("How many requests to return (default 50, max 200)."),
243
+ },
244
+ async ({ deployment_id, app_id, status, path: pathFilter, method, since_minutes, limit }) => {
245
+ if (!deployment_id && !app_id) {
246
+ return { isError: true, content: [{ type: "text", text: "Pass deployment_id (from the deploy tool) or app_id (needs VELIXIR_API_KEY)." }] };
247
+ }
248
+ if (!deployment_id && !API_KEY) {
249
+ return { isError: true, content: [{ type: "text", text: "Reading an owned app by app_id needs VELIXIR_API_KEY set in this MCP server's environment (create one at https://velixir.net under Settings, API keys)." }] };
250
+ }
251
+
252
+ const qs = new URLSearchParams({ limit: String(Math.min(Math.max(limit ?? 50, 1), 200)) });
253
+ if (status) qs.set("status", status);
254
+ if (pathFilter) qs.set("path", pathFilter);
255
+ if (method) qs.set("method", method);
256
+ if (since_minutes) qs.set("after", new Date(Date.now() - since_minutes * 60_000).toISOString());
257
+
258
+ const url = deployment_id
259
+ ? `${API_URL}/api/v1/deploy/${encodeURIComponent(deployment_id)}/access-logs?${qs}`
260
+ : `${API_URL}/api/v1/apps/${encodeURIComponent(app_id)}/access-logs?${qs}`;
261
+ const headers = deployment_id || !API_KEY ? {} : { "X-Api-Key": API_KEY };
262
+
263
+ let res, body;
264
+ try {
265
+ res = await fetch(url, { headers });
266
+ body = await res.text();
267
+ } catch (err) {
268
+ return { isError: true, content: [{ type: "text", text: `Could not reach Velixir at ${API_URL}: ${err?.message ?? err}` }] };
269
+ }
270
+
271
+ if (res.status === 429) return rateLimited(res);
272
+ if (res.status === 404) {
273
+ const why = deployment_id
274
+ ? `No unclaimed deployment ${deployment_id}. Once the user claims the app, read its access logs by app_id with a VELIXIR_API_KEY, or in the dashboard.`
275
+ : `App ${app_id} not found, or this API key can't read it.`;
276
+ return { isError: true, content: [{ type: "text", text: why }] };
277
+ }
278
+ if (res.status === 401 || res.status === 403) {
279
+ return { isError: true, content: [{ type: "text", text: "VELIXIR_API_KEY was rejected. Check the key in this MCP server's environment." }] };
280
+ }
281
+ if (!res.ok) {
282
+ let msg = body;
283
+ try { msg = JSON.parse(body).error ?? body; } catch { /* plain text */ }
284
+ return { isError: true, content: [{ type: "text", text: `Could not fetch access logs (HTTP ${res.status}): ${msg}` }] };
285
+ }
286
+
287
+ let data;
288
+ try { data = JSON.parse(body); } catch { data = {}; }
289
+ const entries = Array.isArray(data.entries) ? data.entries : [];
290
+ if (!entries.length) {
291
+ return { content: [{ type: "text", text: "No requests match yet. If the app was just deployed, open its URL once and try again: lines arrive within a few seconds." }] };
292
+ }
293
+
294
+ const counts = {};
295
+ for (const e of entries) counts[statusClass(e.status)] = (counts[statusClass(e.status)] ?? 0) + 1;
296
+ const slowest = entries.reduce((a, b) => (b.requestTimeMs > a.requestTimeMs ? b : a));
297
+ const out = [
298
+ `${entries.length} request(s), newest first. Status: ${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}.`,
299
+ `Slowest: ${slowest.method} ${slowest.path} took ${slowest.requestTimeMs} ms (status ${slowest.status}).`,
300
+ "",
301
+ ...entries.map(e =>
302
+ `${e.timestamp} ${e.status} ${e.method} ${e.path}${e.query ? "?" + e.query : ""} ${e.requestTimeMs}ms ${e.bytesSent}B ip=${e.clientIp}` +
303
+ (e.status >= 400 && e.userAgent ? ` ua="${e.userAgent}"` : "")),
304
+ ];
305
+ if (data.nextBefore) out.push("", `More, older requests exist; narrow with since_minutes, status or path.`);
306
+ return { content: [{ type: "text", text: out.join("\n") }] };
307
+ },
308
+ );
309
+
175
310
  const transport = new StdioServerTransport();
176
311
  await server.connect(transport);