@offlocal/mcp 0.3.1 → 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 +21 -2
- package/dist/detect.js +30 -1
- package/dist/index.js +12 -2
- package/dist/tools/deploy.js +4 -0
- package/dist/tools/ping.js +22 -2
- package/dist/tools/providers.js +1 -1
- package/dist/tools/status.js +4 -0
- package/dist/version.js +9 -0
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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";
|
|
@@ -315,6 +340,8 @@ function pickNodeFramework(deps) {
|
|
|
315
340
|
return "astro";
|
|
316
341
|
if ("vite" in deps)
|
|
317
342
|
return "vite";
|
|
343
|
+
if ("react-scripts" in deps || "@craco/craco" in deps)
|
|
344
|
+
return "cra";
|
|
318
345
|
if ("fastify" in deps)
|
|
319
346
|
return "fastify";
|
|
320
347
|
if ("hono" in deps)
|
|
@@ -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:
|
|
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()
|
|
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
|
}
|
package/dist/tools/deploy.js
CHANGED
|
@@ -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` +
|
package/dist/tools/ping.js
CHANGED
|
@@ -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: {
|
|
46
|
+
structuredContent: {
|
|
47
|
+
connected: true,
|
|
48
|
+
email,
|
|
49
|
+
updateAvailable: result.updateAvailable ?? null,
|
|
50
|
+
},
|
|
31
51
|
};
|
|
32
52
|
}
|
|
33
53
|
void z;
|
package/dist/tools/providers.js
CHANGED
|
@@ -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
|
-
?
|
|
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
|
}
|
package/dist/tools/status.js
CHANGED
|
@@ -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}**`);
|
package/dist/version.js
ADDED
package/package.json
CHANGED