Skip to content
Closed
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
29 changes: 25 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
name: CI

# MANDATORY gate. The `integration` job below runs the hermetic MCP
# integration suite (test/integration.test.ts) on every push and pull
# request to master/main. A failure blocks the merge — an MCP-server
# change cannot land without proving every tool still works end-to-end
# over the real MCP stdio protocol.
#
# The suite is hermetic: it spawns the built server binary and points it
# at an in-process mock of the agent API (test/mock-api.ts), so it needs
# no external network, no cluster, and no secrets. `npm test` locally
# runs the exact same gate.

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

jobs:
build:
integration:
name: MCP integration suite (CI gate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Type-check + build the server
run: npm run build

- run: npm ci
- run: npm run build
- run: npm test
# `npm test` runs `pretest` (tsc + tsc -p tsconfig.test.json) then
# `node --test dist-test/test/` — the hermetic integration suite.
# This is the gate: a non-zero exit fails the job and blocks merge.
- name: Run the hermetic MCP integration suite
run: npm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules/
dist/
dist-test/
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,11 +297,40 @@ Rotate any time by calling `get_api_token`, which mints a fresh 30-day JWT.
```bash
npm install
npm run build
# Integration test (optional — requires a running instanode.dev server.
# For local k8s, port-forward first: kubectl port-forward -n instant svc/instant-api 8080:8080):
INSTANODE_API_URL=http://localhost:8080 npm test
```

### Tests

`npm test` runs the **hermetic MCP integration suite** — it is the CI gate
(`.github/workflows/ci.yml` runs it on every push and pull request; a failure
blocks the merge).

```bash
npm test
```

The suite (`test/integration.test.ts`) spawns the real built server binary and
drives it over the genuine MCP stdio protocol using the official SDK client,
pointed at an in-process mock of the agent API (`test/mock-api.ts`). It needs
no network, no cluster, and no secrets — `npm test` behaves identically in CI
and locally. It exercises every registered tool: schemas, success + error
responses (401 / 402 / 403 / 404 / 400), the multipart deploy path, bearer-token
auth handling, and malformed-input rejection. Every test that creates a resource
tears it down, and a final sweep asserts the backend ledger is empty.

An **optional live smoke test** (`test/live-smoke.test.ts`) provisions a real
Postgres and tears it down again — it is skipped unless explicitly enabled:

```bash
INSTANODE_LIVE_SMOKE=1 \
INSTANODE_API_URL=http://localhost:8080 \
INSTANODE_TOKEN=<paid bearer> \
npm test
```

`npm run test:smoke` runs the legacy `test.sh` shell smoke test against a live
API instead.

## License

MIT — (c) instanode.dev
33 changes: 17 additions & 16 deletions package-lock.json

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

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "instanode-mcp",
"version": "0.11.0",
"version": "0.11.1",
"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 Expand Up @@ -45,11 +45,14 @@
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "bash test.sh",
"pretest": "tsc && tsc -p tsconfig.test.json",
"test": "node --test dist-test/test/integration.test.js dist-test/test/live-smoke.test.js",
"test:smoke": "bash test.sh",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2"
"@modelcontextprotocol/sdk": "^1.10.2",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
"url": "https://github.com/InstaNode-dev/mcp",
"source": "github"
},
"version": "0.11.0",
"version": "0.11.1",
"websiteUrl": "https://instanode.dev",
"packages": [
{
"registryType": "npm",
"identifier": "instanode-mcp",
"version": "0.11.0",
"version": "0.11.1",
"transport": {
"type": "stdio"
},
Expand Down
75 changes: 70 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,41 @@
* client to the canonical routes above.
*/

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_BASE_URL = "https://api.instanode.dev";
const DEFAULT_DASHBOARD_URL = "https://instanode.dev";

/**
* Resolve this package's version from the installed package.json EXACTLY ONCE
* at module load. Previously the User-Agent string was a hardcoded literal
* ("instanode-mcp/0.11.0") in two places — every version bump silently lied
* to server-side analytics and rate-limit attribution. T17 P1.
*
* Reads relative to this file's compiled location (dist/client.js → ../package.json).
* Falls back to "dev" if the file is missing or unreadable — never crashes the
* client, since the User-Agent is informational.
*/
function resolvePkgVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(here, "..", "package.json");
const raw = readFileSync(pkgPath, "utf8");
const parsed = JSON.parse(raw) as { version?: unknown };
if (typeof parsed.version === "string" && parsed.version.length > 0) {
return parsed.version;
}
} catch {
// Fall through to the sentinel.
}
return "dev";
}

const PKG_VERSION = resolvePkgVersion();
const USER_AGENT = `instanode-mcp/${PKG_VERSION}`;

export interface ClientOptions {
baseURL?: string;
}
Expand Down Expand Up @@ -192,6 +224,23 @@ export interface DeployGetResult {
item: Deployment;
}

/**
* Response shape for POST /deploy/:id/redeploy.
*
* The live API contract (api/internal/handlers/openapi.go:397-407) documents
* this endpoint as a bare 202 with NO body schema — the previous mcp client
* typed it as `DeployGetResult` and dereferenced `result.item.app_id`, which
* threw a TypeError against the real API. We now model it explicitly as a
* fire-and-forget acknowledgement: just `{ ok: true }`, with `id` populated
* client-side from the request so the caller still has something to surface.
* T17 P0-1.
*/
export interface RedeployResult {
ok: boolean;
/** Echoed from the request path so callers don't need to remember it. */
id: string;
}

export interface DeployDeleteResult {
ok: boolean;
id?: string;
Expand Down Expand Up @@ -360,7 +409,7 @@ export class InstantClient {
private headers(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "instanode-mcp/0.11.0",
"User-Agent": USER_AGENT,
};
const tok = this.bearerToken();
if (tok) {
Expand All @@ -375,7 +424,7 @@ export class InstantClient {
*/
private authHeaders(): Record<string, string> {
const h: Record<string, string> = {
"User-Agent": "instanode-mcp/0.11.0",
"User-Agent": USER_AGENT,
};
const tok = this.bearerToken();
if (tok) {
Expand Down Expand Up @@ -715,14 +764,30 @@ export class InstantClient {
);
}

/** POST /deploy/:id/redeploy — rebuild + rolling update an existing app. */
async redeploy(id: string): Promise<DeployGetResult> {
return this.request<DeployGetResult>(
/**
* POST /deploy/:id/redeploy — rebuild + rolling update an existing app.
*
* The live API returns a bare 202 with NO body (api/internal/handlers/openapi.go
* documents this explicitly). The previous implementation typed the response as
* `DeployGetResult` and the index.ts handler dereferenced `result.item.app_id`,
* which threw `TypeError: Cannot read properties of undefined (reading 'app_id')`
* against the real API every time. The hermetic mock used to fabricate
* `{ ok, item }` which masked the bug. T17 P0-1.
*
* We treat the call as fire-and-forget: any 2xx (typically 202) is success;
* any non-2xx flows through the normal ApiError path. The returned `id` is
* just the path param echoed back so the caller has something to surface.
*/
async redeploy(id: string): Promise<RedeployResult> {
// `request<T>` returns `undefined` for an empty 2xx body — that's fine,
// we don't read it. The throw paths still fire normally on non-2xx.
await this.request<unknown>(
"POST",
`/deploy/${encodeURIComponent(id)}/redeploy`,
undefined,
{ requireAuth: true }
);
return { ok: true, id };
}

/** DELETE /deploy/:id — tear down the running pod + remove the record. */
Expand Down
Loading
Loading