@offlocal/mcp 0.2.0 → 0.3.1

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
@@ -157,6 +157,26 @@ export async function setProjectEnv(projectId, vars) {
157
157
  return { error: json.error ?? String(res.status), message: json.message };
158
158
  return json;
159
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
+ }
160
180
  async function failureText(res, kind) {
161
181
  const body = await res.text().catch(() => "");
162
182
  return `${kind} failed: ${res.status} ${res.statusText}${body ? ` - ${body.slice(0, 400)}` : ""}`;
package/dist/detect.js CHANGED
@@ -313,6 +313,8 @@ function pickNodeFramework(deps) {
313
313
  return "remix";
314
314
  if ("astro" in deps)
315
315
  return "astro";
316
+ if ("vite" in deps)
317
+ return "vite";
316
318
  if ("fastify" in deps)
317
319
  return "fastify";
318
320
  if ("hono" in deps)
@@ -321,8 +323,6 @@ function pickNodeFramework(deps) {
321
323
  return "express";
322
324
  if ("koa" in deps)
323
325
  return "koa";
324
- if ("vite" in deps)
325
- return "vite";
326
326
  return "node";
327
327
  }
328
328
  function nodePort(framework) {
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@ 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";
@@ -21,6 +22,8 @@ const CreateManagedDatabaseSchema = z.object(CreateManagedDatabaseInputShape);
21
22
  const SetEnvSchema = z.object(SetEnvInputShape);
22
23
  const PlanSchema = z.object(PlanInputShape);
23
24
  const SetPreferenceSchema = z.object(SetPreferenceInputShape);
25
+ const RollbackSchema = z.object(RollbackInputShape);
26
+ const PlansSchema = z.object(PlansInputShape);
24
27
  async function main() {
25
28
  const server = new McpServer({
26
29
  name: "offlocal",
@@ -32,6 +35,8 @@ async function main() {
32
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)));
33
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)));
34
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)));
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());
35
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)));
36
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)));
37
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());
@@ -39,6 +44,12 @@ async function main() {
39
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)));
40
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)));
41
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
+ });
42
53
  pingApi().catch(() => { });
43
54
  const transport = new StdioServerTransport();
44
55
  await server.connect(transport);
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { createDeployment, markUploadComplete, uploadBundle, } from "../client.js";
2
+ import { createDeployment, markUploadComplete, rollback, uploadBundle, } from "../client.js";
3
3
  import { detect } from "../detect.js";
4
- import { ICON } from "../ui.js";
4
+ import { ICON, ui } from "../ui.js";
5
5
  import { bundleDirectory } from "../zip.js";
6
6
  export const DeployInputShape = {
7
7
  sourceDir: z
@@ -43,10 +43,13 @@ export async function deployHandler(input) {
43
43
  const buildSource = detected.strategy === "dockerfile"
44
44
  ? "your Dockerfile"
45
45
  : `auto-generated Dockerfile (${detected.framework})`;
46
+ const slug = created.slug ?? appName;
47
+ const expectedUrl = created.expectedUrl ?? `https://${slug}.offlocal.ai`;
46
48
  const summary = {
47
49
  deploymentId: created.deploymentId,
48
50
  projectId: created.projectId,
49
51
  appName,
52
+ slug,
50
53
  framework: detected.framework,
51
54
  buildSource,
52
55
  port,
@@ -55,7 +58,7 @@ export async function deployHandler(input) {
55
58
  bytes: bundle.byteSize,
56
59
  },
57
60
  status: "uploaded",
58
- expectedUrl: `https://${appName}.offlocal.ai`,
61
+ expectedUrl,
59
62
  pollAfterSeconds: 45,
60
63
  expectedTotalSeconds: 180,
61
64
  };
@@ -69,19 +72,51 @@ export async function deployHandler(input) {
69
72
  `| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
70
73
  `| Port | ${summary.port} |\n` +
71
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
+ : "") +
72
78
  `———\n` +
73
- `${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–4 min) and you'll ` +
79
+ `${ICON.arrow} **Next (you, the agent):** tell the user it's building (usually 2–8 min) and you'll ` +
74
80
  `report back when it's live. Then:\n` +
75
81
  ` 1. Sleep 45 seconds.\n` +
76
82
  ` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
77
83
  ` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
78
- ` 4. Repeat until \`live\` or \`failed\`. Polling faster does NOT speed up the build.\n` +
84
+ ` 4. Repeat until \`live\` or \`failed\`. **Slow is normal don't panic or declare failure on a slow deploy.** ` +
85
+ `The status tool tells you if it's genuinely stuck; only then should you stop and suggest a retry.\n` +
79
86
  `Keep the **projectId** from the live status for any database step.`,
80
87
  },
81
88
  ],
82
89
  structuredContent: summary,
83
90
  };
