Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,12 @@ to reach for this MCP, see <https://instanode.dev/agent.html>.
| `create_queue` | `POST /queue/new` — Provision a NATS JetStream queue (scoped subject namespace). Returns `connection_url` + `note`/`upgrade`. `name` required. |
| `create_storage` | `POST /storage/new` — Provision an S3-compatible bucket prefix (DigitalOcean Spaces). Returns endpoint, access keys, prefix + `note`/`upgrade`. `name` required. |
| `create_webhook` | `POST /webhook/new` — Provision an inbound webhook receiver URL. Returns `receive_url` + `note`/`upgrade`. `name` required. |
| `create_deploy` | `POST /deploy/new` — Upload a base64 gzip tarball (with Dockerfile) and deploy a container. Returns `deploy_id`, `status`, `url`, `build_logs_url`. `name` required. Requires `INSTANODE_TOKEN`. |
| `create_deploy` | `POST /deploy/new` — Upload a base64 gzip tarball (with Dockerfile) and deploy a container. Returns `deploy_id`, `status`, `url`, `build_logs_url`. `name` required. Pass `redeploy: true` (with the SAME `name`) to update an existing deployment IN PLACE (same app_id + URL). Requires `INSTANODE_TOKEN`. |
| `create_stack` | `POST /stacks/new` — Multi-service bundle. Upload an `instant.yaml` manifest plus one base64 gzip tarball per service; returns `stack_id`, per-service URLs, and the 24h-TTL claim block on the anonymous tier. **Anonymous-friendly** (the wedge). `name`, `manifest`, `service_tarballs` required. |
| `get_stack` | `GET /stacks/{stack_id}` — Poll a stack's per-service status + URLs. Anonymous-friendly. `stack_id` required. |
| `list_deployments`| `GET /api/v1/deployments` — List all deployments on the caller's team. Requires `INSTANODE_TOKEN`. |
| `get_deployment` | `GET /api/v1/deployments/:id` — Fetch one deployment (poll until `status="running"`). Requires `INSTANODE_TOKEN`. |
| `redeploy` | `POST /deploy/:id/redeploy` — Rebuild + rolling update an existing deployment. Requires `INSTANODE_TOKEN`. |
| `redeploy` | `POST /deploy/:id/redeploy` — Push updated code to an existing deployment BY ID. Same URL, new build. Requires `tarball_base64` (same shape as `create_deploy`) — the api never reuses the original tarball. For the more common "update by name" path prefer `create_deploy({ name, redeploy: true, tarball_base64 })`. Requires `INSTANODE_TOKEN`. |
| `delete_deployment` | `DELETE /deploy/:id` — Tear down a running deployment. Irreversible. Requires `INSTANODE_TOKEN`. |
| `claim_resource` | Helper — turn an `upgrade_jwt` from any `create_*` response into the dashboard claim URL the user should click. No API call. No auth required. |
| `claim_token` | `POST /claim` — Programmatic claim: attach an anonymous resource to the authenticated account using its `upgrade_jwt` + `email`. No auth required. |
Expand Down Expand Up @@ -186,6 +186,35 @@ params, which the agent host may log.
`get_deployment({ id: deploy_id })` every few seconds until status flips to
`"running"` (typical: ~30s). At that point the `url` field is the live URL.

### Updating an existing deployment (same URL, new build)

To ship v2 of an app you already deployed without changing the URL or
`app_id`, call `create_deploy` again with the **same `name`** plus
`redeploy: true`:

```json
{
"tarball_base64": "...",
"name": "my-app",
"redeploy": true
}
```

The api finds the existing deployment by `(team_id, name)` and updates it
in place — same `app_id`, same `*.deployment.instanode.dev` URL, status
flips back to `building` while the new image rolls out.

Without `redeploy: true`, calling `create_deploy` with a name you've used
before mints a **new** `app_id` and a **new** URL (the legacy behaviour).
This is the trap that caused the AGENT-UX issue where agents ended up
with two live deployments + two URLs for the same app.

