@offlocal/mcp 0.3.1 → 0.3.3

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
@@ -116,6 +116,21 @@ export async function detectRequirements(sourceDir) {
116
116
  if (has("redis") || has("ioredis")) {
117
117
  reqs.push({ capability: "redis", reason: "Redis client detected" });
118
118
  }
119
+ const wantsObjectStorage = has("@aws-sdk/client-s3") ||
120
+ has("@aws-sdk/lib-storage") ||
121
+ has("aws-sdk") ||
122
+ has("@google-cloud/storage") ||
123
+ has("@azure/storage-blob") ||
124
+ has("minio") ||
125
+ has("multer-s3") ||
126
+ has("cloudinary") ||
127
+ /\b(s3_bucket|bucket_name|storage_bucket|r2_bucket|spaces_bucket|cloudinary_url)\b/.test(envText);
128
+ if (wantsObjectStorage) {
129
+ reqs.push({
130
+ capability: "object_storage",
131
+ reason: "object-storage client or bucket config detected",
132
+ });
133
+ }
119
134
  return reqs;
120
135
  }
121
136
  const WEB_FRAMEWORKS = new Set([
@@ -124,6 +139,7 @@ const WEB_FRAMEWORKS = new Set([
124
139
  "remix",
125
140
  "astro",
126
141
  "vite",
142
+ "cra",
127
143
  "static",
128
144
  ]);
129
145
  const API_FRAMEWORKS = new Set(["fastify", "hono", "express", "koa", "nestjs"]);
@@ -214,6 +230,8 @@ export function apiUrlEnvVar(framework) {
214
230
  return { key: "NEXT_PUBLIC_API_URL", buildTime: true };
215
231
  case "vite":
216
232
  return { key: "VITE_API_URL", buildTime: true };
233
+ case "cra":
234
+ return { key: "REACT_APP_API_URL", buildTime: true };
217
235
  case "nuxt":
218
236
  return { key: "NUXT_PUBLIC_API_URL", buildTime: true };
219
237
  case "astro":
@@ -279,9 +297,12 @@ async function detectPython(sourceDir, appName) {
279
297
  const hasManage = await fileExists(join(sourceDir, "manage.py"));
280
298
  let framework = "python";
281
299
  let startCommand = "python app.py";
300
+ const notes = [];
282
301
  if (hasManage || manifest.includes("django")) {
283
302
  framework = "django";
284
- startCommand = "python manage.py runserver 0.0.0.0:$PORT";
303
+ const wsgi = await findDjangoWsgiModule(sourceDir);
304
+ startCommand = `python manage.py migrate --noinput && gunicorn ${wsgi}:application --bind 0.0.0.0:$PORT`;
305
+ 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
306
  }
286
307
  else if (manifest.includes("fastapi")) {
287
308
  framework = "fastapi";
@@ -302,8 +323,27 @@ async function detectPython(sourceDir, appName) {
302
323
  startCommand,
303
324
  port: 8000,
304
325
  strategy: "template",
326
+ notes: notes.length ? notes : undefined,
305
327
  };
306
328
  }
329
+ async function findDjangoWsgiModule(sourceDir) {
330
+ const manage = await readTextSafe(join(sourceDir, "manage.py"));
331
+ const m = /DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([\w.]+)\.settings['"]/.exec(manage);
332
+ if (m?.[1])
333
+ return `${m[1]}.wsgi`;
334
+ try {
335
+ const entries = await readdir(sourceDir, { withFileTypes: true });
336
+ for (const e of entries) {
337
+ if (e.isDirectory() &&
338
+ (await fileExists(join(sourceDir, e.name, "wsgi.py")))) {
339
+ return `${e.name}.wsgi`;
340
+ }
341
+ }
342
+ }
343
+ catch {
344
+ }
345
+ return "wsgi";
346
+ }
307
347
  function pickNodeFramework(deps) {
308
348
  if ("next" in deps)
309
349
  return "nextjs";
@@ -315,6 +355,8 @@ function pickNodeFramework(deps) {
315
355
  return "astro";
316
356
  if ("vite" in deps)
317
357
  return "vite";
358
+ if ("react-scripts" in deps || "@craco/craco" in deps)
359
+ return "cra";
318
360
  if ("fastify" in deps)
319
361
  return "fastify";
320
362
  if ("hono" in deps)
@@ -331,6 +373,8 @@ function nodePort(framework) {
331
373
  return 4321;
332
374
  case "vite":
333
375
  return 4173;
376
+ case "cra":
377
+ return 80;
334
378
  default:
335
379
  return 3000;
336
380
  }
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
  }
@@ -75,6 +75,10 @@ export async function deployHandler(input) {
75
75
  (bundle.warnings.length
76
76
  ? bundle.warnings.map((w) => `${ICON.warn} ${w}`).join("\n") + "\n\n"
77
77
  : "") +
78
+ (detected.notes?.length
79
+ ? detected.notes.map((n) => `${ICON.warn} ${n}`).join("\n\n") +
80
+ "\n\n"
81
+ : "") +
78
82
  `———\n` +
79
83
  `${ICON.arrow} **Next (you, the agent):** tell the user it's building (usually 2–8 min) and you'll ` +
80
84
  `report back when it's live. Then:\n` +
@@ -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;
@@ -11,6 +11,7 @@ const CAP_LABEL = {
11
11
  postgres: "Postgres database",
12
12
  mysql: "MySQL database",
13
13
  redis: "Redis",
14
+ object_storage: "Object storage",
14
15
  };
15
16
  const ROLE_LABEL = {
16
17
  web: "Web app",
@@ -222,7 +222,7 @@ export async function createManagedDatabaseHandler(input) {
222
222
  const res = await createManagedDatabase(input.projectId);
223
223
  if (res.error) {
224
224
  const planHint = res.error === "plan_not_allowed" || res.error === "limit_reached"
225
- ? " 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").`
226
226
  : "";
227
227
  return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${planHint}`, res);
228
228
  }
@@ -60,6 +60,10 @@ export async function statusHandler(input) {
60
60
  lines.push(dep.errorMessage.slice(0, 2000));
61
61
  lines.push("```");
62
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
+ }
63
67
  }
64
68
  else {
65
69
  lines.push(`${ICON.busy} **${phase}**`);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offlocal/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",