@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 +21 -2
- package/dist/detect.js +45 -1
- package/dist/index.js +12 -2
- package/dist/tools/deploy.js +4 -0
- package/dist/tools/ping.js +22 -2
- package/dist/tools/plan.js +1 -0
- 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
|
@@ -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
|
-
|
|
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:
|
|
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/plan.js
CHANGED
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