Skip to content

Commit 4423a61

Browse files
fix(mcp): redeploy in-place — create_deploy {redeploy:true} + tarball on redeploy tool (#34)
Closes the AGENT-UX trap (shared/AGENT-UX.md) where shipping v2 of an app via the MCP left two live deployments + two URLs, and the standalone `redeploy` tool was 100% broken (sent no tarball, api always 400'd missing_tarball, tool description falsely claimed "the tarball from the original deploy is reused"). create_deploy: new optional `redeploy: boolean` (default false). When true, the client appends `redeploy=true` to the multipart form on POST /deploy/new — the api (PR feat/deploy-new-redeploy-in-place) then updates the existing deployment in place (same app_id, same URL) instead of minting a fresh one. Omitting the flag preserves legacy "always new" behaviour byte-for-byte on the wire. Forward-compatible: on an api that hasn't yet shipped the field, Fiber's MultipartForm parser silently ignores it, so callers see the legacy path with no error. redeploy (standalone tool): now requires `tarball_base64` (same shape as create_deploy). client.redeploy(id, tarball) POSTs multipart to /deploy/:id/redeploy with a tarball file part — matches deploy.go:1245 which already required it. Tool description rewritten to stop lying about tarball reuse and to steer agents toward the create_deploy({name, redeploy:true}) path when they have the name. Breaking change for the standalone redeploy tool (added required tarball param), but the tool was 100% broken before so no real callers depended on it. Coverage block (rule 17): Symptom: AGENT-UX path B: redeploy({id}) → 400 missing_tarball AGENT-UX path A: create_deploy(same name) → 2 URLs Enumeration: rg -F 'redeploy' src/ test/ + rg -n 'createDeploy|create_deploy' Sites found: src/index.ts (redeploy tool, create_deploy tool, file docstring), src/client.ts (redeploy method, createDeploy method, CreateDeployParams interface), 4 tests in tools-unit, 3 tests in client-unit, 3 tests in integration, mock-api /deploy/:id/redeploy + /deploy/new handlers, README.md. Sites touched: all listed sites (no skips). Coverage test: redeploy-in-place tool handlers suite — iterates the create_deploy {redeploy:true vs default} contract and asserts the standalone redeploy tool sends a multipart POST with a tarball part. Live verified: awaiting api PR feat/deploy-new-redeploy-in-place in prod (rule 14 build-SHA check) before MCP merge — see PR body. Tests: 373/373 pass. Coverage: client.js 100% line / 95.33% branch, index.js 99.79% line / 96.53% branch. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 96eb228 commit 4423a61

7 files changed

Lines changed: 559 additions & 39 deletions

File tree

README.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,12 @@ to reach for this MCP, see <https://instanode.dev/agent.html>.
119119
| `create_queue` | `POST /queue/new` — Provision a NATS JetStream queue (scoped subject namespace). Returns `connection_url` + `note`/`upgrade`. `name` required. |
120120
| `create_storage` | `POST /storage/new` — Provision an S3-compatible bucket prefix (DigitalOcean Spaces). Returns endpoint, access keys, prefix + `note`/`upgrade`. `name` required. |
121121
| `create_webhook` | `POST /webhook/new` — Provision an inbound webhook receiver URL. Returns `receive_url` + `note`/`upgrade`. `name` required. |
122-
| `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`. |
122+
| `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`. |
123123
| `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. |
124124
| `get_stack` | `GET /stacks/{stack_id}` — Poll a stack's per-service status + URLs. Anonymous-friendly. `stack_id` required. |
125125
| `list_deployments`| `GET /api/v1/deployments` — List all deployments on the caller's team. Requires `INSTANODE_TOKEN`. |
126126
| `get_deployment` | `GET /api/v1/deployments/:id` — Fetch one deployment (poll until `status="running"`). Requires `INSTANODE_TOKEN`. |
127-
| `redeploy` | `POST /deploy/:id/redeploy` — Rebuild + rolling update an existing deployment. Requires `INSTANODE_TOKEN`. |
127+
| `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`. |
128128
| `delete_deployment` | `DELETE /deploy/:id` — Tear down a running deployment. Irreversible. Requires `INSTANODE_TOKEN`. |
129129
| `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. |
130130
| `claim_token` | `POST /claim` — Programmatic claim: attach an anonymous resource to the authenticated account using its `upgrade_jwt` + `email`. No auth required. |
@@ -186,6 +186,35 @@ params, which the agent host may log.
186186
`get_deployment({ id: deploy_id })` every few seconds until status flips to
187187
`"running"` (typical: ~30s). At that point the `url` field is the live URL.
188188

189+
### Updating an existing deployment (same URL, new build)
190+
191+
To ship v2 of an app you already deployed without changing the URL or
192+
`app_id`, call `create_deploy` again with the **same `name`** plus
193+
`redeploy: true`:
194+
195+
```json
196+
{
197+
"tarball_base64": "...",
198+
"name": "my-app",
199+
"redeploy": true
200+
}
201+
```
202+
203+
The api finds the existing deployment by `(team_id, name)` and updates it
204+
in place — same `app_id`, same `*.deployment.instanode.dev` URL, status
205+
flips back to `building` while the new image rolls out.
206+
207+
Without `redeploy: true`, calling `create_deploy` with a name you've used
208+
before mints a **new** `app_id` and a **new** URL (the legacy behaviour).
209+
This is the trap that caused the AGENT-UX issue where agents ended up
210+
with two live deployments + two URLs for the same app.
211+
212+
The standalone `redeploy` tool (by `id`, not `name`) still works and also
213+
requires a `tarball_base64` — the api never reuses the original tarball.
214+
Prefer the `create_deploy({ name, redeploy: true })` path when you have
215+
the name; use `redeploy({ id, tarball_base64 })` when you only have the
216+
deploy id.
217+
189218
### Private deploys
190219

191220
Set `private: true` and pass `allowed_ips` to restrict access to specific IPs

src/client.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,21 @@ export interface CreateDeployParams {
314314
* shape (e.g. `allowed_cidrs`), reconcile post-merge.
315315
*/
316316
allowed_ips?: string[];
317+
/**
318+
* In-place redeploy flag (api PR feat/deploy-new-redeploy-in-place).
319+
* When true AND `name` matches an existing deployment on the caller's team,
320+
* the api updates that deployment IN PLACE (same app_id, same URL) instead
321+
* of minting a fresh one. Default false → preserves the legacy "always mint
322+
* a new app_id" behaviour. This closes the AGENT-UX gap where an agent
323+
* shipping v2 of an existing app ended up with two live URLs.
324+
*
325+
* Forward compatibility: when sent against an api that doesn't yet
326+
* understand the field, the multipart form value is silently ignored by
327+
* Fiber's MultipartForm parser → behaves like the legacy path. Safe to
328+
* ship from MCP before the api PR lands; the user only sees in-place
329+
* redeploy behaviour once the api side is in prod.
330+
*/
331+
redeploy?: boolean;
317332
}
318333

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

960984
// Merge resource_bindings into env_vars. The api treats every value
961985
// either as plaintext, a vault://env/KEY ref, or — for deploy bindings —
@@ -1083,20 +1107,47 @@ export class InstantClient {
10831107
/**
10841108
* POST /deploy/:id/redeploy — rebuild + rolling update an existing app.
10851109
*
1086-
* The live API returns a bare 202 with no body (see openapi.json). Earlier
1087-
* versions of this client typed the response as DeployGetResult and the
1088-
* tool handler dereferenced `result.item.app_id`, throwing
1089-
* "Cannot read properties of undefined (reading 'app_id')" on every real
1090-
* call. BugBash B16 F1 (regression of task #170): the empty-body now
1091-
* resolves to `{ok: true}` via the request<T>() empty-2xx sentinel; this
1110+
* The api handler REQUIRES a fresh tarball multipart file part
1111+
* (deploy.go:1245 `missing_tarball`); there is no tarball reuse anywhere
1112+
* server-side. The previous bodyless version of this method always 400'd
1113+
* with "Multipart field 'tarball' is required" — see AGENT-UX.md Path B.
1114+
*
1115+
* `tarball_base64` is the same shape `createDeploy()` accepts: base64-
1116+
* encoded gzip tar (Dockerfile + source), capped at 50 MiB after decode.
1117+
* The 50 MiB ceiling is enforced client-side BEFORE the upload so an
1118+
* oversized payload fails fast with a clear error instead of round-
1119+
* tripping multiple MB of base64 to the api.
1120+
*
1121+
* The live api returns a bare 202 with no body (see openapi.json). The
1122+
* request<T>() empty-2xx sentinel resolves it to `{ok: true}`; this
10921123
* helper layers the caller-supplied id on top so the tool handler has a
10931124
* stable surface to read.
10941125
*/
1095-
async redeploy(id: string): Promise<RedeployResult> {
1096-
const raw = await this.request<RedeployResult>(
1097-
"POST",
1126+
async redeploy(id: string, tarballBase64: string): Promise<RedeployResult> {
1127+
const form = new FormData();
1128+
1129+
const tarball = Buffer.from(tarballBase64, "base64");
1130+
1131+
// Mirror the createDeploy guard — fail BEFORE opening a multipart
1132+
// connection on an oversized payload. The api enforces 50 MiB
1133+
// (deploy.go:1249 tarball_too_large); pre-empting it here surfaces a
1134+
// precise error and avoids bandwidth burn.
1135+
if (tarball.byteLength > MAX_TARBALL_BYTES) {
1136+
throw new Error(
1137+
`Tarball is too large: ${tarball.byteLength.toLocaleString()} bytes ` +
1138+
`(decoded). The api accepts at most ${MAX_TARBALL_BYTES.toLocaleString()} ` +
1139+
`bytes (50 MiB). Shrink the tarball: include only what \`docker build\` ` +
1140+
`needs — exclude node_modules, .git, build artifacts, large media files. ` +
1141+
`Add a .dockerignore to your project root.`
1142+
);
1143+
}
1144+
1145+
const blob = new Blob([tarball], { type: "application/gzip" });
1146+
form.append("tarball", blob, "app.tar.gz");
1147+
1148+
const raw = await this.requestMultipart<RedeployResult>(
10981149
`/deploy/${encodeURIComponent(id)}/redeploy`,
1099-
undefined,
1150+
form,
11001151
{ requireAuth: true }
11011152
);
11021153
return {

src/index.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
* create_storage — provision an S3-compatible object storage bucket prefix
1313
* create_webhook — provision an inbound webhook receiver URL
1414
* create_deploy — upload a base64 gzip tarball (Dockerfile + source) and
15-
* deploy a container; returns a public URL in ~30s
15+
* deploy a container; returns a public URL in ~30s.
16+
* Pass `redeploy: true` with the same name to update an
17+
* existing deployment IN PLACE (same app_id + URL).
1618
*
1719
* claim_resource — turn an anonymous upgrade JWT into the dashboard claim URL
1820
* the agent should direct the user to (no API call — pure helper)
@@ -25,7 +27,10 @@
2527
*
2628
* list_deployments — list all deployments for the caller's team
2729
* get_deployment — fetch a deployment by app id (for polling build status)
28-
* redeploy — trigger a rebuild + rolling update of an existing app
30+
* redeploy — push updated code to an existing deployment by id;
31+
* requires a fresh tarball (api never reuses the original).
32+
* Prefer `create_deploy({name, redeploy:true})` when you
33+
* have the name; use this when you only have the deploy id.
2934
* delete_deployment — tear down a running deployment
3035
*
3136
* Every create_* tool surfaces the API's `note` and `upgrade` fields so the
@@ -940,7 +945,7 @@ agent can route the user to the dashboard instead of guessing.`,
940945

941946
server.tool(
942947
"create_deploy",
943-
`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.
948+
`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.
944949
945950
Deploys a containerized application on instanode.dev (POST /deploy/new).
946951
@@ -950,6 +955,13 @@ deploys + returns a public URL in ~30s. Build is asynchronous: the initial
950955
response carries status="building"; poll 'get_deployment' with the returned
951956
'deploy_id' until status becomes "running" or "failed".
952957
958+
In-place update (redeploy:true): when you ship v2 of an existing app, pass
959+
the SAME 'name' plus 'redeploy: true'. The api updates that deployment in
960+
place — same app_id, same *.deployment.instanode.dev URL — instead of
961+
minting a fresh one. Default behaviour (redeploy omitted or false) always
962+
creates a new deployment and a new URL. This closes the AGENT-UX trap where
963+
shipping v2 with the same name left two live deployments + two URLs.
964+
953965
Tarball construction (agent side, runtime depends on language):
954966
tar = subprocess.check_output(["tar", "czf", "-", "-C", project_dir, "."])
955967
tarball_base64 = base64.b64encode(tar).decode()
@@ -1066,6 +1078,20 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`,
10661078
.describe(
10671079
"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."
10681080
),
1081+
// In-place redeploy opt-in (api PR feat/deploy-new-redeploy-in-place).
1082+
// Sent to the api as a multipart form field — when true, the api looks
1083+
// up an existing deployment by (team_id, name) and updates it in place
1084+
// (same app_id, same URL) instead of minting a fresh one. Default false
1085+
// preserves the existing "always create a new deployment" behaviour.
1086+
// Note: the api PR must be in prod before this flag does anything; on
1087+
// an older api the field is silently ignored by Fiber's form parser
1088+
// (caller sees legacy behaviour, no error).
1089+
redeploy: z
1090+
.boolean()
1091+
.optional()
1092+
.describe(
1093+
"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."
1094+
),
10691095
},
10701096
// BUG-MCP-021: enforce the documented private+allowed_ips coupling
10711097
// client-side. The API rejects (private=true, allowed_ips=[]) with a 400,
@@ -1442,28 +1468,51 @@ Requires INSTANODE_TOKEN.`,
14421468

14431469
server.tool(
14441470
"redeploy",
1445-
`Trigger a rebuild + rolling update of an existing deployment
1446-
(POST /deploy/:id/redeploy). Useful after updating env vars via the
1447-
dashboard, rotating a vault secret, or when the underlying image needs
1448-
a refresh. The tarball from the original deploy is reused.
1471+
`Push updated code to an existing deployment by app id. Same URL, new build
1472+
(POST /deploy/:id/redeploy).
1473+
1474+
Use this when you already know the deploy_id and want to ship a code change
1475+
without touching the URL or app_id. For the more common "I have the name, I
1476+
want to update the app I just shipped" path, prefer
1477+
create_deploy({ name, tarball_base64, redeploy: true }) — that resolves the
1478+
deployment by name and is the AGENT-UX-recommended path.
1479+
1480+
The api REQUIRES a fresh tarball — there is no server-side tarball reuse
1481+
(the earlier tool description claiming reuse was wrong and caused every
1482+
real call to fail with 400 missing_tarball). Pass a base64-encoded gzip
1483+
tar of the project (Dockerfile + source), same shape as create_deploy.
14491484
14501485
Status flips back to "building"; poll get_deployment until it returns
1451-
to "running".
1486+
to "running" (~30s typical).
14521487
14531488
Requires INSTANODE_TOKEN.`,
14541489
{
14551490
// BUG-MCP-025: validate UUID client-side.
14561491
id: uuidSchema.describe("Deployment app id (returned as 'deploy_id' by create_deploy)."),
1492+
// T-redeploy-fix: tarball is required. The api handler at
1493+
// deploy.go:1245 returns 400 missing_tarball without it; the previous
1494+
// tool schema omitted this field and the description lied about
1495+
// tarball reuse, making every real call 400.
1496+
tarball_base64: z
1497+
.string()
1498+
.min(1)
1499+
.max(
1500+
70 * 1024 * 1024,
1501+
"tarball_base64: encoded payload exceeds 70 MiB (≈50 MiB decoded). Shrink the tarball — strip .git, node_modules, build artifacts."
1502+
)
1503+
.describe(
1504+
"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."
1505+
),
14571506
},
1458-
async ({ id }) => {
1507+
async ({ id, tarball_base64 }) => {
14591508
try {
14601509
// BugBash B16 F1 (regression of task #170): /deploy/:id/redeploy returns
14611510
// a bare 202 with no body — the previous handler dereferenced
14621511
// result.item.app_id and crashed with "Cannot read properties of
14631512
// undefined (reading 'app_id')". client.redeploy() now resolves to
14641513
// {ok, id, status, message} with safe fallbacks so the handler stays
14651514
// alive even when the body is empty.
1466-
const result = await client.redeploy(id);
1515+
const result = await client.redeploy(id, tarball_base64);
14671516
const appId = result.id ?? id;
14681517
const lines = [
14691518
`Redeploy accepted for ${appId}.`,

0 commit comments

Comments
 (0)