@offlocal/mcp 0.1.0 → 0.3.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @offlocal/mcp
2
2
 
3
- The MCP (Model Context Protocol) server for [Offlocal](https://offlocal.ai)
3
+ The MCP (Model Context Protocol) server for [Offlocal](https://offlocal.ai) -
4
4
  lets your AI coding agent (Claude Code, Cursor, Codex, Windsurf, etc.) deploy
5
5
  apps directly from a coding session.
6
6
 
package/dist/client.js CHANGED
@@ -18,7 +18,7 @@ export async function pingApi() {
18
18
  const body = await res.text().catch(() => "");
19
19
  return {
20
20
  ok: false,
21
- reason: `${res.status} ${res.statusText} from ${url}${body ? ` ${body.slice(0, 200)}` : ""}`,
21
+ reason: `${res.status} ${res.statusText} from ${url}${body ? ` - ${body.slice(0, 200)}` : ""}`,
22
22
  };
23
23
  }
24
24
  const data = (await res.json());
@@ -28,7 +28,7 @@ export async function pingApi() {
28
28
  const message = err instanceof Error ? err.message : String(err);
29
29
  return {
30
30
  ok: false,
31
- reason: `fetch ${url} failed ${message}`,
31
+ reason: `fetch ${url} failed - ${message}`,
32
32
  };
33
33
  }
34
34
  }
@@ -77,8 +77,107 @@ export async function getDeploymentLogs(deploymentId, options) {
77
77
  const json = (await res.json());
78
78
  return json.logs;
79
79
  }
80
+ export async function listConnections() {
81
+ const res = await fetch(`${API_URL}/api/providers`, {
82
+ headers: { ...authHeaders() },
83
+ });
84
+ if (!res.ok)
85
+ throw new Error(await failureText(res, "list_connections"));
86
+ const json = (await res.json());
87
+ return json.providers;
88
+ }
89
+ export async function resolveRequirement(need) {
90
+ const res = await fetch(`${API_URL}/api/providers/resolve?need=${encodeURIComponent(need)}`, { headers: { ...authHeaders() } });
91
+ if (!res.ok)
92
+ throw new Error(await failureText(res, "resolve_requirement"));
93
+ return (await res.json());
94
+ }
95
+ export async function getConnectLink(provider) {
96
+ const res = await fetch(`${API_URL}/api/providers/${encodeURIComponent(provider)}/connect-link`, { headers: { ...authHeaders() } });
97
+ if (!res.ok)
98
+ throw new Error(await failureText(res, "connect_link"));
99
+ return (await res.json());
100
+ }
101
+ export async function provisionResource(provider, spec, opts) {
102
+ const res = await fetch(`${API_URL}/api/providers/${encodeURIComponent(provider)}/provision`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json", ...authHeaders() },
105
+ body: JSON.stringify({
106
+ ...spec,
107
+ confirm: opts?.confirm ?? false,
108
+ projectId: opts?.projectId,
109
+ }),
110
+ });
111
+ if (!res.ok && res.status !== 502) {
112
+ throw new Error(await failureText(res, "provision"));
113
+ }
114
+ return (await res.json());
115
+ }
116
+ export async function pollProvision(actionId) {
117
+ const res = await fetch(`${API_URL}/api/providers/provision/${encodeURIComponent(actionId)}/poll`, { method: "POST", headers: { ...authHeaders() } });
118
+ if (!res.ok && res.status !== 502) {
119
+ throw new Error(await failureText(res, "poll_provision"));
120
+ }
121
+ return (await res.json());
122
+ }
123
+ export async function getPreferences() {
124
+ const res = await fetch(`${API_URL}/api/me/preferences`, {
125
+ headers: { ...authHeaders() },
126
+ });
127
+ if (!res.ok)
128
+ return {};
129
+ const json = (await res.json());
130
+ return json.preferences ?? {};
131
+ }
132
+ export async function setPreference(capability, platform) {
133
+ const res = await fetch(`${API_URL}/api/me/preferences`, {
134
+ method: "PUT",
135
+ headers: { "content-type": "application/json", ...authHeaders() },
136
+ body: JSON.stringify({ capability, platform }),
137
+ });
138
+ if (!res.ok)
139
+ throw new Error(await failureText(res, "set_preference"));
140
+ }
141
+ export async function createManagedDatabase(projectId) {
142
+ const res = await fetch(`${API_URL}/api/projects/${encodeURIComponent(projectId)}/database/create`, { method: "POST", headers: { ...authHeaders() } });
143
+ const json = (await res.json().catch(() => ({})));
144
+ if (!res.ok) {
145
+ return { error: json.error ?? String(res.status), message: json.message };
146
+ }
147
+ return json;
148
+ }
149
+ export async function setProjectEnv(projectId, vars) {
150
+ const res = await fetch(`${API_URL}/api/projects/${encodeURIComponent(projectId)}/env`, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json", ...authHeaders() },
153
+ body: JSON.stringify({ vars }),
154
+ });
155
+ const json = (await res.json().catch(() => ({})));
156
+ if (!res.ok)
157
+ return { error: json.error ?? String(res.status), message: json.message };
158
+ return json;
159
+ }
160
+ export async function rollback(projectId, toDeploymentId) {
161
+ const res = await fetch(`${API_URL}/api/projects/${encodeURIComponent(projectId)}/rollback`, {
162
+ method: "POST",
163
+ headers: { "content-type": "application/json", ...authHeaders() },
164
+ body: JSON.stringify(toDeploymentId ? { toDeploymentId } : {}),
165
+ });
166
+ const json = (await res.json().catch(() => ({})));
167
+ if (!res.ok) {
168
+ return { error: json.error ?? String(res.status), message: json.message };
169
+ }
170
+ return json;
171
+ }
172
+ export async function getPlanInfo() {
173
+ const res = await fetch(`${API_URL}/api/me/plan`, {
174
+ headers: { ...authHeaders() },
175
+ });
176
+ if (!res.ok)
177
+ throw new Error(await failureText(res, "get_plan"));
178
+ return (await res.json());
179
+ }
80
180
  async function failureText(res, kind) {
81
181
  const body = await res.text().catch(() => "");
82
- return `${kind} failed: ${res.status} ${res.statusText}${body ? ` ${body.slice(0, 400)}` : ""}`;
182
+ return `${kind} failed: ${res.status} ${res.statusText}${body ? ` - ${body.slice(0, 400)}` : ""}`;
83
183
  }