84
91
  }
92
+ export const RollbackInputShape = {
93
+ projectId: z
94
+ .string()
95
+ .min(1)
96
+ .describe("The Offlocal projectId to roll back (from offlocal_deploy / offlocal_status)."),
97
+ toDeploymentId: z
98
+ .string()
99
+ .optional()
100
+ .describe("Optional: a specific previous deploymentId to roll back to. Omit to roll back to the most recent prior build."),
101
+ };
102
+ export async function rollbackHandler(input) {
103
+ const res = await rollback(input.projectId, input.toDeploymentId);
104
+ if (res.error || !res.deployment) {
105
+ const hint = res.error === "no_rollback_target" || res.error === "deployment_not_found"
106
+ ? " There's no earlier build with an image to roll back to (this project may only have one successful deploy)."
107
+ : "";
108
+ return ui({
109
+ show: `${ICON.fail} Rollback failed: ${res.message ?? res.error}.${hint}`,
110
+ structured: res,
111
+ });
112
+ }
113
+ const d = res.deployment;
114
+ return ui({
115
+ show: `${ICON.spark} **Rolling back** to the build from \`${res.rolledBackTo}\` — re-deploying that image (skips the build, ~1 min).`,
116
+ next: `Poll \`offlocal_status({ deploymentId: "${d.id}" })\` until it's \`live\`. It reuses the previous image, so there's no rebuild.`,
117
+ structured: { deployment: d, rolledBackTo: res.rolledBackTo ?? null },
118
+ });
119
+ }
85
120
  function needsDockerfile(sourceDir, port) {
86
121
  const summary = {
87
122
  status: "needs_dockerfile",
@@ -116,6 +116,9 @@ export async function planHandler(input) {
116
116
  (requirements.length
117
117
  ? ` + ${requirements.map((r) => r.reason).join(", ")}`
118
118
  : "");
119
+ const hasDatastore = requirements.some((r) => r.capability === "postgres" || r.capability === "mysql");
120
+ const hasServerComponent = components.some((c) => c.role !== "web");
121
+ const noteEphemeral = !hasDatastore && hasServerComponent;
119
122
  const show = `${ICON.search} **Offlocal.ai — deployment plan**\n\n` +
120
123
  `**Detected:** ${detectedLine}\n\n` +
121
124
  `**Plan**\n${planLines.join("\n")}\n` +
@@ -123,9 +126,13 @@ export async function planHandler(input) {
123
126
  `\n**Setup checklist** ${progressBar(0, checklist.length)}\n${checklistLines.join("\n")}\n\n` +
124
127
  `Reply **"go"** to ship it ${ICON.rocket} — or switch any line ` +
125
128
  `(e.g. _"use Supabase for the database"_, _"host the API on Railway"_).`;
129
+ const persistenceCheck = noteEphemeral
130
+ ? `\n\nNote: this host's storage is ephemeral. Mention data persistence only if the user asks about keeping data between deploys — a database covers it.`
131
+ : "";
126
132
  const agentNote = `When the user switches a line, call \`offlocal_set_preference\` ` +
127
- `(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan\`.\n\n` +
128
- `**Execution order once they confirm — walk the checklist top to bottom, ` +
133
+ `(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan\`.` +
134
+ persistenceCheck +
135
+ `\n\n**Execution order once they confirm — walk the checklist top to bottom, ` +
129
136
  `re-showing it with ${ICON.done} on finished steps so they see live progress:**\n` +
130
137
  executionSteps(items, links, dbTarget).join("\n");
131
138
  return ui({
@@ -0,0 +1,25 @@
1
+ import { getPlanInfo } from "../client.js";
2
+ import { ICON, ui } from "../ui.js";
3
+ export const PlansInputShape = {};
4
+ export async function plansHandler() {
5
+ const info = await getPlanInfo();
6
+ const tierLines = info.plans.map((p) => {
7
+ const isCurrent = p.plan === info.plan;
8
+ const head = isCurrent
9
+ ? `${ICON.connected} **${p.label} — ${p.priceLabel}** _(your plan)_`
10
+ : `${ICON.open} **${p.label} — ${p.priceLabel}**`;
11
+ const projects = `${p.projects} project${p.projects === 1 ? "" : "s"}`;
12
+ return `${head}\n ${projects} · ${p.features.slice(0, 4).join(" · ")}`;
13
+ });
14
+ const upcoming = info.upcoming.length
15
+ ? `\n\n${ICON.soon} **On the roadmap:** ${info.upcoming.join(" · ")}`
16
+ : "";
17
+ return ui({
18
+ show: `${ICON.spark} **Offlocal.ai plans**\n\n` +
19
+ `You're on **${info.planLabel}** — using ${info.usage.projects}/${info.limits.projects} projects, ${info.limits.managedDbs} managed database${info.limits.managedDbs === 1 ? "" : "s"} included.\n\n` +
20
+ tierLines.join("\n\n") +
21
+ upcoming,
22
+ next: `Use this to answer the user's plan and pricing questions. If they need more capacity (projects, databases, custom domains), share ${info.billingUrl} where they can change plans.`,
23
+ structured: { ...info },
24
+ });
25
+ }
@@ -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,
@@ -213,10 +221,10 @@ export async function setEnvHandler(input) {
213
221
  export async function createManagedDatabaseHandler(input) {
214
222
  const res = await createManagedDatabase(input.projectId);
215
223
  if (res.error) {
216
- const upsell = res.error === "plan_not_allowed" || res.error === "limit_reached"
217
- ? " Tell the user this needs a Hobby+ plan or they've hit their database limit — they can upgrade at /billing."
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."
218
226
  : "";
219
- return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${upsell}`, res);
227
+ return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${planHint}`, res);
220
228
  }
221
229
  return text(`${ICON.db} **Offlocal Managed Postgres created** (${res.database?.status}). ` +
222
230
  `${ICON.spark} DATABASE_URL is wired into this project automatically — **redeploy** to apply.`, res);
@@ -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,
@@ -61,9 +66,19 @@ export async function statusHandler(input) {
61
66
  lines.push("");
62
67
  lines.push(bar);
63
68
  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.`);
69
+ if (stalled) {
70
+ lines.push(`${ICON.warn} It's been at "${phase}" for ~${Math.round(secondsSinceUpdate / 60)} min with no change — ` +
71
+ `longer than a normal deploy. It may be stuck.\n` +
72
+ `Tell the user it's taking unusually long. You can wait a little longer, but do NOT keep polling indefinitely — ` +
73
+ `if it doesn't move within another couple of minutes, suggest re-running \`offlocal_deploy\` (a retry usually clears a stuck deploy).`);
74
+ }
75
+ else {
76
+ 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` +
77
+ `• A deploy is only failed when the status is literally **failed** — nothing else means it broke.\n` +
78
+ `• "starting container" can mean the container is already healthy but not yet promoted; it'll flip to **live** shortly.\n` +
79
+ `• Empty \`offlocal_logs\` during build / before live is expected (container logs only exist once it's running) — not an error.\n` +
80
+ `Sleep ${pollAfterSeconds}s, then call this exactly once. Polling faster does not help.`);
81
+ }
67
82
  }
68
83
  return {
69
84
  content: [{ type: "text", text: lines.join("\n") }],
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.2.0",
3
+ "version": "0.3.1",
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",