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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ One tool call per resource type, each returning a drop-in connection string:
(Dockerfile + source), get back a public URL in ~30s. Bind any of the
resources above by passing their tokens as `resource_bindings` — the API
resolves tokens to connection URLs server-side.
- **Multi-service stack** (`create_stack`) → declare 1..N services in an
`instant.yaml` manifest, ship them as a bundle in a single MCP call. Anonymous
callers get a 24h-TTL stack with a live URL on
`*.deployment.instanode.dev` — no card required. Cross-service refs
(`service://<name>`) resolve cluster-internally at deploy time. Poll
status with `get_stack`.

Every anonymous resource auto-expires in 24h. The provision response carries
a `note` and `upgrade` field — the MCP server surfaces both verbatim so the
Expand Down Expand Up @@ -114,6 +120,8 @@ to reach for this MCP, see <https://instanode.dev/agent.html>.
| `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_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`. |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "instanode-mcp",
"version": "0.11.1",
"version": "0.12.0",
"description": "MCP server for instanode.dev \u2014 lets AI coding agents provision ephemeral Postgres, Redis, MongoDB, NATS queues, S3-compatible object storage, webhook receivers, and deploy containerized apps over HTTPS, with optional bearer-token auth for paid users.",
"keywords": [
"mcp",
Expand Down
223 changes: 207 additions & 16 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 All @@ -281,7 +281,12 @@
name: string;
/** Container HTTP port. Default 8080. */
port?: number;
/** Deploy env scope: production / staging / development. Default "production". */
/**
* Deploy env scope: development / staging / production. Default
* "development" server-side — see CLAUDE.md convention #11 / migration 026.
* Omitting `env` lands the deploy in 'development' (lowest stakes) so
* accidental no-env deploys can't merge with prod state.
*/
env?: string;
/**
* env vars dict; values can be plaintext or vault://env/KEY refs. The api
Expand Down Expand Up @@ -311,6 +316,77 @@
allowed_ips?: string[];
}

/**
* A single service entry in a StackResponse. The api returns one of these per
* service declared in the manifest. Only services with `expose: true` get a
* public `url`; the rest are reachable in-cluster only via
* `http://<service-name>:<port>`.
*/
export interface StackService {
name: string;
status: string;
port: number;
expose?: boolean;
/** Empty string while building or for non-exposed services. */
url?: string;
}

/**
* Response shape from POST /stacks/new (HTTP 202 Accepted) and GET /stacks/{slug}.
*
* Mirrors the `StackResponse` schema in api/internal/handlers/openapi.go. Like
* /deploy/new, the build is asynchronous: the initial response carries
* status="building"; poll `getStack(stack_id)` until status="healthy" (or
* "failed"). Overall status is "healthy" only when every service is healthy.
*/
export interface StackResult {
ok: boolean;
/** Format: stk-<8-char-hex>. Use this for getStack / GET /stacks/{slug}. */
stack_id: string;
status: string;
tier: string;
/** Resolved env bucket (defaults to 'development' — see CLAUDE.md #11). */
env?: string;
name?: string;
services: StackService[];
/** Anonymous stacks have a 24h TTL; authenticated stacks return empty. */
expires_in?: string;
/** Anonymous-tier CTA fields, same semantics as create_*. */
note?: string;
upgrade?: string;
upgrade_jwt?: string;
}

/**
* Caller-supplied params for create_stack — wraps POST /stacks/new.
*
* `manifest` is the raw YAML text of an `instant.yaml`. `service_tarballs`
* maps each service-name declared in the manifest to a base64-encoded gzip
* tarball of that service's build context (Dockerfile + sources). The client
* decodes each tarball and attaches it as a multipart file part NAMED AFTER
* THE SERVICE — this is the api's documented contract (see openapi.json
* StackRequest: "One field per service declared in the manifest, named after
* the service. Value is a gzipped tar archive."). Total request body cap is
* 200 MB across all services (api side); each service's decoded tarball is
* still capped at 50 MiB client-side.
*/
export interface CreateStackParams {
/** Stack name. Required (1-64 chars, ^[A-Za-z0-9][A-Za-z0-9 _-]*$). */
name: string;
/** instant.yaml text — declares services + their build/port/expose/needs. */
manifest: string;
/**
* One entry per service declared in the manifest. Keys are service names;
* values are base64-encoded gzip tarballs of that service's build context.
*/
service_tarballs: Record<string, string>;
/**
* Optional resource env scope (development / staging / production). Default
* "development" server-side (see CLAUDE.md convention #11 / mig 026).
*/
env?: string;
}

export interface ClaimResult {
ok: boolean;
id: string;
Expand Down Expand Up @@ -606,50 +682,94 @@
return data as T;
}

/**
* Build a `{ name [, env] }` body for the /<resource>/new endpoints.
*
* CLI-MCP FINDING-8: `env` is the resource environment scope (development /
* staging / production). The MCP previously dropped it entirely, so every
* call landed in the server-side default (`development` per mig 026 /
* CLAUDE.md convention #11) with no way for the agent to override. The
* helper only sets the field when the caller actually passed a non-empty
* string — undefined/empty preserves the server default and matches the
* pre-fix request shape exactly (so the wire diff is opt-in).
*/
private provisionBody(name: string, env?: string): { name: string; env?: string } {
const body: { name: string; env?: string } = { name };
if (typeof env === "string" && env.length > 0) body.env = env;
return body;
}

/** POST /db/new — provision a Postgres database. `name` is required. */
async createPostgres(name: string): Promise<DatabaseProvisionResult> {
return this.request<DatabaseProvisionResult>("POST", "/db/new", { name });
async createPostgres(name: string, env?: string): Promise<DatabaseProvisionResult> {
return this.request<DatabaseProvisionResult>(
"POST",
"/db/new",
this.provisionBody(name, env)
);
}

/**
* POST /vector/new — provision a pgvector-enabled Postgres database. `name`
* is required client-side for parity with the other create_* tools (the
* server allows it to be omitted, but every other endpoint requires it).
* Optional `dimensions` is a documentation hint only — pgvector picks
* dimensions per column at table-create time.
* dimensions per column at table-create time. Optional `env` lands the
* resource in a specific env bucket (server default `development`).
*/
async createVector(
name: string,
dimensions?: number
dimensions?: number,
env?: string
): Promise<VectorProvisionResult> {
const body: { name: string; dimensions?: number } = { name };
const body: { name: string; dimensions?: number; env?: string } =
this.provisionBody(name, env);
if (typeof dimensions === "number") body.dimensions = dimensions;
return this.request<VectorProvisionResult>("POST", "/vector/new", body);
}

/** POST /cache/new — provision a Redis cache. `name` is required. */
async createCache(name: string): Promise<CacheProvisionResult> {
return this.request<CacheProvisionResult>("POST", "/cache/new", { name });
async createCache(name: string, env?: string): Promise<CacheProvisionResult> {
return this.request<CacheProvisionResult>(
"POST",
"/cache/new",
this.provisionBody(name, env)
);
}

/** POST /nosql/new — provision a MongoDB database. `name` is required. */
async createNoSQL(name: string): Promise<NoSQLProvisionResult> {
return this.request<NoSQLProvisionResult>("POST", "/nosql/new", { name });
async createNoSQL(name: string, env?: string): Promise<NoSQLProvisionResult> {
return this.request<NoSQLProvisionResult>(
"POST",
"/nosql/new",
this.provisionBody(name, env)
);
}

/** POST /queue/new — provision a NATS JetStream queue. `name` is required. */
async createQueue(name: string): Promise<QueueProvisionResult> {
return this.request<QueueProvisionResult>("POST", "/queue/new", { name });
async createQueue(name: string, env?: string): Promise<QueueProvisionResult> {
return this.request<QueueProvisionResult>(
"POST",
"/queue/new",
this.provisionBody(name, env)
);
}

/** POST /storage/new — provision an S3-compatible object storage bucket prefix. `name` is required. */
async createStorage(name: string): Promise<StorageProvisionResult> {
return this.request<StorageProvisionResult>("POST", "/storage/new", { name });
async createStorage(name: string, env?: string): Promise<StorageProvisionResult> {
return this.request<StorageProvisionResult>(
"POST",
"/storage/new",
this.provisionBody(name, env)
);
}

/** POST /webhook/new — provision a webhook receiver. `name` is required. */
async createWebhook(name: string): Promise<WebhookProvisionResult> {
return this.request<WebhookProvisionResult>("POST", "/webhook/new", { name });
async createWebhook(name: string, env?: string): Promise<WebhookProvisionResult> {
return this.request<WebhookProvisionResult>(
"POST",
"/webhook/new",
this.provisionBody(name, env)
);
}

/**
Expand Down Expand Up @@ -844,6 +964,77 @@
};
}

/**
* POST /stacks/new — upload an instant.yaml manifest + one gzipped tarball
* per declared service and deploy a multi-service bundle. Multipart.
*
* Anonymous-friendly: like /deploy/new the api accepts anonymous callers
* (OptionalAuth — openapi.json:157), issuing the stack at the anonymous tier
* with a 24h TTL. This is the CEO wedge: a single MCP call from a cold-start
* agent → live bundle URL on *.deployment.instanode.dev, no card, no
* dashboard round-trip.
*
* Multipart shape (per StackRequest in openapi.json):
* - `name` — text field, required.
* - `manifest` — text field carrying the YAML body.
* - `<service-name>` — one binary file part PER service declared in the
* manifest, named after the service (e.g. `api`, `web`, `worker`).
* - `env` — optional text field (resource env scope).
*
* Returns the api's 202 StackResponse with stack_id, per-service status +
* URL (exposed services only), and anonymous-tier CTA fields.
*/
async createStack(params: CreateStackParams): Promise<StackResult> {
const form = new FormData();

form.append("name", params.name);
form.append("manifest", params.manifest);
if (typeof params.env === "string" && params.env.length > 0) {
form.append("env", params.env);
}

// One file part per service. Enforce the per-tarball 50 MiB cap
// client-side, mirroring the create_deploy guard — an oversized tarball
// would otherwise stream multiple MB of base64 to the api just to be
// rejected, with the agent host potentially logging the body.
for (const [serviceName, b64] of Object.entries(params.service_tarballs)) {
const tarball = Buffer.from(b64, "base64");
if (tarball.byteLength > MAX_TARBALL_BYTES) {
throw new Error(
`Tarball for service "${serviceName}" is too large: ` +
`${tarball.byteLength.toLocaleString()} bytes (decoded). ` +
`The api accepts at most ${MAX_TARBALL_BYTES.toLocaleString()} ` +
`bytes (50 MiB) per service. Shrink the tarball: include only ` +
`what \`docker build\` needs — exclude node_modules, .git, build ` +
`artifacts, large media. Add a .dockerignore.`
);
}
const blob = new Blob([tarball], { type: "application/gzip" });
form.append(serviceName, blob, `${serviceName}.tar.gz`);
}

// /stacks/new is OptionalAuth — anonymous callers are accepted with a 24h
// TTL. Do NOT pass requireAuth here.
return this.requestMultipart<StackResult>("/stacks/new", form);
}

/**
* GET /stacks/{slug} — poll a stack's per-service status + URLs.
*
* The public `/stacks/{slug}` route mirrors the StackResponse shape returned
* by POST /stacks/new (services array, expires_in, etc.) — distinct from
* the dashboard-only `GET /api/v1/stacks/{slug}` which requires auth and
* returns a flatter summary. Anonymous callers polling a stack they just
* created use the public route, so this method is intentionally NOT
* requireAuth.
*/
async getStack(stackId: string): Promise<StackResult> {
return this.request<StackResult>(
"GET",
`/stacks/${encodeURIComponent(stackId)}`
);
}

/** GET /api/v1/deployments — list deployments for the authenticated team. */
async listDeployments(): Promise<DeployListResult> {
return this.request<DeployListResult>("GET", "/api/v1/deployments", undefined, {
Expand Down
Loading
Loading