@offlocal/mcp 0.2.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/dist/client.js +20 -0
- package/dist/index.js +12 -1
- package/dist/tools/deploy.js +34 -3
- package/dist/tools/plan.js +9 -2
- package/dist/tools/plans.js +25 -0
- package/dist/tools/providers.js +3 -3
- package/package.json +1 -1
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/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);
|
package/dist/tools/deploy.js
CHANGED
|
@@ -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
|
|
61
|
+
expectedUrl,
|
|
59
62
|
pollAfterSeconds: 45,
|
|
60
63
|
expectedTotalSeconds: 180,
|
|
61
64
|
};
|
|
@@ -82,6 +85,34 @@ export async function deployHandler(input) {
|
|
|
82
85
|
structuredContent: summary,
|
|
83
86
|
};
|
|
84
87
|
}
|
|
88
|
+
export const RollbackInputShape = {
|
|
89
|
+
projectId: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(1)
|
|
92
|
+
.describe("The Offlocal projectId to roll back (from offlocal_deploy / offlocal_status)."),
|
|
93
|
+
toDeploymentId: z
|
|
94
|
+
.string()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("Optional: a specific previous deploymentId to roll back to. Omit to roll back to the most recent prior build."),
|
|
97
|
+
};
|
|
98
|
+
export async function rollbackHandler(input) {
|
|
99
|
+
const res = await rollback(input.projectId, input.toDeploymentId);
|
|
100
|
+
if (res.error || !res.deployment) {
|
|
101
|
+
const hint = res.error === "no_rollback_target" || res.error === "deployment_not_found"
|
|
102
|
+
? " There's no earlier build with an image to roll back to (this project may only have one successful deploy)."
|
|
103
|
+
: "";
|
|
104
|
+
return ui({
|
|
105
|
+
show: `${ICON.fail} Rollback failed: ${res.message ?? res.error}.${hint}`,
|
|
106
|
+
structured: res,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const d = res.deployment;
|
|
110
|
+
return ui({
|
|
111
|
+
show: `${ICON.spark} **Rolling back** to the build from \`${res.rolledBackTo}\` — re-deploying that image (skips the build, ~1 min).`,
|
|
112
|
+
next: `Poll \`offlocal_status({ deploymentId: "${d.id}" })\` until it's \`live\`. It reuses the previous image, so there's no rebuild.`,
|
|
113
|
+
structured: { deployment: d, rolledBackTo: res.rolledBackTo ?? null },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
85
116
|
function needsDockerfile(sourceDir, port) {
|
|
86
117
|
const summary = {
|
|
87
118
|
status: "needs_dockerfile",
|
package/dist/tools/plan.js
CHANGED
|
@@ -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
|
|
128
|
-
|
|
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
|
+
}
|
package/dist/tools/providers.js
CHANGED
|
@@ -213,10 +213,10 @@ export async function setEnvHandler(input) {
|
|
|
213
213
|
export async function createManagedDatabaseHandler(input) {
|
|
214
214
|
const res = await createManagedDatabase(input.projectId);
|
|
215
215
|
if (res.error) {
|
|
216
|
-
const
|
|
217
|
-
? "
|
|
216
|
+
const planHint = res.error === "plan_not_allowed" || res.error === "limit_reached"
|
|
217
|
+
? " A Hobby plan or higher is required, or the database limit was reached — plans can be changed at /billing."
|
|
218
218
|
: "";
|
|
219
|
-
return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${
|
|
219
|
+
return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${planHint}`, res);
|
|
220
220
|
}
|
|
221
221
|
return text(`${ICON.db} **Offlocal Managed Postgres created** (${res.database?.status}). ` +
|
|
222
222
|
`${ICON.spark} DATABASE_URL is wired into this project automatically — **redeploy** to apply.`, res);
|
package/package.json
CHANGED