The standalone `redeploy` tool (by `id`, not `name`) still works and also
requires a `tarball_base64` — the api never reuses the original tarball.
Prefer the `create_deploy({ name, redeploy: true })` path when you have
the name; use `redeploy({ id, tarball_base64 })` when you only have the
deploy id.

### Private deploys

Set `private: true` and pass `allowed_ips` to restrict access to specific IPs
Expand Down
71 changes: 61 additions & 10 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
* Response shape from POST /deploy/:id/redeploy.
*
* The live API documents this as a bare 202 with NO body (see openapi.json),
* not a deployment record. The previous client mis-typed it as DeployGetResult

Check warning on line 263 in src/client.ts

View workflow job for this annotation

GitHub Actions / typos

"mis" should be "miss" or "mist".
* and the index.ts handler dereferenced `result.item.app_id`, blowing up
* with "Cannot read properties of undefined (reading 'app_id')" on every
* real call. BugBash B16 F1 (regression of task #170): use a body-less type
Expand Down Expand Up @@ -314,6 +314,21 @@
* shape (e.g. `allowed_cidrs`), reconcile post-merge.
*/
allowed_ips?: string[];
/**
* In-place redeploy flag (api PR feat/deploy-new-redeploy-in-place).
* When true AND `name` matches an existing deployment on the caller's team,
* the api updates that deployment IN PLACE (same app_id, same URL) instead
* of minting a fresh one. Default false → preserves the legacy "always mint
* a new app_id" behaviour. This closes the AGENT-UX gap where an agent
* shipping v2 of an existing app ended up with two live URLs.
*
* Forward compatibility: when sent against an api that doesn't yet
* understand the field, the multipart form value is silently ignored by
* Fiber's MultipartForm parser → behaves like the legacy path. Safe to
* ship from MCP before the api PR lands; the user only sees in-place
* redeploy behaviour once the api side is in prod.
*/
redeploy?: boolean;
}

