@offlocal/mcp 0.3.0 → 0.3.2

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/dist/client.js CHANGED
@@ -1,7 +1,13 @@
1
+ import { MCP_VERSION } from "./version.js";
1
2
  const API_URL = process.env.OFFLOCAL_API_URL?.replace(/\/+$/, "") ?? "http://localhost:4000";
2
3
  const AGENT_KEY = process.env.OFFLOCAL_AGENT_KEY ?? "";
3
4
  function authHeaders() {
4
- return AGENT_KEY ? { Authorization: `Bearer ${AGENT_KEY}` } : {};
5
+ const headers = {
6
+ "x-offlocal-mcp-version": MCP_VERSION,
7
+ };
8
+ if (AGENT_KEY)
9
+ headers.Authorization = `Bearer ${AGENT_KEY}`;
10
+ return headers;
5
11
  }
6
12
  export async function pingApi() {
7
13
  if (!AGENT_KEY) {
@@ -14,6 +20,15 @@ export async function pingApi() {
14
20
  headers: { "content-type": "application/json", ...authHeaders() },
15
21
  body: "{}",
16
22
  });
23
+ if (res.status === 426) {
24
+ const body = await res.text().catch(() => "");
25
+ return {
26
+ ok: false,
27
+ mustUpdate: true,
28
+ reason: body.slice(0, 300) ||
29
+ "This Offlocal MCP version is no longer supported. Update with: npx -y @offlocal/mcp@latest",
30
+ };
31
+ }
17
32
  if (!res.ok) {
18
33
  const body = await res.text().catch(() => "");
19
34
  return {
@@ -22,7 +37,11 @@ export async function pingApi() {
22
37
  };
23
38
  }
24
39
  const data = (await res.json());
25
- return { ok: true, user: data.user };
40
+ return {
41
+ ok: true,
42
+ user: data.user,
43
+ updateAvailable: res.headers.get("x-offlocal-mcp-update"),
44
+ };
26
45
  }
27
46
  catch (err) {
28
47
  const message = err instanceof Error ? err.message : String(err);
package/dist/detect.js CHANGED
@@ -124,6 +124,7 @@ const WEB_FRAMEWORKS = new Set([
124
124
  "remix",
125
125
  "astro",
126
126
  "vite",
127
+ "cra",
127
128
  "static",
128
129
  ]);
129
130
  const API_FRAMEWORKS = new Set(["fastify", "hono", "express", "koa", "nestjs"]);
@@ -214,6 +215,8 @@ export function apiUrlEnvVar(framework) {
214
215
  return { key: "NEXT_PUBLIC_API_URL", buildTime: true };
215
216
  case "vite":
216
217
  return { key: "VITE_API_URL", buildTime: true };
218
+ case "cra":
219
+ return { key: "REACT_APP_API_URL", buildTime: true };
217
220
  case "nuxt":
218
221
  return { key: "NUXT_PUBLIC_API_URL", buildTime: true };
219
222
  case "astro":
@@ -279,9 +282,12 @@ async function detectPython(sourceDir, appName) {
279
282
  const hasManage = await fileExists(join(sourceDir, "manage.py"));
280
283
  let framework = "python";
281
284
  let startCommand = "python app.py";
285
+ const notes = [];
282
286
  if (hasManage || manifest.includes("django")) {
283
287
  framework = "django";
284
- startCommand = "python manage.py runserver 0.0.0.0:$PORT";
288
+ const wsgi = await findDjangoWsgiModule(sourceDir);
289
+ startCommand = `python manage.py migrate --noinput && gunicorn ${wsgi}:application --bind 0.0.0.0:$PORT`;
290
+ notes.push("Django runs under gunicorn (production) and migrates on start. IMPORTANT: a default project's `ALLOWED_HOSTS` is empty and will 400 every request. Make settings.py read it from the environment — e.g. `ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')` — and offlocal will set ALLOWED_HOSTS to the live host automatically. Use a real database (SQLite resets on every cold start), and run `collectstatic` in a build step if you serve static files.");
285
291
  }
286
292
  else if (manifest.includes("fastapi")) {
287
293
  framework = "fastapi";
@@ -302,8 +308,27 @@ async function detectPython(sourceDir, appName) {
302
308
  startCommand,
303
309
  port: 8000,
304
310
  strategy: "template",
311
+ notes: notes.length ? notes : undefined,
305
312
  };
306
313
  }
314
+ async function findDjangoWsgiModule(sourceDir) {
315
+ const manage = await readTextSafe(join(sourceDir, "manage.py"));
316
+ const m = /DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([\w.]+)\.settings['"]/.exec(manage);
317
+ if (m?.[1])
318
+ return `${m[1]}.wsgi`;
319
+ try {
320
+ const entries = await readdir(sourceDir, { withFileTypes: true });
321
+ for (const e of entries) {
322
+ if (e.isDirectory() &&
323
+ (await fileExists(join(sourceDir, e.name, "wsgi.py")))) {
324
+ return `${e.name}.wsgi`;
325
+ }
326
+ }
327
+ }
328
+ catch {
329
+ }
330
+ return "wsgi";
331
+ }
307
332
  function pickNodeFramework(deps) {
308
333
  if ("next" in deps)
309
334
  return "nextjs";
@@ -313,6 +338,10 @@ function pickNodeFramework(deps) {
313
338
  return "remix";
314
339
  if ("astro" in deps)
315
340
  return "astro";
341
+ if ("vite" in deps)
342
+ return "vite";
343
+ if ("react-scripts" in deps || "@craco/craco" in deps)
344
+ return "cra";
316
345
  if ("fastify" in deps)
317
346
  return "fastify";
318
347
  if ("hono" in deps)
@@ -321,8 +350,6 @@ function pickNodeFramework(deps) {
321
350
  return "express";
322
351
  if ("koa" in deps)
323
352
  return "koa";
324
- if ("vite" in deps)
325
- return "vite";
326
353
  return "node";
327
354
  }
328
355
  function nodePort(framework) {
@@ -331,6 +358,8 @@ function nodePort(framework) {
331
358
  return 4321;
332
359
  case "vite":
333
360
  return 4173;
361
+ case "cra":
362
+ return 80;
334
363
  default:
335
364
  return 3000;
336
365
  }
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { pingApi } from "./client.js";
6
+ import { MCP_VERSION } from "./version.js";
6
7
  import { DeployInputShape, RollbackInputShape, deployHandler, rollbackHandler, } from "./tools/deploy.js";
7
8
  import { PlansInputShape, plansHandler } from "./tools/plans.js";
8
9
  import { StatusInputShape, statusHandler } from "./tools/status.js";
@@ -27,7 +28,7 @@ const PlansSchema = z.object(PlansInputShape);
27
28
  async function main() {
28
29
  const server = new McpServer({
29
30
  name: "offlocal",
30
- version: "0.1.0",
31
+ version: MCP_VERSION,
31
32
  });
32
33
  server.tool("offlocal_ping", "Verify the Offlocal connection. Returns the signed-in email if connected, or an error message if not. This tool does NOT deploy anything, upload anything, or provision any resources - it is the right tool to call when the user asks to 'check my Offlocal connection' or similar.", PingInputShape, async (args) => pingHandler(PingSchema.parse(args)));
33
34
  server.tool("offlocal_plan", "START HERE when the user asks to deploy/ship/set up a project. Detects the app + the resources it needs (e.g. a Postgres database), applies the user's saved platform preferences, and returns a complete proposed plan (which platform for each piece — Offlocal.ai by default, or Supabase/PlanetScale/DigitalOcean/Railway). Present the plan to the user verbatim and let them confirm with 'go' or switch any line. Then execute the steps the plan lists. Do NOT deploy or provision anything until the user confirms the plan.", PlanInputShape, async (args) => planHandler(PlanSchema.parse(args)));
@@ -50,7 +51,16 @@ async function main() {
50
51
  process.on("unhandledRejection", (reason) => {
51
52
  console.error("offlocal-mcp unhandled rejection (continuing):", reason);
52
53
  });
53
- pingApi().catch(() => { });
54
+ pingApi()
55
+ .then((r) => {
56
+ if (!r.ok && r.mustUpdate) {
57
+ console.error(`offlocal-mcp: ${r.reason}`);
58
+ }
59
+ else if (r.ok && r.updateAvailable) {
60
+ console.error(`offlocal-mcp: a newer version (v${r.updateAvailable}) is available — update with: npx -y @offlocal/mcp@latest`);
61
+ }
62
+ })
63
+ .catch(() => { });
54
64
  const transport = new StdioServerTransport();
55
65
  await server.connect(transport);
56
66
  }
@@ -72,13 +72,21 @@ export async function deployHandler(input) {
72
72
  `| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
73
73
  `| Port | ${summary.port} |\n` +
74
74
  `| Bundle | ${summary.bundle.fileCount} files · ${summary.bundle.bytes} bytes |\n\n` +
75
+ (bundle.warnings.length
76
+ ? bundle.warnings.map((w) => `${ICON.warn} ${w}`).join("\n") + "\n\n"
77
+ : "") +
78
+ (detected.notes?.length
79
+ ? detected.notes.map((n) => `${ICON.warn} ${n}`).join("\n\n") +
80
+ "\n\n"
81
+ : "") +
75
82
  `———\n` +
76
- `${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–4 min) and you'll ` +
83
+ `${ICON.arrow} **Next (you, the agent):** tell the user it's building (usually 2–8 min) and you'll ` +
77
84
  `report back when it's live. Then:\n` +
78
85
  ` 1. Sleep 45 seconds.\n` +
79
86
  ` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
80
87
  ` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
81
- ` 4. Repeat until \`live\` or \`failed\`. Polling faster does NOT speed up the build.\n` +
88
+ ` 4. Repeat until \`live\` or \`failed\`. **Slow is normal don't panic or declare failure on a slow deploy.** ` +
89
+ `The status tool tells you if it's genuinely stuck; only then should you stop and suggest a retry.\n` +
82
90
  `Keep the **projectId** from the live status for any database step.`,
83
91
  },
84
92
  ],
@@ -4,6 +4,19 @@ export const PingInputShape = {};
4
4
  export async function pingHandler(_input) {
5
5
  const result = await pingApi();
6
6
  if (!result.ok) {
7
+ if (result.mustUpdate) {
8
+ return {
9
+ content: [
10
+ {
11
+ type: "text",
12
+ text: `⚠️ Your Offlocal MCP is out of date and no longer supported.\n\n` +
13
+ `${result.reason}\n\n` +
14
+ "Update it with: npx -y @offlocal/mcp@latest (then restart the agent).",
15
+ },
16
+ ],
17
+ structuredContent: { connected: false, mustUpdate: true, reason: result.reason },
18
+ };
19
+ }
7
20
  return {
8
21
  content: [
9
22
  {
@@ -20,14 +33,21 @@ export async function pingHandler(_input) {
20
33
  };
21
34
  }
22
35
  const email = result.user?.email ?? "(unknown)";
36
+ const updateNote = result.updateAvailable
37
+ ? `\n\nℹ️ A newer Offlocal MCP (v${result.updateAvailable}) is available — update with: npx -y @offlocal/mcp@latest`
38
+ : "";
23
39
  return {
24
40
  content: [
25
41
  {
26
42
  type: "text",
27
- text: `Connected to Offlocal as ${email}.`,
43
+ text: `Connected to Offlocal as ${email}.${updateNote}`,
28
44
  },
29
45
  ],
30
- structuredContent: { connected: true, email },
46
+ structuredContent: {
47
+ connected: true,
48
+ email,
49
+ updateAvailable: result.updateAvailable ?? null,
50
+ },
31
51
  };
32
52
  }
33
53
  void z;
@@ -110,7 +110,10 @@ export async function connectHandler(input) {
110
110
  };
111
111
  }
112
112
  export const ProvisionInputShape = {
113
- provider: z.string().min(1).describe("The connected provider to provision on."),
113
+ provider: z
114
+ .string()
115
+ .min(1)
116
+ .describe("The CONNECTED external provider to provision on: supabase, planetscale, digitalocean, or railway. For Offlocal's own managed Postgres, use offlocal_create_managed_database instead — not this."),
114
117
  type: z
115
118
  .enum(["project", "database", "deployment", "env_vars", "branch"])
116
119
  .describe("The kind of resource to create."),
@@ -131,6 +134,11 @@ export const ProvisionInputShape = {
131
134
  };
132
135
  export async function provisionHandler(input) {
133
136
  const { provider, confirm, projectId, ...spec } = input;
137
+ if (["offlocal", "offlocal.ai"].includes(provider.toLowerCase())) {
138
+ return text(`${ICON.warn} "offlocal" isn't a provision provider. For an Offlocal Managed Postgres database, call ` +
139
+ `\`offlocal_create_managed_database({ projectId })\`. \`offlocal_provision\` is only for connected external ` +
140
+ `providers: **supabase, planetscale, digitalocean, railway**.`);
141
+ }
134
142
  const res = await provisionResource(provider, spec, {
135
143
  confirm: confirm ?? false,
136
144
  projectId,
@@ -214,7 +222,7 @@ export async function createManagedDatabaseHandler(input) {
214
222
  const res = await createManagedDatabase(input.projectId);
215
223
  if (res.error) {
216
224
  const planHint = res.error === "plan_not_allowed" || res.error === "limit_reached"
217
- ? " A Hobby plan or higher is required, or the database limit was reached plans can be changed at /billing."
225
+ ? ` Two options: add a managed Postgres by upgrading at /billing, or connect your own database with \`offlocal_connect_provider\` (capability "postgres").`
218
226
  : "";
219
227
  return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${planHint}`, res);
220
228
  }
@@ -24,6 +24,9 @@ export async function statusHandler(input) {
24
24
  const isTerminal = dep.status === "live" || dep.status === "failed";
25
25
  const pollAfterSeconds = isTerminal ? 0 : pollIntervalFor(dep.status);
26
26
  const etaSeconds = isTerminal ? 0 : etaFor(dep.status);
27
+ const secondsSinceUpdate = Math.max(0, Math.round((Date.now() - new Date(dep.updatedAt).getTime()) / 1000));
28
+ const STUCK_AFTER = 9 * 60;
29
+ const stalled = !isTerminal && secondsSinceUpdate > STUCK_AFTER;
27
30
  const summary = {
28
31
  deploymentId: dep.id,
29
32
  projectId: dep.projectId,
@@ -32,6 +35,8 @@ export async function statusHandler(input) {
32
35
  isTerminal,
33
36
  pollAfterSeconds,
34
37
  etaSeconds,
38
+ secondsSinceUpdate,
39
+ stalled,
35
40
  liveUrl: dep.liveUrl,
36
41
  errorType: dep.errorType,
37
42
  errorMessage: dep.errorMessage,
@@ -55,15 +60,29 @@ export async function statusHandler(input) {
55
60
  lines.push(dep.errorMessage.slice(0, 2000));
56
61
  lines.push("```");
57
62
  }
63
+ if (dep.errorType === "build_failed" || dep.errorType === "build_error") {
64
+ lines.push("");
65
+ lines.push(`Call \`offlocal_logs({ deploymentId: "${dep.id}" })\` for the full build output, fix the cause, then re-run \`offlocal_deploy\`.`);
66
+ }
58
67
  }
59
68
  else {
60
69
  lines.push(`${ICON.busy} **${phase}**`);
61
70
  lines.push("");
62
71
  lines.push(bar);
63
72
  lines.push("");
64
- lines.push(`Still in progress (typical end-to-end deploy: 2–4 min). Do NOT call this tool ` +
65
- `again for at least ${pollAfterSeconds}s polling doesn't speed it up. ` +
66
- `Sleep ${pollAfterSeconds}s, then call it exactly once.`);
73
+ if (stalled) {
74
+ lines.push(`${ICON.warn} It's been at "${phase}" for ~${Math.round(secondsSinceUpdate / 60)} min with no change — ` +
75
+ `longer than a normal deploy. It may be stuck.\n` +
76
+ `Tell the user it's taking unusually long. You can wait a little longer, but do NOT keep polling indefinitely — ` +
77
+ `if it doesn't move within another couple of minutes, suggest re-running \`offlocal_deploy\` (a retry usually clears a stuck deploy).`);
78
+ }
79
+ else {
80
+ lines.push(`Building / rolling out — **this is normal and can take 2–8 minutes** (occasionally a bit more). Stay calm and don't treat slowness as failure:\n` +
81
+ `• A deploy is only failed when the status is literally **failed** — nothing else means it broke.\n` +
82
+ `• "starting container" can mean the container is already healthy but not yet promoted; it'll flip to **live** shortly.\n` +
83
+ `• Empty \`offlocal_logs\` during build / before live is expected (container logs only exist once it's running) — not an error.\n` +
84
+ `Sleep ${pollAfterSeconds}s, then call this exactly once. Polling faster does not help.`);
85
+ }
67
86
  }
68
87
  return {
69
88
  content: [{ type: "text", text: lines.join("\n") }],
@@ -0,0 +1,9 @@
1
+ import { createRequire } from "node:module";
2
+ let version = "0.0.0";
3
+ try {
4
+ const req = createRequire(import.meta.url);
5
+ version = req("../package.json").version ?? version;
6
+ }
7
+ catch {
8
+ }
9
+ export const MCP_VERSION = version;
package/dist/zip.js CHANGED
@@ -20,13 +20,16 @@ const IGNORED_DIRS = new Set([
20
20
  ".idea",
21
21
  ".vscode",
22
22
  ]);
23
- const IGNORED_FILE_RE = /^(\.env(\..+)?|.*\.log|\.DS_Store|Thumbs\.db|.*\.tsbuildinfo)$/;
23
+ const IGNORED_FILE_RE = /^(\.env(\..+)?|\.DS_Store|Thumbs\.db)$|\.(log|tsbuildinfo|exe|dll|dylib|so|msi|dmg|pdb|class|o|obj|lib)$/i;
24
24
  const MAX_FILE_BYTES = 50 * 1024 * 1024;
25
25
  const MAX_BUNDLE_BYTES = 250 * 1024 * 1024;
26
+ const WARN_BUNDLE_BYTES = 25 * 1024 * 1024;
27
+ const WARN_FILE_BYTES = 5 * 1024 * 1024;
26
28
  export async function bundleDirectory(sourceDir) {
27
29
  const zip = new AdmZip();
28
30
  let fileCount = 0;
29
31
  let totalBytes = 0;
32
+ const bigFiles = [];
30
33
  await walk(sourceDir, async (absolute) => {
31
34
  const rel = relative(sourceDir, absolute);
32
35
  if (!rel)
@@ -42,14 +45,26 @@ export async function bundleDirectory(sourceDir) {
42
45
  zip.addFile(zipPath, content);
43
46
  fileCount++;
44
47
  totalBytes += st.size;
48
+ if (st.size > WARN_FILE_BYTES)
49
+ bigFiles.push({ rel: zipPath, bytes: st.size });
45
50
  if (totalBytes > MAX_BUNDLE_BYTES) {
46
- throw new Error(`bundle exceeds ${MAX_BUNDLE_BYTES} bytes. Trim node_modules-like outputs or add a .offlocalignore.`);
51
+ throw new Error(`bundle exceeds ${MAX_BUNDLE_BYTES} bytes. Trim node_modules-like outputs or add a .gitignore.`);
47
52
  }
48
53
  });
49
54
  if (fileCount === 0) {
50
55
  throw new Error(`no files to bundle under ${sourceDir}`);
51
56
  }
52
- return { buffer: zip.toBuffer(), fileCount, byteSize: totalBytes };
57
+ const warnings = [];
58
+ if (totalBytes > WARN_BUNDLE_BYTES) {
59
+ warnings.push(`Bundle is ${mb(totalBytes)} — large for a deploy. Add a .gitignore for build output/binaries you don't need to ship.`);
60
+ }
61
+ for (const f of bigFiles.sort((a, b) => b.bytes - a.bytes).slice(0, 3)) {
62
+ warnings.push(`Large file bundled: ${f.rel} (${mb(f.bytes)}). Gitignore it if it isn't needed at runtime.`);
63
+ }
64
+ return { buffer: zip.toBuffer(), fileCount, byteSize: totalBytes, warnings };
65
+ }
66
+ function mb(bytes) {
67
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
53
68
  }
54
69
  async function walk(root, onFile) {
55
70
  const stack = [root];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offlocal/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "MCP server for Offlocal - lets your AI coding agent deploy apps via offlocal_deploy, offlocal_status, offlocal_logs, offlocal_ping.",
5
5
  "keywords": [
6
6
  "mcp",