84
- //# sourceMappingURL=client.js.map
package/dist/detect.js CHANGED
@@ -1,46 +1,310 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
- const DEFAULTS = {
4
- runtime: "node-20",
5
- framework: "node",
6
- buildCommand: "echo no-build",
7
- startCommand: "npm start",
8
- port: 3000,
9
- };
3
+ const NO_BUILD = "echo no-build";
4
+ const FROM_DOCKERFILE = "(provided by Dockerfile)";
10
5
  export async function detect(sourceDir) {
11
- const pkgPath = join(sourceDir, "package.json");
12
- let pkg = null;
13
- if (await fileExists(pkgPath)) {
6
+ const appName = await resolveAppName(sourceDir);
7
+ const hasDockerfile = await fileExists(join(sourceDir, "Dockerfile"));
8
+ if (hasDockerfile) {
9
+ const dockerfile = await readTextSafe(join(sourceDir, "Dockerfile"));
10
+ return {
11
+ appName,
12
+ runtime: "dockerfile",
13
+ framework: "dockerfile",
14
+ buildCommand: FROM_DOCKERFILE,
15
+ startCommand: FROM_DOCKERFILE,
16
+ port: parseExpose(dockerfile) ?? 3000,
17
+ strategy: "dockerfile",
18
+ hasDockerfile: true,
19
+ };
20
+ }
21
+ if (await fileExists(join(sourceDir, "package.json"))) {
22
+ return { ...(await detectNode(sourceDir, appName)), hasDockerfile: false };
23
+ }
24
+ if ((await fileExists(join(sourceDir, "requirements.txt"))) ||
25
+ (await fileExists(join(sourceDir, "pyproject.toml"))) ||
26
+ (await fileExists(join(sourceDir, "Pipfile")))) {
27
+ return { ...(await detectPython(sourceDir, appName)), hasDockerfile: false };
28
+ }
29
+ if (await fileExists(join(sourceDir, "go.mod"))) {
30
+ return {
31
+ appName,
32
+ runtime: "go-1.22",
33
+ framework: "go",
34
+ buildCommand: NO_BUILD,
35
+ startCommand: "/usr/local/bin/app",
36
+ port: 8080,
37
+ strategy: "template",
38
+ hasDockerfile: false,
39
+ };
40
+ }
41
+ if (await fileExists(join(sourceDir, "Cargo.toml"))) {
42
+ return {
43
+ appName,
44
+ runtime: "rust-1",
45
+ framework: "rust",
46
+ buildCommand: NO_BUILD,
47
+ startCommand: "/usr/local/bin/app",
48
+ port: 8080,
49
+ strategy: "template",
50
+ hasDockerfile: false,
51
+ };
52
+ }
53
+ if (await fileExists(join(sourceDir, "index.html"))) {
54
+ return {
55
+ appName,
56
+ runtime: "static",
57
+ framework: "static",
58
+ buildCommand: NO_BUILD,
59
+ startCommand: "nginx -g 'daemon off;'",
60
+ port: 80,
61
+ strategy: "template",
62
+ hasDockerfile: false,
63
+ };
64
+ }
65
+ return {
66
+ appName,
67
+ runtime: "unknown",
68
+ framework: "unknown",
69
+ buildCommand: NO_BUILD,
70
+ startCommand: NO_BUILD,
71
+ port: 3000,
72
+ strategy: "unknown",
73
+ hasDockerfile: false,
74
+ };
75
+ }
76
+ export async function detectRequirements(sourceDir) {
77
+ let deps = {};
78
+ try {
79
+ const pkg = JSON.parse(await readFile(join(sourceDir, "package.json"), "utf8"));
80
+ deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
81
+ }
82
+ catch {
83
+ deps = {};
84
+ }
85
+ const has = (name) => name in deps;
86
+ const prisma = await readTextSafe(join(sourceDir, "prisma", "schema.prisma"));
87
+ const prismaProvider = /provider\s*=\s*"(postgresql|mysql|sqlite|mongodb)"/i
88
+ .exec(prisma)?.[1]
89
+ ?.toLowerCase();
90
+ const envText = ((await readTextSafe(join(sourceDir, ".env.example"))) +
91
+ "\n" +
92
+ (await readTextSafe(join(sourceDir, ".env")))).toLowerCase();
93
+ const reqs = [];
94
+ const wantsPg = has("pg") ||
95
+ has("postgres") ||
96
+ prismaProvider === "postgresql" ||
97
+ /database_url\s*=\s*['"]?postgres/.test(envText);
98
+ const wantsMysql = has("mysql2") ||
99
+ has("mysql") ||
100
+ has("@planetscale/database") ||
101
+ prismaProvider === "mysql" ||
102
+ /database_url\s*=\s*['"]?mysql/.test(envText);
103
+ const wantsGenericDb = has("@prisma/client") ||
104
+ has("prisma") ||
105
+ has("drizzle-orm") ||
106
+ has("typeorm") ||
107
+ has("sequelize") ||
108
+ has("knex") ||
109
+ /\bdatabase_url\b/.test(envText);
110
+ if (wantsPg)
111
+ reqs.push({ capability: "postgres", reason: "Postgres driver/ORM detected" });
112
+ else if (wantsMysql)
113
+ reqs.push({ capability: "mysql", reason: "MySQL driver detected" });
114
+ else if (wantsGenericDb)
115
+ reqs.push({ capability: "postgres", reason: "the app reads DATABASE_URL" });
116
+ if (has("redis") || has("ioredis")) {
117
+ reqs.push({ capability: "redis", reason: "Redis client detected" });
118
+ }
119
+ return reqs;
120
+ }
121
+ const WEB_FRAMEWORKS = new Set([
122
+ "nextjs",
123
+ "nuxt",
124
+ "remix",
125
+ "astro",
126
+ "vite",
127
+ "static",
128
+ ]);
129
+ const API_FRAMEWORKS = new Set(["fastify", "hono", "express", "koa", "nestjs"]);
130
+ const COMPONENT_DIRS = ["apps", "packages", "services"];
131
+ export async function detectComponents(sourceDir) {
132
+ const candidates = await collectComponentDirs(sourceDir);
133
+ const components = [];
134
+ const seen = new Set();
135
+ for (const dir of candidates) {
136
+ if (seen.has(dir))
137
+ continue;
138
+ seen.add(dir);
139
+ const app = await detect(dir);
140
+ if (app.framework === "unknown" && dir !== sourceDir)
141
+ continue;
142
+ components.push({
143
+ name: componentName(sourceDir, dir),
144
+ relPath: relPath(sourceDir, dir),
145
+ absPath: dir,
146
+ role: roleFor(app.framework),
147
+ framework: app.framework,
148
+ });
149
+ }
150
+ if (components.length === 0) {
151
+ const app = await detect(sourceDir);
152
+ return [
153
+ {
154
+ name: "app",
155
+ relPath: "",
156
+ absPath: sourceDir,
157
+ role: roleFor(app.framework),
158
+ framework: app.framework,
159
+ },
160
+ ];
161
+ }
162
+ if (components.length === 1)
163
+ return components;
164
+ const roles = new Set(components.map((c) => c.role));
165
+ if (roles.size < 2 && !components.some((c) => c.relPath !== "")) {
166
+ return [components[0]];
167
+ }
168
+ return components;
169
+ }
170
+ async function collectComponentDirs(sourceDir) {
171
+ const out = [];
172
+ const rootPkg = await readJsonSafe(join(sourceDir, "package.json"));
173
+ const hasWorkspaces = Boolean(rootPkg?.workspaces) ||
174
+ (await fileExists(join(sourceDir, "pnpm-workspace.yaml"))) ||
175
+ (await fileExists(join(sourceDir, "turbo.json")));
176
+ if (!hasWorkspaces) {
177
+ if (rootPkg && (rootPkg.dependencies || rootPkg.scripts))
178
+ out.push(sourceDir);
179
+ return out;
180
+ }
181
+ for (const base of COMPONENT_DIRS) {
182
+ const baseDir = join(sourceDir, base);
183
+ let entries = [];
14
184
  try {
15
- const raw = await readFile(pkgPath, "utf8");
16
- pkg = JSON.parse(raw);
185
+ entries = (await readdir(baseDir, { withFileTypes: true }))
186
+ .filter((e) => e.isDirectory())
187
+ .map((e) => e.name);
17
188
  }
18
189
  catch {
19
- pkg = null;
190
+ continue;
20
191
  }
192
+ for (const name of entries) {
193
+ const dir = join(baseDir, name);
194
+ if ((await fileExists(join(dir, "package.json"))) ||
195
+ (await fileExists(join(dir, "Dockerfile"))) ||
196
+ (await fileExists(join(dir, "go.mod"))) ||
197
+ (await fileExists(join(dir, "requirements.txt")))) {
198
+ out.push(dir);
199
+ }
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+ function roleFor(framework) {
205
+ if (WEB_FRAMEWORKS.has(framework))
206
+ return "web";
207
+ if (API_FRAMEWORKS.has(framework))
208
+ return "api";
209
+ return "app";
210
+ }
211
+ export function apiUrlEnvVar(framework) {
212
+ switch (framework) {
213
+ case "nextjs":
214
+ return { key: "NEXT_PUBLIC_API_URL", buildTime: true };
215
+ case "vite":
216
+ return { key: "VITE_API_URL", buildTime: true };
217
+ case "nuxt":
218
+ return { key: "NUXT_PUBLIC_API_URL", buildTime: true };
219
+ case "astro":
220
+ return { key: "PUBLIC_API_URL", buildTime: true };
221
+ default:
222
+ return { key: "API_URL", buildTime: false };
223
+ }
224
+ }
225
+ function componentName(root, dir) {
226
+ if (dir === root)
227
+ return "root";
228
+ const raw = basename(dir).toLowerCase();
229
+ if (["frontend", "client", "site", "www", "ui"].includes(raw))
230
+ return "web";
231
+ if (["backend", "server", "service"].includes(raw))
232
+ return "api";
233
+ return raw.replace(/[^a-z0-9-]+/g, "-") || "app";
234
+ }
235
+ function relPath(root, dir) {
236
+ if (dir === root)
237
+ return "";
238
+ const r = dir.slice(root.length).replace(/^[\\/]+/, "").replace(/\\/g, "/");
239
+ return r;
240
+ }
241
+ async function readJsonSafe(path) {
242
+ try {
243
+ return JSON.parse(await readFile(path, "utf8"));
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
249
+ async function detectNode(sourceDir, appName) {
250
+ let pkg = null;
251
+ try {
252
+ pkg = JSON.parse(await readFile(join(sourceDir, "package.json"), "utf8"));
253
+ }
254
+ catch {
255
+ pkg = null;
21
256
  }
22
- const appName = pkg?.name ?? slugifyDirName(sourceDir) ?? "app";
23
257
  const deps = {
24
258
  ...(pkg?.dependencies ?? {}),
25
259
  ...(pkg?.devDependencies ?? {}),
26
260
  };
27
261
  const scripts = pkg?.scripts ?? {};
28
- const framework = pickFramework(deps);
29
- const port = portFor(framework);
30
- const buildCommand = scripts.build ? "npm run build" : DEFAULTS.buildCommand;
31
- const startCommand = scripts.start
32
- ? "npm start"
33
- : pickFallbackStart(framework);
262
+ const framework = pickNodeFramework(deps);
263
+ return {
264
+ appName: pkg?.name ?? appName,
265
+ runtime: "node-20",
266
+ framework,
267
+ buildCommand: scripts.build ? "npm run build" : NO_BUILD,
268
+ startCommand: scripts.start ? "npm start" : nodeFallbackStart(framework),
269
+ port: nodePort(framework),
270
+ strategy: "template",
271
+ };
272
+ }
273
+ async function detectPython(sourceDir, appName) {
274
+ const manifest = ((await readTextSafe(join(sourceDir, "requirements.txt"))) +
275
+ "\n" +
276
+ (await readTextSafe(join(sourceDir, "pyproject.toml"))) +
277
+ "\n" +
278
+ (await readTextSafe(join(sourceDir, "Pipfile")))).toLowerCase();
279
+ const hasManage = await fileExists(join(sourceDir, "manage.py"));
280
+ let framework = "python";
281
+ let startCommand = "python app.py";
282
+ if (hasManage || manifest.includes("django")) {
283
+ framework = "django";
284
+ startCommand = "python manage.py runserver 0.0.0.0:$PORT";
285
+ }
286
+ else if (manifest.includes("fastapi")) {
287
+ framework = "fastapi";
288
+ startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT";
289
+ }
290
+ else if (manifest.includes("flask")) {
291
+ framework = "flask";
292
+ startCommand = "gunicorn app:app -b 0.0.0.0:$PORT";
293
+ }
294
+ else if (await fileExists(join(sourceDir, "main.py"))) {
295
+ startCommand = "python main.py";
296
+ }
34
297
  return {
35
298
  appName,
36
- runtime: DEFAULTS.runtime,
299
+ runtime: "python-3.12",
37
300
  framework,
38
- buildCommand,
301
+ buildCommand: NO_BUILD,
39
302
  startCommand,
40
- port,
303
+ port: 8000,
304
+ strategy: "template",
41
305
  };
42
306
  }
43
- function pickFramework(deps) {
307
+ function pickNodeFramework(deps) {
44
308
  if ("next" in deps)
45
309
  return "nextjs";
46
310
  if ("nuxt" in deps)
@@ -61,41 +325,60 @@ function pickFramework(deps) {
61
325
  return "vite";
62
326
  return "node";
63
327
  }
64
- function portFor(framework) {
328
+ function nodePort(framework) {
65
329
  switch (framework) {
66
- case "nextjs":
67
- return 3000;
68
- case "nuxt":
69
- return 3000;
70
- case "remix":
71
- return 3000;
72
330
  case "astro":
73
331
  return 4321;
74
332
  case "vite":
75
333
  return 4173;
76
- case "fastify":
77
- return 3000;
78
- case "hono":
79
- return 3000;
80
- case "express":
81
- return 3000;
82
- case "koa":
83
- return 3000;
84
334
  default:
85
335
  return 3000;
86
336
  }
87
337
  }
88
- function pickFallbackStart(framework) {
338
+ function nodeFallbackStart(framework) {
89
339
  if (framework === "nextjs")
90
340
  return "npx next start";
91
341
  if (framework === "nuxt")
92
342
  return "node .output/server/index.mjs";
93
343
  return "node index.js";
94
344
  }
345
+ async function resolveAppName(sourceDir) {
346
+ try {
347
+ const pkg = JSON.parse(await readFile(join(sourceDir, "package.json"), "utf8"));
348
+ if (pkg.name)
349
+ return pkg.name;
350
+ }
351
+ catch {
352
+ }
353
+ const cargo = await readTextSafe(join(sourceDir, "Cargo.toml"));
354
+ const cargoName = /name\s*=\s*"([^"]+)"/.exec(cargo)?.[1];
355
+ if (cargoName)
356
+ return cargoName;
357
+ const pyproject = await readTextSafe(join(sourceDir, "pyproject.toml"));
358
+ const pyName = /name\s*=\s*"([^"]+)"/.exec(pyproject)?.[1];
359
+ if (pyName)
360
+ return pyName;
361
+ return slugifyDirName(sourceDir) ?? "app";
362
+ }
363
+ function parseExpose(dockerfile) {
364
+ const m = /^\s*EXPOSE\s+(\d{1,5})/im.exec(dockerfile);
365
+ if (!m)
366
+ return null;
367
+ const port = Number.parseInt(m[1], 10);
368
+ return port >= 1 && port <= 65535 ? port : null;
369
+ }
95
370
  function slugifyDirName(dir) {
96
371
  const name = basename(dir).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
97
372
  return name || null;
98
373
  }
374
+ async function readTextSafe(path) {
375
+ try {
376
+ return await readFile(path, "utf8");
377
+ }
378
+ catch {
379
+ return "";
380
+ }
381
+ }
99
382
  async function fileExists(path) {
100
383
  try {
101
384
  await stat(path);
@@ -105,4 +388,3 @@ async function fileExists(path) {
105
388
  return false;
106
389
  }
107
390
  }
108
- //# sourceMappingURL=detect.js.map
package/dist/index.js CHANGED
@@ -3,25 +3,53 @@ 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 { DeployInputShape, deployHandler } from "./tools/deploy.js";
6
+ import { DeployInputShape, RollbackInputShape, deployHandler, rollbackHandler, } from "./tools/deploy.js";
7
+ import { PlansInputShape, plansHandler } from "./tools/plans.js";
7
8
  import { StatusInputShape, statusHandler } from "./tools/status.js";
8
9
  import { LogsInputShape, logsHandler } from "./tools/logs.js";
9
10
  import { PingInputShape, pingHandler } from "./tools/ping.js";
11
+ import { ConnectInputShape, CreateManagedDatabaseInputShape, ListConnectionsInputShape, ProvisionInputShape, ProvisionStatusInputShape, ResolveRequirementInputShape, SetEnvInputShape, connectHandler, createManagedDatabaseHandler, listConnectionsHandler, provisionHandler, provisionStatusHandler, resolveRequirementHandler, setEnvHandler, } from "./tools/providers.js";
12
+ import { PlanInputShape, SetPreferenceInputShape, planHandler, setPreferenceHandler, } from "./tools/plan.js";
10
13
  const DeploySchema = z.object(DeployInputShape);
11
14
  const StatusSchema = z.object(StatusInputShape);
12
15
  const LogsSchema = z.object(LogsInputShape);
13
16
  const PingSchema = z.object(PingInputShape);
17
+ const ResolveRequirementSchema = z.object(ResolveRequirementInputShape);
18
+ const ConnectSchema = z.object(ConnectInputShape);
19
+ const ProvisionSchema = z.object(ProvisionInputShape);
20
+ const ProvisionStatusSchema = z.object(ProvisionStatusInputShape);
21
+ const CreateManagedDatabaseSchema = z.object(CreateManagedDatabaseInputShape);
22
+ const SetEnvSchema = z.object(SetEnvInputShape);
23
+ const PlanSchema = z.object(PlanInputShape);
24
+ const SetPreferenceSchema = z.object(SetPreferenceInputShape);
25
+ const RollbackSchema = z.object(RollbackInputShape);
26
+ const PlansSchema = z.object(PlansInputShape);
14
27
  async function main() {
15
28
  const server = new McpServer({
16
29
  name: "offlocal",
17
30
  version: "0.1.0",
18
31
  });
19
- 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 call AWS 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)));
20
- server.tool("offlocal_deploy", "Deploy an app from a local directory to Offlocal. Returns a deploymentId you can poll with offlocal_status. Detects runtime/framework/port automatically; you can override appName and port. Only call this when the user explicitly asks to deploy.", DeployInputShape, async (args) => deployHandler(DeploySchema.parse(args)));
32
+ 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
+ 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)));
34
+ server.tool("offlocal_set_preference", "Remember the user's platform choice for a capability (e.g. capability:'postgres', platform:'supabase'), so future plans default to it. Call this when the user says 'use X for Y', then re-run offlocal_plan.", SetPreferenceInputShape, async (args) => setPreferenceHandler(SetPreferenceSchema.parse(args)));
35
+ server.tool("offlocal_deploy", "Deploy an app from a local directory to Offlocal. Returns a deploymentId you can poll with offlocal_status. Supports Node, Python, Go, Rust, and static sites out of the box (auto-detecting runtime/framework/port), and uses the project's own Dockerfile as-is when one exists. For an unrecognized stack with no Dockerfile, it returns instructions asking you to add a Dockerfile and re-run - follow them, then call this again. You can override appName and port. Only call this when the user explicitly asks to deploy.", DeployInputShape, async (args) => deployHandler(DeploySchema.parse(args)));
21
36
  server.tool("offlocal_status", "Get the current status of a deployment. Poll this every few seconds after offlocal_deploy until status is 'live' or 'failed'.", StatusInputShape, async (args) => statusHandler(StatusSchema.parse(args)));
22
37
  server.tool("offlocal_logs", "Read recent container logs for a deployment. Useful for debugging a failed deploy or watching live output.", LogsInputShape, async (args) => logsHandler(LogsSchema.parse(args)));
23
- // Fire-and-forget ping so the dashboard knows the agent is connected.
24
- // Don't block startup if it fails agent will still work.
38
+ server.tool("offlocal_rollback", "Roll a project back to a previous build. Re-deploys an existing image (skips the build, ~1 min), so it's fast and safe. Defaults to the most recent prior build; pass toDeploymentId to target a specific one. Use when a new deploy is broken or the user wants the last working version back.", RollbackInputShape, async (args) => rollbackHandler(RollbackSchema.parse(args)));
39
+ server.tool("offlocal_plans", "Show the user's current Offlocal.ai plan, the full tier catalog (prices, limits, features), and upcoming features. Use it to answer plan and pricing questions, and to show upgrade options when the user needs more capacity (projects, databases, custom domains). Includes the billing link.", PlansInputShape, async () => plansHandler());
40
+ server.tool("offlocal_resolve_requirement", "When a deploy needs an external resource (a database, hosting, etc.) that the project doesn't already have, call this with the capability (e.g. 'postgres') to get the providers that can supply it and their connection status. Present the options to the user and let them choose; the user can also just paste an existing connection string as an env var instead.", ResolveRequirementInputShape, async (args) => resolveRequirementHandler(ResolveRequirementSchema.parse(args)));
41
+ server.tool("offlocal_connect", "Get a browser link for the user to connect a chosen provider via OAuth. You CANNOT complete OAuth yourself - share the link, then poll offlocal_list_connections until the provider is 'connected'. Only call this if the user chose a provider that isn't connected yet.", ConnectInputShape, async (args) => connectHandler(ConnectSchema.parse(args)));
42
+ server.tool("offlocal_list_connections", "List the user's provider connections and their status. Use this to poll after offlocal_connect (no faster than every 5 seconds) until a provider becomes 'connected'.", ListConnectionsInputShape, async () => listConnectionsHandler());
43
+ server.tool("offlocal_provision", "Provision a real resource (e.g. a database) on a CONNECTED provider. ALWAYS call first with confirm omitted/false to get a PREVIEW + cost, show the user, and get explicit confirmation — then call again with confirm:true. Pass projectId (from offlocal_deploy) so the resulting DATABASE_URL is auto-injected. May return status 'provisioning' for DigitalOcean/Railway — poll with offlocal_provision_status.", ProvisionInputShape, async (args) => provisionHandler(ProvisionSchema.parse(args)));
44
+ server.tool("offlocal_provision_status", "Poll an in-progress provision (DigitalOcean/Railway) by its actionId until it's ready. Poll no faster than once every ~20 seconds.", ProvisionStatusInputShape, async (args) => provisionStatusHandler(ProvisionStatusSchema.parse(args)));
45
+ server.tool("offlocal_create_managed_database", "Create an Offlocal Managed Postgres database for a project (the 'Offlocal' platform option for postgres). Pass the projectId from offlocal_deploy. DATABASE_URL is wired into the project automatically; redeploy to apply. Requires a Hobby+ plan.", CreateManagedDatabaseInputShape, async (args) => createManagedDatabaseHandler(CreateManagedDatabaseSchema.parse(args)));
46
+ server.tool("offlocal_set_env", "Set environment variables on a project (stored encrypted, injected on the next deploy). Use this for cross-app wiring in a monorepo — e.g. set NEXT_PUBLIC_API_URL on the web project to the API project's live URL so the frontend can reach the backend. Build-time vars (NEXT_PUBLIC_*, VITE_*) require a fresh deploy to take effect.", SetEnvInputShape, async (args) => setEnvHandler(SetEnvSchema.parse(args)));
47
+ process.on("uncaughtException", (err) => {
48
+ console.error("offlocal-mcp uncaught exception (continuing):", err);
49
+ });
50
+ process.on("unhandledRejection", (reason) => {
51
+ console.error("offlocal-mcp unhandled rejection (continuing):", reason);
52
+ });
25
53
  pingApi().catch(() => { });
26
54
  const transport = new StdioServerTransport();
27
55
  await server.connect(transport);
@@ -30,4 +58,3 @@ main().catch((err) => {
30
58
  console.error("offlocal-mcp fatal:", err);
31
59
  process.exit(1);
32
60
  });
33
- //# sourceMappingURL=index.js.map