/**
Expand Down Expand Up @@ -956,6 +971,15 @@
if (params.allowed_ips && params.allowed_ips.length > 0) {
form.append("allowed_ips", JSON.stringify(params.allowed_ips));
}
// Redeploy-in-place opt-in (api PR feat/deploy-new-redeploy-in-place).
// Only forward when explicitly true — omitting the field keeps the api
// on the legacy "mint a new app_id" path, preserving existing behaviour
// for every caller that hasn't asked for in-place. Sending "false"
// would also work server-side, but omitting it makes the wire trace
// identical to pre-fix MCP versions for unaffected callers.
if (params.redeploy === true) {
form.append("redeploy", "true");
}

// Merge resource_bindings into env_vars. The api treats every value
// either as plaintext, a vault://env/KEY ref, or — for deploy bindings —
Expand Down Expand Up @@ -1083,20 +1107,47 @@
/**
* POST /deploy/:id/redeploy — rebuild + rolling update an existing app.
*
* The live API returns a bare 202 with no body (see openapi.json). Earlier
* versions of this client typed the response as DeployGetResult and the
* tool handler dereferenced `result.item.app_id`, throwing
* "Cannot read properties of undefined (reading 'app_id')" on every real
* call. BugBash B16 F1 (regression of task #170): the empty-body now
* resolves to `{ok: true}` via the request<T>() empty-2xx sentinel; this
* The api handler REQUIRES a fresh tarball multipart file part
* (deploy.go:1245 `missing_tarball`); there is no tarball reuse anywhere
* server-side. The previous bodyless version of this method always 400'd
* with "Multipart field 'tarball' is required" — see AGENT-UX.md Path B.
*
* `tarball_base64` is the same shape `createDeploy()` accepts: base64-
* encoded gzip tar (Dockerfile + source), capped at 50 MiB after decode.
* The 50 MiB ceiling is enforced client-side BEFORE the upload so an
* oversized payload fails fast with a clear error instead of round-
* tripping multiple MB of base64 to the api.
*
* The live api returns a bare 202 with no body (see openapi.json). The
* request<T>() empty-2xx sentinel resolves it to `{ok: true}`; this
* helper layers the caller-supplied id on top so the tool handler has a
* stable surface to read.
*/
async redeploy(id: string): Promise<RedeployResult> {
const raw = await this.request<RedeployResult>(
"POST",
async redeploy(id: string, tarballBase64: string): Promise<RedeployResult> {
const form = new FormData();

const tarball = Buffer.from(tarballBase64, "base64");

// Mirror the createDeploy guard — fail BEFORE opening a multipart
// connection on an oversized payload. The api enforces 50 MiB
// (deploy.go:1249 tarball_too_large); pre-empting it here surfaces a
// precise error and avoids bandwidth burn.
if (tarball.byteLength > MAX_TARBALL_BYTES) {
throw new Error(
`Tarball is too large: ${tarball.byteLength.toLocaleString()} bytes ` +
`(decoded). The api accepts at most ${MAX_TARBALL_BYTES.toLocaleString()} ` +
`bytes (50 MiB). Shrink the tarball: include only what \`docker build\` ` +
`needs — exclude node_modules, .git, build artifacts, large media files. ` +
`Add a .dockerignore to your project root.`
);
}

const blob = new Blob([tarball], { type: "application/gzip" });
form.append("tarball", blob, "app.tar.gz");

const raw = await this.requestMultipart<RedeployResult>(
`/deploy/${encodeURIComponent(id)}/redeploy`,
undefined,
form,
{ requireAuth: true }
);
return {
Expand Down
69 changes: 59 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
* create_storage — provision an S3-compatible object storage bucket prefix
* create_webhook — provision an inbound webhook receiver URL
* create_deploy — upload a base64 gzip tarball (Dockerfile + source) and
* deploy a container; returns a public URL in ~30s
* deploy a container; returns a public URL in ~30s.
* Pass `redeploy: true` with the same name to update an
* existing deployment IN PLACE (same app_id + URL).
*
* claim_resource — turn an anonymous upgrade JWT into the dashboard claim URL
* the agent should direct the user to (no API call — pure helper)
Expand All @@ -25,7 +27,10 @@
*
* list_deployments — list all deployments for the caller's team
* get_deployment — fetch a deployment by app id (for polling build status)
* redeploy — trigger a rebuild + rolling update of an existing app
* redeploy — push updated code to an existing deployment by id;
* requires a fresh tarball (api never reuses the original).
* Prefer `create_deploy({name, redeploy:true})` when you
* have the name; use this when you only have the deploy id.
* delete_deployment — tear down a running deployment
*
* Every create_* tool surfaces the API's `note` and `upgrade` fields so the
Expand Down Expand Up @@ -940,7 +945,7 @@ agent can route the user to the dashboard instead of guessing.`,

server.tool(
"create_deploy",
`Create a new deploy. Optionally set \`private: true\` + \`allowed_ips: ['1.2.3.4', '10.0.0.0/8']\` to restrict access to specific IPs. Requires Pro tier or higher. Useful when an agent is asked to deploy a CRM, internal dashboard, or staging app that should only be reachable by the user.
`Create a new deploy — OR set \`redeploy: true\` to update an existing deployment with the same name (preserves app_id + URL). Optionally set \`private: true\` + \`allowed_ips: ['1.2.3.4', '10.0.0.0/8']\` to restrict access to specific IPs. Requires Pro tier or higher. Useful when an agent is asked to deploy a CRM, internal dashboard, or staging app that should only be reachable by the user.

Deploys a containerized application on instanode.dev (POST /deploy/new).

Expand All @@ -950,6 +955,13 @@ deploys + returns a public URL in ~30s. Build is asynchronous: the initial
response carries status="building"; poll 'get_deployment' with the returned
'deploy_id' until status becomes "running" or "failed".

In-place update (redeploy:true): when you ship v2 of an existing app, pass
the SAME 'name' plus 'redeploy: true'. The api updates that deployment in
place — same app_id, same *.deployment.instanode.dev URL — instead of
minting a fresh one. Default behaviour (redeploy omitted or false) always
creates a new deployment and a new URL. This closes the AGENT-UX trap where
shipping v2 with the same name left two live deployments + two URLs.

Tarball construction (agent side, runtime depends on language):
tar = subprocess.check_output(["tar", "czf", "-", "-C", project_dir, "."])
tarball_base64 = base64.b64encode(tar).decode()
Expand Down Expand Up @@ -1066,6 +1078,20 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`,
.describe(
"IP / CIDR allowlist enforced at the Ingress when 'private' is true. Examples: ['1.2.3.4', '10.0.0.0/8', '203.0.113.42/32']. Required when private=true; ignored otherwise. Max 256 entries; each must parse as IPv4/IPv6 address or CIDR."
),
// In-place redeploy opt-in (api PR feat/deploy-new-redeploy-in-place).
// Sent to the api as a multipart form field — when true, the api looks
// up an existing deployment by (team_id, name) and updates it in place
// (same app_id, same URL) instead of minting a fresh one. Default false
// preserves the existing "always create a new deployment" behaviour.
// Note: the api PR must be in prod before this flag does anything; on
// an older api the field is silently ignored by Fiber's form parser
// (caller sees legacy behaviour, no error).
redeploy: z
.boolean()
.optional()
.describe(
"Set true to update an existing deployment with the same name (preserves app_id + URL). Default false → creates a new deployment with a fresh app_id and URL. Use redeploy:true when shipping a new version of an app you've already deployed."
),
},
// BUG-MCP-021: enforce the documented private+allowed_ips coupling
// client-side. The API rejects (private=true, allowed_ips=[]) with a 400,
Expand Down Expand Up @@ -1442,28 +1468,51 @@ Requires INSTANODE_TOKEN.`,

server.tool(
"redeploy",
`Trigger a rebuild + rolling update of an existing deployment
(POST /deploy/:id/redeploy). Useful after updating env vars via the
dashboard, rotating a vault secret, or when the underlying image needs
a refresh. The tarball from the original deploy is reused.
`Push updated code to an existing deployment by app id. Same URL, new build
(POST /deploy/:id/redeploy).

Use this when you already know the deploy_id and want to ship a code change
without touching the URL or app_id. For the more common "I have the name, I
want to update the app I just shipped" path, prefer
create_deploy({ name, tarball_base64, redeploy: true }) — that resolves the
deployment by name and is the AGENT-UX-recommended path.

The api REQUIRES a fresh tarball — there is no server-side tarball reuse
(the earlier tool description claiming reuse was wrong and caused every
real call to fail with 400 missing_tarball). Pass a base64-encoded gzip
tar of the project (Dockerfile + source), same shape as create_deploy.

Status flips back to "building"; poll get_deployment until it returns
to "running".
to "running" (~30s typical).

Requires INSTANODE_TOKEN.`,
{
// BUG-MCP-025: validate UUID client-side.
id: uuidSchema.describe("Deployment app id (returned as 'deploy_id' by create_deploy)."),
// T-redeploy-fix: tarball is required. The api handler at
// deploy.go:1245 returns 400 missing_tarball without it; the previous
// tool schema omitted this field and the description lied about
// tarball reuse, making every real call 400.
tarball_base64: z
.string()
.min(1)
.max(
70 * 1024 * 1024,
"tarball_base64: encoded payload exceeds 70 MiB (≈50 MiB decoded). Shrink the tarball — strip .git, node_modules, build artifacts."
)
.describe(
"Base64-encoded gzip tarball of the project directory (must include a Dockerfile at the root). <50 MB after decode (≈70 MiB encoded). Same shape as create_deploy.tarball_base64."
),
},
async ({ id }) => {
async ({ id, tarball_base64 }) => {
try {
// BugBash B16 F1 (regression of task #170): /deploy/:id/redeploy returns
// a bare 202 with no body — the previous handler dereferenced
// result.item.app_id and crashed with "Cannot read properties of
// undefined (reading 'app_id')". client.redeploy() now resolves to
// {ok, id, status, message} with safe fallbacks so the handler stays
// alive even when the body is empty.
const result = await client.redeploy(id);
const result = await client.redeploy(id, tarball_base64);
const appId = result.id ?? id;
const lines = [
`Redeploy accepted for ${appId}.`,
Expand Down
Loading
Loading