From 791c2df40189463493ab2be105a0eec4bf8b62cf Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Tue, 19 May 2026 13:05:52 +0530 Subject: [PATCH 1/2] test: mandatory hermetic MCP integration suite + CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes MCP-server testing a hard CI gate. Previously `npm test` ran a shell smoke test (test.sh) against the live api.instanode.dev — not hermetic, not runnable in CI without a cluster, and easy to skip. An MCP-server change could silently break the tools agents depend on. What this adds -------------- - test/integration.test.ts — drives the REAL built server binary over the genuine MCP stdio protocol via the official SDK client. 39 tests covering all 16 registered tools: tool registry + every input schema, success responses, error envelopes (401/402/403/404/400), the multipart create_deploy upload, bearer-token auth (none/valid/bad), the full deploy lifecycle (create→get→redeploy→delete), private deploys + tier gating, and malformed-input rejection. - test/mock-api.ts — hermetic in-process http.Server mock of the agent API (the https://api.instanode.dev/openapi.json contract). The suite runs in CI with zero network, zero cluster, zero secrets. It keeps a ledger of every resource/deployment so the cleanup sweep can assert nothing leaked. - test/live-smoke.test.ts — optional, build-flagged (INSTANODE_LIVE_SMOKE=1) provision-then-teardown smoke test against a real backend; skipped by default. Deletes its resource in a finally block. Resource cleanup (mandatory) ---------------------------- Every test that creates a paid resource or a deployment tears it down. The after() hook runs a final sweep that deletes every still-live deployment + paid resource on the mock, then asserts the deletable ledger is empty. mock.close() runs in a finally so a failed assertion can't leave the server open. CI gate ------- - package.json `test` script now runs the hermetic suite via `node --test` (pretest builds server + tests). Local `npm test` == the CI gate exactly. Legacy shell smoke test moved to `test:smoke`. - .github/workflows/ci.yml runs `npm test` on every push + PR to master/main; a non-zero exit blocks the merge. - zod added as an explicit dependency (index.ts imports it directly; it was only resolving transitively via the MCP SDK). npm test: 39 passed, 0 failed (live-smoke SKIP), exit 0, fully hermetic. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 29 +- .gitignore | 1 + README.md | 35 +- package-lock.json | 3 +- package.json | 7 +- test/integration.test.ts | 822 +++++++++++++++++++++++++++++++++++++++ test/live-smoke.test.ts | 92 +++++ test/mock-api.ts | 625 +++++++++++++++++++++++++++++ tsconfig.test.json | 12 + 9 files changed, 1616 insertions(+), 10 deletions(-) create mode 100644 test/integration.test.ts create mode 100644 test/live-smoke.test.ts create mode 100644 test/mock-api.ts create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76acd98..ff3e193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,16 @@ 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] @@ -7,7 +18,8 @@ on: branches: [master, main] jobs: - build: + integration: + name: MCP integration suite (CI gate) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -15,7 +27,16 @@ jobs: - 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 diff --git a/.gitignore b/.gitignore index b947077..9e63e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +dist-test/ diff --git a/README.md b/README.md index 042dc86..5ea9568 100644 --- a/README.md +++ b/README.md @@ -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= \ +npm test +``` + +`npm run test:smoke` runs the legacy `test.sh` shell smoke test against a live +API instead. + ## License MIT — (c) instanode.dev diff --git a/package-lock.json b/package-lock.json index 7b7f461..4cb3be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.11.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.2" + "@modelcontextprotocol/sdk": "^1.10.2", + "zod": "^3.25.0 || ^4.0.0" }, "bin": { "instanode-mcp": "dist/index.js" diff --git a/package.json b/package.json index 9a08b6a..7ac7e62 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..22f8fb4 --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,822 @@ +/** + * MANDATORY integration test suite for the instanode-mcp server. + * + * Why this exists + * ─────────────── + * The MCP server is the surface AI coding agents (Claude Code, Cursor, + * Windsurf) call to provision databases, caches, queues, storage, webhooks, + * and to deploy containers on instanode.dev. A regression here silently + * breaks every agent that depends on it. This suite is wired as a CI gate + * (.github/workflows/ci.yml runs `npm test` on every push + PR) so an + * MCP-server change cannot land without proving every tool still works. + * + * What it does + * ──────────── + * - Spawns the REAL built server binary (dist/index.js) and drives it over + * the genuine MCP stdio protocol using the official SDK Client. No mocked + * transport — this is end-to-end through JSON-RPC. + * - Points the server at a hermetic in-process mock of the agent API + * (test/mock-api.ts) so the suite runs in CI with zero external deps. + * - Asserts the tool registry, every tool's input schema, success responses, + * error envelopes (401 / 402 / 403 / 404 / 400), the multipart deploy + * path, bearer-token auth handling, and malformed-input rejection. + * + * RESOURCE CLEANUP (mandatory) + * ──────────────────────────── + * Every test that creates a resource or deployment records its id and tears + * it down. The `after()` hook runs a final sweep: it lists every resource + + * deployment still live on the mock and deletes them, then asserts the mock's + * ledger is empty. Because the backend is a hermetic in-process mock, no real + * kaniko build or k8s pod is ever created — but the cleanup discipline is + * exercised exactly as it would be against a live backend, and the optional + * live smoke test (test/live-smoke.test.ts) reuses the same teardown helpers. + */ + +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +import { startMockApi, type MockApiHandle, VALID_TOKEN, BAD_TOKEN } from "./mock-api.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// The server is built to dist/index.js (tsconfig rootDir=src, outDir=dist). +// This test file compiles to dist-test/test/integration.test.js, so the +// server binary sits two directories up then into dist/. +const SERVER_ENTRY = resolve(__dirname, "..", "..", "dist", "index.js"); + +/** Every tool the server is contractually required to register. */ +const EXPECTED_TOOLS = [ + "create_postgres", + "create_cache", + "create_nosql", + "create_queue", + "create_storage", + "create_webhook", + "create_deploy", + "list_deployments", + "get_deployment", + "redeploy", + "delete_deployment", + "claim_resource", + "claim_token", + "list_resources", + "delete_resource", + "get_api_token", +] as const; + +/** + * Spawn the MCP server connected to the mock API and return a connected + * SDK Client. `token` controls the INSTANODE_TOKEN env var: + * - "valid" → recognised paid bearer + * - "bad" → revoked bearer (mock returns 401) + * - "none" → anonymous (env var unset) + */ +async function connectClient( + apiUrl: string, + token: "valid" | "bad" | "none" +): Promise<{ client: Client; close: () => Promise }> { + const env: Record = { + INSTANODE_API_URL: apiUrl, + // Keep PATH so node can find shared libs; everything else is scrubbed. + PATH: process.env["PATH"] ?? "", + }; + if (token === "valid") env["INSTANODE_TOKEN"] = VALID_TOKEN; + if (token === "bad") env["INSTANODE_TOKEN"] = BAD_TOKEN; + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_ENTRY], + env, + // "ignore" (not "pipe"): an undrained stderr pipe is an open handle that + // keeps the test process's event loop alive forever — the suite would + // hang after the last test. We don't read server stderr here anyway. + stderr: "ignore", + }); + const client = new Client( + { name: "instanode-mcp-integration-test", version: "1.0.0" }, + { capabilities: {} } + ); + await client.connect(transport); + return { + client, + close: async () => { + // Closing the client closes the stdio transport, which signals the + // spawned server to exit. Capture the pid first so we can hard-kill + // it if it lingers — a leaked child process keeps `node --test` from + // ever exiting (the suite would hang forever in CI). + const pid = transport.pid; + await client.close().catch(() => {}); + if (pid !== null) { + try { + process.kill(pid, 0); // throws if already gone + process.kill(pid, "SIGKILL"); + } catch { + // already exited — nothing to do + } + } + }, + }; +} + +/** Extract the flattened text from a tools/call result. */ +function resultText(callResult: unknown): string { + const r = callResult as { content?: Array<{ type: string; text?: string }> }; + if (!r.content || r.content.length === 0) return ""; + return r.content.map((c) => c.text ?? "").join("\n"); +} + +/** A minimal but valid gzip tarball payload, base64-encoded. */ +function fakeTarballBase64(): string { + // Real gzip stream so the payload is structurally a gzip blob; the mock + // does not untar it, it only checks the multipart `tarball` part exists. + return gzipSync(Buffer.from("FROM scratch\n")).toString("base64"); +} + +// ── Suite ───────────────────────────────────────────────────────────────────── + +describe("instanode-mcp integration suite", () => { + let mock: MockApiHandle; + + before(async () => { + // Verify the server was built. The CI gate runs `npm run build` first; + // locally `npm test` does the same via the prebuild step. + const check = spawnSync(process.execPath, ["-e", `require('node:fs').accessSync(${JSON.stringify(SERVER_ENTRY)})`]); + assert.equal( + check.status, + 0, + `server binary missing at ${SERVER_ENTRY} — run "npm run build" first` + ); + mock = await startMockApi(); + }); + + after(async () => { + // ── MANDATORY CLEANUP SWEEP ─────────────────────────────────────────── + // Every paid create_* / create_deploy test below tears down what it + // made, but a failed assertion can leave a deletable resource behind. + // This sweep deletes anything still live that the API CAN delete, then + // asserts the deletable ledger is empty — the same discipline the + // optional live smoke test relies on so a real deploy never leaks a + // kaniko build or k8s pod. + // + // Anonymous-tier resources (provisioned by the six "anonymous tier" + // tests with no token) are deliberately NOT swept: the API forbids + // deleting them — they auto-expire at their 24h TTL. They are counted + // and reported, not deleted. Only deployments + paid resources are + // swept, because only those cost real compute if leaked. + let leakedPaid = 0; + let leakedDeploys = 0; + try { + const { client, close } = await connectClient(mock.url, "valid"); + try { + for (const d of mock.liveDeployments()) { + await client.callTool({ name: "delete_deployment", arguments: { id: d.app_id } }); + } + for (const r of mock.liveResources()) { + if (r.tier === "anonymous" || r.tier === "free") continue; // not deletable by contract + await client.callTool({ name: "delete_resource", arguments: { token: r.token } }); + } + } finally { + await close(); + } + leakedPaid = mock.liveResources().filter((r) => r.tier !== "anonymous" && r.tier !== "free").length; + leakedDeploys = mock.liveDeployments().length; + } finally { + // mock.close() MUST run even if the sweep above throws — otherwise the + // mock's http.Server stays open and the test process never exits. + await mock.close(); + } + assert.equal(leakedPaid, 0, `cleanup sweep left ${leakedPaid} deletable resource(s) on the backend`); + assert.equal(leakedDeploys, 0, `cleanup sweep left ${leakedDeploys} deployment(s) on the backend`); + }); + + // ── Tool registry + schemas ───────────────────────────────────────────────── + + describe("tool registry", () => { + it("registers exactly the 16 contract tools, no dead ones", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const names = new Set(tools.map((t) => t.name)); + for (const expected of EXPECTED_TOOLS) { + assert.ok(names.has(expected), `missing tool: ${expected}`); + } + assert.equal( + names.size, + EXPECTED_TOOLS.length, + `unexpected tools registered: ${[...names].filter((n) => !EXPECTED_TOOLS.includes(n as never))}` + ); + // Dead tools from earlier MCP builds must never reappear. + for (const dead of ["provision_cache", "deploy_app", "deploy_stack"]) { + assert.ok(!names.has(dead), `dead tool still registered: ${dead}`); + } + } finally { + await close(); + } + }); + + it("every tool advertises a non-empty description and input schema", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + for (const t of tools) { + assert.ok( + typeof t.description === "string" && t.description.length > 20, + `tool ${t.name} has a too-short description` + ); + assert.ok(t.inputSchema, `tool ${t.name} has no inputSchema`); + assert.equal( + (t.inputSchema as { type?: string }).type, + "object", + `tool ${t.name} inputSchema is not an object schema` + ); + } + } finally { + await close(); + } + }); + + it("create_* tools require a 'name' string argument", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const byName = new Map(tools.map((t) => [t.name, t])); + for (const tool of ["create_postgres", "create_cache", "create_nosql", "create_queue", "create_storage", "create_webhook"]) { + const schema = byName.get(tool)!.inputSchema as { + properties?: Record; + required?: string[]; + }; + assert.ok(schema.properties && "name" in schema.properties, `${tool} missing 'name' property`); + assert.ok(schema.required?.includes("name"), `${tool} does not mark 'name' required`); + } + } finally { + await close(); + } + }); + + it("create_deploy schema advertises tarball_base64, name, private, allowed_ips, resource_bindings", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const deploy = tools.find((t) => t.name === "create_deploy")!; + const props = (deploy.inputSchema as { properties?: Record }).properties ?? {}; + for (const field of ["tarball_base64", "name", "port", "env", "env_vars", "resource_bindings", "private", "allowed_ips"]) { + assert.ok(field in props, `create_deploy schema missing '${field}'`); + } + assert.equal(props["allowed_ips"]?.type, "array", "allowed_ips should be an array"); + const required = (deploy.inputSchema as { required?: string[] }).required ?? []; + assert.ok(required.includes("tarball_base64"), "create_deploy must require tarball_base64"); + assert.ok(required.includes("name"), "create_deploy must require name"); + assert.ok(/pro tier/i.test(deploy.description ?? ""), "create_deploy description must mention the Pro tier gate"); + } finally { + await close(); + } + }); + + it("claim_token requires both upgrade_jwt and email (rejects the legacy single-token shape)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const claim = tools.find((t) => t.name === "claim_token")!; + const schema = claim.inputSchema as { properties?: Record; required?: string[] }; + assert.ok(schema.properties && "upgrade_jwt" in schema.properties, "claim_token missing upgrade_jwt"); + assert.ok(schema.properties && "email" in schema.properties, "claim_token missing email"); + assert.ok(schema.required?.includes("upgrade_jwt"), "claim_token must require upgrade_jwt"); + assert.ok(schema.required?.includes("email"), "claim_token must require email"); + } finally { + await close(); + } + }); + }); + + // ── create_* provisioning tools (anonymous tier) ──────────────────────────── + + describe("provisioning tools — anonymous tier", () => { + const provisioners: Array<{ tool: string; expectInOutput: string[] }> = [ + { tool: "create_postgres", expectInOutput: ["Postgres database provisioned.", "Connection URL:", "DATABASE_URL="] }, + { tool: "create_cache", expectInOutput: ["Redis cache provisioned.", "REDIS_URL="] }, + { tool: "create_nosql", expectInOutput: ["MongoDB database provisioned.", "MONGODB_URI="] }, + { tool: "create_queue", expectInOutput: ["NATS JetStream queue provisioned.", "NATS_URL="] }, + { tool: "create_webhook", expectInOutput: ["Webhook receiver provisioned.", "Receive URL:"] }, + ]; + + for (const { tool, expectInOutput } of provisioners) { + it(`${tool} succeeds and surfaces the claim/upgrade block`, async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: tool, arguments: { name: `it-${tool}` } }); + const text = resultText(res); + for (const fragment of expectInOutput) { + assert.ok(text.includes(fragment), `${tool} output missing "${fragment}":\n${text}`); + } + // Anonymous responses must carry the claim URL verbatim. + assert.ok(text.includes("Claim URL"), `${tool} did not surface the claim URL`); + assert.ok(text.includes("Tier:"), `${tool} did not report a tier`); + } finally { + await close(); + } + }); + } + + it("create_storage returns S3 credentials and the AWS-SDK env block", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "create_storage", arguments: { name: "it-storage" } }); + const text = resultText(res); + for (const fragment of ["Object storage bucket prefix provisioned.", "Access key ID:", "Secret access key:", "AWS_ACCESS_KEY_ID=", "AWS_ENDPOINT_URL="]) { + assert.ok(text.includes(fragment), `create_storage output missing "${fragment}":\n${text}`); + } + } finally { + await close(); + } + }); + + it("create_postgres rejects an empty name at the schema layer", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "create_postgres", arguments: { name: "" } }); + // Zod min(1) failure surfaces as an MCP error result. + assert.ok( + (res as { isError?: boolean }).isError === true, + `create_postgres accepted an empty name: ${JSON.stringify(res)}` + ); + } finally { + await close(); + } + }); + + it("create_postgres rejects an over-long (>64 char) name", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "create_postgres", + arguments: { name: "x".repeat(65) }, + }); + assert.ok((res as { isError?: boolean }).isError === true, "create_postgres accepted a 65-char name"); + } finally { + await close(); + } + }); + + it("create_postgres rejects a missing name argument entirely", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "create_postgres", arguments: {} }); + assert.ok((res as { isError?: boolean }).isError === true, "create_postgres accepted a missing name"); + } finally { + await close(); + } + }); + }); + + // ── create_* provisioning tools (paid tier) ───────────────────────────────── + + describe("provisioning tools — authenticated paid tier", () => { + it("create_postgres with a valid token reports the pro tier and no claim block", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + let token = ""; + try { + const res = await client.callTool({ name: "create_postgres", arguments: { name: "it-paid-pg" } }); + const text = resultText(res); + assert.ok(text.includes("Tier: pro"), `expected pro tier:\n${text}`); + assert.ok(!text.includes("Claim URL"), `paid resource should not carry a claim URL:\n${text}`); + const m = /Token:\s+(\S+)/.exec(text); + assert.ok(m, "could not parse provisioned token"); + token = m[1]; + } finally { + await close(); + } + // CLEANUP: tear down what this test created. + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + const delRes = await c2.callTool({ name: "delete_resource", arguments: { token } }); + assert.ok(resultText(delRes).includes("Resource deleted."), "cleanup delete failed"); + } finally { + await close2(); + } + }); + }); + + // ── Authentication handling ───────────────────────────────────────────────── + + describe("authentication handling", () => { + it("list_resources without a token surfaces the auth-required message (no network call)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "list_resources", arguments: {} }); + const text = resultText(res); + assert.ok(text.includes("INSTANODE_TOKEN"), `expected auth-required text:\n${text}`); + } finally { + await close(); + } + }); + + it("list_resources with a bad token surfaces a 401 with the dashboard CTA", async () => { + const { client, close } = await connectClient(mock.url, "bad"); + try { + const res = await client.callTool({ name: "list_resources", arguments: {} }); + const text = resultText(res); + assert.ok(/401/.test(text), `expected a 401 in the output:\n${text}`); + assert.ok(text.includes("instanode.dev/dashboard"), `expected the dashboard CTA:\n${text}`); + } finally { + await close(); + } + }); + + it("list_resources with a valid token returns the resource list", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + let token = ""; + try { + // Provision one so the list is non-empty. + const prov = await client.callTool({ name: "create_cache", arguments: { name: "it-list-cache" } }); + token = /Token:\s+(\S+)/.exec(resultText(prov))![1]; + + const res = await client.callTool({ name: "list_resources", arguments: {} }); + const text = resultText(res); + assert.ok(text.includes("resource(s) on this account"), `expected a resource list:\n${text}`); + assert.ok(text.includes(token), `provisioned resource ${token} not in the list:\n${text}`); + } finally { + await close(); + } + // CLEANUP + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + await c2.callTool({ name: "delete_resource", arguments: { token } }); + } finally { + await close2(); + } + }); + + it("create_deploy without a token surfaces the auth-required message before any upload", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-noauth-deploy" }, + }); + const text = resultText(res); + assert.ok(text.includes("INSTANODE_TOKEN"), `expected auth-required text:\n${text}`); + assert.equal(mock.deployCount(), 0, "create_deploy hit the network despite missing auth"); + } finally { + await close(); + } + }); + + it("get_api_token without a token surfaces the auth-required message", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "get_api_token", arguments: {} }); + assert.ok(resultText(res).includes("INSTANODE_TOKEN"), "get_api_token did not gate on auth"); + } finally { + await close(); + } + }); + + it("get_api_token with a valid token mints and returns a fresh key", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ name: "get_api_token", arguments: { name: "ci-test-key" } }); + const text = resultText(res); + assert.ok(text.includes("New API key minted."), `expected a minted key:\n${text}`); + assert.ok(/ik_live_/.test(text), `expected the key value in the output:\n${text}`); + } finally { + await close(); + } + }); + }); + + // ── delete_resource error envelopes ───────────────────────────────────────── + + describe("delete_resource", () => { + it("returns a 404-style error for an unknown token", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ + name: "delete_resource", + arguments: { token: "00000000-0000-0000-0000-000000000000" }, + }); + const text = resultText(res); + assert.ok(/404|not found/i.test(text), `expected a not-found error:\n${text}`); + } finally { + await close(); + } + }); + + it("deletes a paid resource and the mock ledger drops it", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const prov = await client.callTool({ name: "create_nosql", arguments: { name: "it-del-mongo" } }); + const token = /Token:\s+(\S+)/.exec(resultText(prov))![1]; + const before = mock.liveResources().length; + + const del = await client.callTool({ name: "delete_resource", arguments: { token } }); + assert.ok(resultText(del).includes("Resource deleted."), "delete_resource did not confirm deletion"); + assert.equal(mock.liveResources().length, before - 1, "mock ledger did not drop the deleted resource"); + } finally { + await close(); + } + }); + }); + + // ── claim helpers ─────────────────────────────────────────────────────────── + + describe("claim helpers", () => { + it("claim_resource builds an API-host /start URL from a raw JWT (pure helper, no network)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "claim_resource", + arguments: { upgrade_jwt: "ey.raw.jwt" }, + }); + const text = resultText(res); + assert.ok(text.includes(`${mock.url}/start?t=ey.raw.jwt`), `expected API-host claim URL:\n${text}`); + } finally { + await close(); + } + }); + + it("claim_resource extracts the JWT from a full /start?t= URL", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "claim_resource", + arguments: { upgrade_jwt: "https://instanode.dev/start?t=ey.url.jwt" }, + }); + assert.ok(resultText(res).includes("/start?t=ey.url.jwt"), "claim_resource did not re-extract the JWT"); + } finally { + await close(); + } + }); + + it("claim_token with upgrade_jwt + email succeeds against POST /claim", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "claim_token", + arguments: { upgrade_jwt: "ey.valid.jwt", email: "dev@example.com" }, + }); + assert.ok(resultText(res).includes("JWT claimed."), `expected a successful claim:\n${resultText(res)}`); + } finally { + await close(); + } + }); + + it("claim_token surfaces the already-claimed conflict from the API", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "claim_token", + arguments: { upgrade_jwt: "invalid.jwt", email: "dev@example.com" }, + }); + const text = resultText(res); + assert.ok(/409|already.?claimed/i.test(text), `expected a conflict error:\n${text}`); + } finally { + await close(); + } + }); + + it("claim_token rejects a malformed email at the schema layer", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ + name: "claim_token", + arguments: { upgrade_jwt: "ey.valid.jwt", email: "not-an-email" }, + }); + assert.ok((res as { isError?: boolean }).isError === true, "claim_token accepted a malformed email"); + } finally { + await close(); + } + }); + }); + + // ── Deployment lifecycle (create → poll → redeploy → delete) ───────────────── + + describe("deployment lifecycle", () => { + it("create_deploy uploads a multipart tarball and returns a building deployment", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + let appId = ""; + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-deploy-basic", port: 3000 }, + }); + const text = resultText(res); + assert.ok(text.includes("Deployment accepted"), `expected an accepted deploy:\n${text}`); + assert.ok(/Status:\s+building/.test(text), `expected status=building:\n${text}`); + appId = /Deploy ID:\s+(\S+)/.exec(text)![1]; + } finally { + await close(); + } + // CLEANUP — mandatory: a real deploy is a kaniko build + k8s pod. + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + const del = await c2.callTool({ name: "delete_deployment", arguments: { id: appId } }); + assert.ok(resultText(del).includes("Deployment deleted."), "deploy cleanup failed"); + } finally { + await close2(); + } + }); + + it("create_deploy with resource_bindings + env_vars merges them into the upload", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + let appId = ""; + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { + tarball_base64: fakeTarballBase64(), + name: "it-deploy-bound", + env_vars: { LOG_LEVEL: "debug" }, + resource_bindings: { DATABASE_URL: "11111111-2222-3333-4444-555555555555" }, + }, + }); + appId = /Deploy ID:\s+(\S+)/.exec(resultText(res))![1]; + const deployment = mock.liveDeployments().find((d) => d.app_id === appId)!; + assert.equal(deployment.env["LOG_LEVEL"], "debug", "env_vars not forwarded"); + assert.equal( + deployment.env["DATABASE_URL"], + "11111111-2222-3333-4444-555555555555", + "resource_bindings not merged into env_vars" + ); + } finally { + await close(); + } + // CLEANUP + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + await c2.callTool({ name: "delete_deployment", arguments: { id: appId } }); + } finally { + await close2(); + } + }); + + it("full lifecycle: create → get (building→running) → redeploy → delete", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + // create + const created = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-lifecycle" }, + }); + const appId = /Deploy ID:\s+(\S+)/.exec(resultText(created))![1]; + + // get — the mock flips building→running on first poll + const got = await client.callTool({ name: "get_deployment", arguments: { id: appId } }); + const gotText = resultText(got); + assert.ok(/Status:\s+running/.test(gotText), `expected status=running after poll:\n${gotText}`); + assert.ok(/https:\/\/.*deployment\.instanode\.dev/.test(gotText), `expected a live URL:\n${gotText}`); + + // list — the deployment must appear + const listed = await client.callTool({ name: "list_deployments", arguments: {} }); + assert.ok(resultText(listed).includes(appId), "deployment missing from list_deployments"); + + // redeploy — status flips back to building + const redeployed = await client.callTool({ name: "redeploy", arguments: { id: appId } }); + assert.ok(/Status:\s+building/.test(resultText(redeployed)), "redeploy did not reset status to building"); + + // delete — MANDATORY teardown + const deleted = await client.callTool({ name: "delete_deployment", arguments: { id: appId } }); + assert.ok(resultText(deleted).includes("Deployment deleted."), "delete_deployment did not confirm"); + assert.ok( + !mock.liveDeployments().some((d) => d.app_id === appId), + "deleted deployment still live on the mock" + ); + } finally { + await close(); + } + }); + + it("get_deployment returns a not-found error for an unknown app id", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ name: "get_deployment", arguments: { id: "app-doesnotexist" } }); + assert.ok(/404|not found/i.test(resultText(res)), "get_deployment did not surface a 404"); + } finally { + await close(); + } + }); + + it("redeploy returns a not-found error for an unknown app id", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ name: "redeploy", arguments: { id: "app-doesnotexist" } }); + assert.ok(/404|not found/i.test(resultText(res)), "redeploy did not surface a 404"); + } finally { + await close(); + } + }); + + it("list_deployments with no deployments returns the empty-state hint", async () => { + // Fresh mock so the deployment ledger is genuinely empty. + const freshMock = await startMockApi(); + const { client, close } = await connectClient(freshMock.url, "valid"); + try { + const res = await client.callTool({ name: "list_deployments", arguments: {} }); + assert.ok(resultText(res).includes("No deployments"), "expected the empty-state message"); + } finally { + await close(); + await freshMock.close(); + } + }); + }); + + // ── Private deploys + tier gating ─────────────────────────────────────────── + + describe("private deploys + tier gating", () => { + it("create_deploy with private=true + allowed_ips succeeds on Pro and echoes the allowlist", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + let appId = ""; + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { + tarball_base64: fakeTarballBase64(), + name: "it-private-crm", + private: true, + allowed_ips: ["1.2.3.4", "10.0.0.0/8"], + }, + }); + const text = resultText(res); + assert.ok(text.includes("Private: true"), `expected the private flag echoed:\n${text}`); + assert.ok(text.includes("1.2.3.4"), `expected the IP allowlist echoed:\n${text}`); + appId = /Deploy ID:\s+(\S+)/.exec(text)![1]; + } finally { + await close(); + } + // CLEANUP + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + await c2.callTool({ name: "delete_deployment", arguments: { id: appId } }); + } finally { + await close2(); + } + }); + }); + + // ── Malformed input handling ──────────────────────────────────────────────── + + describe("malformed input handling", () => { + it("create_deploy rejects a non-string tarball_base64 at the schema layer", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: 12345 as unknown as string, name: "it-bad-tar" }, + }); + assert.ok((res as { isError?: boolean }).isError === true, "create_deploy accepted a numeric tarball"); + } finally { + await close(); + } + }); + + it("create_deploy rejects an out-of-range port", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-bad-port", port: 99999 }, + }); + assert.ok((res as { isError?: boolean }).isError === true, "create_deploy accepted port 99999"); + } finally { + await close(); + } + }); + + it("calling an unknown tool name fails cleanly (error result, not a crash)", async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + // The MCP SDK surfaces an unregistered tool as either a rejected + // JSON-RPC call or an isError result — both are "clean" failures. + // What must NOT happen is the server crashing or hanging. + let failedCleanly = false; + try { + const res = await client.callTool({ name: "no_such_tool", arguments: {} }); + failedCleanly = (res as { isError?: boolean }).isError === true; + } catch { + failedCleanly = true; + } + assert.ok(failedCleanly, "unknown tool was not rejected as an error"); + // The server must still be alive and serving — list a known tool. + const { tools } = await client.listTools(); + assert.ok(tools.length === EXPECTED_TOOLS.length, "server unhealthy after unknown-tool call"); + } finally { + await close(); + } + }); + + it("get_deployment rejects an empty id at the schema layer", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ name: "get_deployment", arguments: { id: "" } }); + assert.ok((res as { isError?: boolean }).isError === true, "get_deployment accepted an empty id"); + } finally { + await close(); + } + }); + }); +}); diff --git a/test/live-smoke.test.ts b/test/live-smoke.test.ts new file mode 100644 index 0000000..1d19880 --- /dev/null +++ b/test/live-smoke.test.ts @@ -0,0 +1,92 @@ +/** + * OPTIONAL live smoke test — provision-then-teardown against a REAL backend. + * + * This file is build-flagged: it is a no-op unless INSTANODE_LIVE_SMOKE=1 is + * set. The hermetic integration suite (integration.test.ts) is the CI gate; + * this is a manual confidence check an operator can run against a live + * cluster, e.g.: + * + * INSTANODE_LIVE_SMOKE=1 \ + * INSTANODE_API_URL=http://localhost:8080 \ + * INSTANODE_TOKEN= \ + * npm test + * + * It deliberately exercises ONLY the cheap, fully-reversible path: + * create_postgres → list_resources (assert present) → delete_resource. + * + * RESOURCE CLEANUP IS MANDATORY here too: the provisioned database is deleted + * in a finally block, and a failure to delete fails the test loudly. It does + * NOT exercise create_deploy — a real kaniko build + k8s pod is too costly + * and slow for a smoke test; the hermetic suite covers the deploy path. + */ + +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const LIVE = process.env["INSTANODE_LIVE_SMOKE"] === "1"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SERVER_ENTRY = resolve(__dirname, "..", "..", "dist", "index.js"); + +function resultText(callResult: unknown): string { + const r = callResult as { content?: Array<{ text?: string }> }; + return (r.content ?? []).map((c) => c.text ?? "").join("\n"); +} + +describe("live smoke (provision-then-teardown)", { skip: !LIVE }, () => { + it("create_postgres → list_resources → delete_resource against the real API", async () => { + const apiUrl = process.env["INSTANODE_API_URL"]; + const token = process.env["INSTANODE_TOKEN"]; + assert.ok(apiUrl, "INSTANODE_API_URL must be set for the live smoke test"); + assert.ok(token, "INSTANODE_TOKEN (a paid bearer) must be set for the live smoke test"); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_ENTRY], + env: { + PATH: process.env["PATH"] ?? "", + INSTANODE_API_URL: apiUrl, + INSTANODE_TOKEN: token, + }, + stderr: "ignore", + }); + const client = new Client({ name: "live-smoke", version: "1.0.0" }, { capabilities: {} }); + await client.connect(transport); + + let provisionedToken = ""; + try { + const created = await client.callTool({ + name: "create_postgres", + arguments: { name: `mcp-live-smoke-${Date.now()}` }, + }); + const text = resultText(created); + assert.ok(text.includes("Postgres database provisioned."), `provision failed:\n${text}`); + provisionedToken = /Token:\s+(\S+)/.exec(text)?.[1] ?? ""; + assert.ok(provisionedToken, "could not parse the provisioned token"); + + const listed = await client.callTool({ name: "list_resources", arguments: {} }); + assert.ok( + resultText(listed).includes(provisionedToken), + "provisioned resource not visible in list_resources" + ); + } finally { + // MANDATORY teardown — a leaked live resource costs money. + if (provisionedToken) { + const deleted = await client.callTool({ + name: "delete_resource", + arguments: { token: provisionedToken }, + }); + assert.ok( + resultText(deleted).includes("Resource deleted."), + `LIVE CLEANUP FAILED — resource ${provisionedToken} may be orphaned` + ); + } + await client.close(); + } + }); +}); diff --git a/test/mock-api.ts b/test/mock-api.ts new file mode 100644 index 0000000..2db2c76 --- /dev/null +++ b/test/mock-api.ts @@ -0,0 +1,625 @@ +/** + * Hermetic in-process mock of the instanode.dev agent API. + * + * The MCP server's only dependency on the outside world is the HTTP REST API + * documented at https://api.instanode.dev/openapi.json. This module stands up + * a real `http.Server` on an ephemeral port that implements that contract + * faithfully enough to exercise every MCP tool end-to-end — success paths, + * error envelopes (401 / 402 / 403 / 429 / 400), the multipart /deploy/new + * upload, and malformed-input handling — with NO external network access. + * + * The integration suite points the spawned MCP server at this mock via the + * INSTANODE_API_URL env var, so `npm test` runs identically in CI and locally. + * + * The mock also keeps an in-memory ledger of every resource / deployment it + * "created" so the test suite's cleanup sweep can assert that nothing leaked. + */ + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; + +/** A resource the mock has "provisioned". */ +export interface MockResource { + id: string; + token: string; + resource_type: string; + tier: string; + status: string; + name: string; + created_at: string; + expires_at: string | null; +} + +/** A deployment the mock has "accepted". */ +export interface MockDeployment { + id: string; + app_id: string; + token: string; + port: number; + tier: string; + status: string; + url: string; + env: Record; + environment: string; + private: boolean; + allowed_ips: string[]; + created_at: string; + updated_at: string; +} + +/** + * The bearer token the mock recognises as a valid paid-tier credential. + * Any other Authorization value is treated as a bad token (401). Requests + * with no Authorization header are treated as anonymous. + */ +export const VALID_TOKEN = "test-bearer-pro-tier"; + +/** A token the mock rejects with 401 — used to exercise bad-auth handling. */ +export const BAD_TOKEN = "test-bearer-revoked"; + +export interface MockApiHandle { + /** Base URL the MCP server should be pointed at (INSTANODE_API_URL). */ + url: string; + /** Underlying server, for shutdown. */ + server: Server; + /** Every resource the mock currently believes is live (not deleted). */ + liveResources(): MockResource[]; + /** Every deployment the mock currently believes is live (not deleted). */ + liveDeployments(): MockDeployment[]; + /** Total count of create_* calls received, for sanity assertions. */ + provisionCount(): number; + /** Total count of /deploy/new calls received. */ + deployCount(): number; + /** Shut the server down. */ + close(): Promise; +} + +interface State { + resources: Map; + deployments: Map; + provisionCalls: number; + deployCalls: number; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function expiry24h(): string { + return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolveBody, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolveBody(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function sendJSON(res: ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(body); +} + +/** Classify the inbound Authorization header. */ +type AuthState = "anonymous" | "valid" | "bad"; +function classifyAuth(req: IncomingMessage): AuthState { + const h = req.headers["authorization"]; + if (!h) return "anonymous"; + const m = /^Bearer\s+(.+)$/.exec(Array.isArray(h) ? h[0] : h); + if (!m) return "bad"; + if (m[1] === VALID_TOKEN) return "valid"; + return "bad"; +} + +/** Standard error envelope, matching the real API's shape. */ +function errorEnvelope(opts: { + error?: string; + message: string; + upgrade_url?: string; + agent_action?: string; + claim_url?: string; +}): Record { + const e: Record = { ok: false, message: opts.message }; + if (opts.error) e["error"] = opts.error; + if (opts.upgrade_url) e["upgrade_url"] = opts.upgrade_url; + if (opts.agent_action) e["agent_action"] = opts.agent_action; + if (opts.claim_url) e["claim_url"] = opts.claim_url; + return e; +} + +/** + * Build a provisioning response for a create_* endpoint. `auth` controls the + * tier and whether an upgrade/claim block is attached. + */ +function provisionResponse( + state: State, + resourceType: string, + name: string, + auth: AuthState, + extra: Record +): Record { + const id = randomUUID(); + const token = randomUUID(); + const tier = auth === "valid" ? "pro" : "anonymous"; + const resource: MockResource = { + id, + token, + resource_type: resourceType, + tier, + status: "active", + name, + created_at: nowIso(), + expires_at: auth === "valid" ? null : expiry24h(), + }; + state.resources.set(token, resource); + state.provisionCalls += 1; + + const body: Record = { + ok: true, + id, + token, + name, + tier, + env: "development", + expires_at: resource.expires_at, + limits: + auth === "valid" + ? { storage_mb: 10240, connections: 20 } + : { storage_mb: 10, connections: 2, expires_in: "24h" }, + ...extra, + }; + if (auth !== "valid") { + body["note"] = + "Anonymous resource — expires in 24h. Claim it to keep it permanently."; + body["upgrade"] = "https://api.instanode.dev/start?t=mock.upgrade.jwt"; + body["upgrade_jwt"] = "mock.upgrade.jwt"; + } + return body; +} + +/** + * Parse a multipart/form-data body just enough to confirm the deploy upload + * carries the `tarball` file part + `name` field. We do not need a full RFC + * parser — we only assert structure. + */ +function parseMultipart( + buf: Buffer, + contentType: string +): { hasTarball: boolean; fields: Record } { + const m = /boundary=(.+)$/.exec(contentType); + const fields: Record = {}; + if (!m) return { hasTarball: false, fields }; + const boundary = `--${m[1]}`; + const text = buf.toString("latin1"); + const parts = text.split(boundary).slice(1, -1); + let hasTarball = false; + for (const part of parts) { + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd === -1) continue; + const headers = part.slice(0, headerEnd); + const value = part.slice(headerEnd + 4).replace(/\r\n$/, ""); + const nameMatch = /name="([^"]+)"/.exec(headers); + if (!nameMatch) continue; + const fieldName = nameMatch[1]; + if (fieldName === "tarball" || headers.includes("filename=")) { + hasTarball = true; + } else { + fields[fieldName] = value; + } + } + return { hasTarball, fields }; +} + +/** + * Start the mock API. Resolves once it is listening on an ephemeral port. + */ +export function startMockApi(): Promise { + const state: State = { + resources: new Map(), + deployments: new Map(), + provisionCalls: 0, + deployCalls: 0, + }; + + const server = createServer(async (req, res) => { + try { + await route(req, res, state); + } catch (err) { + sendJSON(res, 500, errorEnvelope({ message: `mock internal error: ${String(err)}` })); + } + }); + + return new Promise((resolveHandle) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr === null || typeof addr === "string") { + throw new Error("mock api failed to bind a port"); + } + const url = `http://127.0.0.1:${addr.port}`; + resolveHandle({ + url, + server, + liveResources: () => + [...state.resources.values()].filter((r) => r.status !== "deleted"), + liveDeployments: () => + [...state.deployments.values()].filter((d) => d.status !== "deleted"), + provisionCount: () => state.provisionCalls, + deployCount: () => state.deployCalls, + close: () => + new Promise((closeResolve, closeReject) => { + // Drop any keep-alive sockets so close() resolves promptly even + // if a spawned MCP server's HTTP agent left a socket pooled. + server.closeAllConnections(); + server.close((err) => (err ? closeReject(err) : closeResolve())); + }), + }); + }); + }); +} + +async function route(req: IncomingMessage, res: ServerResponse, state: State): Promise { + const method = (req.method ?? "GET").toUpperCase(); + const fullUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + const path = fullUrl.pathname; + const auth = classifyAuth(req); + + // A bad bearer token is rejected up-front on every route, mirroring the + // real API's auth middleware. + if (auth === "bad") { + sendJSON( + res, + 401, + errorEnvelope({ + error: "unauthorized", + message: "invalid or revoked bearer token", + }) + ); + return; + } + + // ── create_* provisioning routes ────────────────────────────────────────── + const provisionRoutes: Record = { + "/db/new": "postgres", + "/cache/new": "cache", + "/nosql/new": "nosql", + "/queue/new": "queue", + "/storage/new": "storage", + "/webhook/new": "webhook", + }; + if (method === "POST" && path in provisionRoutes) { + const resourceType = provisionRoutes[path]; + const raw = await readBody(req); + let parsed: { name?: unknown }; + try { + parsed = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {}; + } catch { + sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" })); + return; + } + const name = typeof parsed.name === "string" ? parsed.name : ""; + if (name.length === 0) { + sendJSON( + res, + 400, + errorEnvelope({ error: "bad_request", message: "name is required" }) + ); + return; + } + const extra: Record = {}; + if (resourceType === "postgres" || resourceType === "cache" || resourceType === "nosql" || resourceType === "queue") { + const scheme = + resourceType === "postgres" + ? "postgres" + : resourceType === "cache" + ? "redis" + : resourceType === "nosql" + ? "mongodb" + : "nats"; + extra["connection_url"] = `${scheme}://user:pass@mock-host:5432/db_${randomUUID().slice(0, 8)}`; + } else if (resourceType === "storage") { + const prefix = `prefix-${randomUUID().slice(0, 8)}`; + extra["connection_url"] = `https://nyc3.mock-spaces.com/instant-shared/${prefix}/`; + extra["endpoint"] = "https://nyc3.mock-spaces.com"; + extra["access_key_id"] = `AK${randomUUID().slice(0, 12)}`; + extra["secret_access_key"] = `SK${randomUUID()}`; + extra["prefix"] = prefix; + } else if (resourceType === "webhook") { + extra["receive_url"] = `http://127.0.0.1/webhook/${randomUUID()}`; + } + sendJSON(res, 201, provisionResponse(state, resourceType, name, auth, extra)); + return; + } + + // ── POST /claim ──────────────────────────────────────────────────────────── + if (method === "POST" && path === "/claim") { + const raw = await readBody(req); + let parsed: { jwt?: unknown; email?: unknown }; + try { + parsed = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {}; + } catch { + sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" })); + return; + } + if (typeof parsed.jwt !== "string" || parsed.jwt.length === 0) { + sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "jwt is required" })); + return; + } + if (typeof parsed.email !== "string" || parsed.email.length === 0) { + sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "email is required" })); + return; + } + if (parsed.jwt === "invalid.jwt") { + sendJSON( + res, + 409, + errorEnvelope({ error: "already_claimed", message: "this JWT has already been claimed" }) + ); + return; + } + sendJSON(res, 200, { + ok: true, + id: randomUUID(), + token: randomUUID(), + resource_type: "postgres", + name: "claimed-resource", + tier: "free", + status: "active", + }); + return; + } + + // ── GET /api/v1/resources ────────────────────────────────────────────────── + if (method === "GET" && path === "/api/v1/resources") { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const items = [...state.resources.values()].filter((r) => r.status !== "deleted"); + sendJSON(res, 200, { ok: true, items, total: items.length }); + return; + } + + // ── DELETE /api/v1/resources/:token ──────────────────────────────────────── + if (method === "DELETE" && path.startsWith("/api/v1/resources/")) { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const token = decodeURIComponent(path.slice("/api/v1/resources/".length)); + const resource = state.resources.get(token); + if (!resource) { + sendJSON(res, 404, errorEnvelope({ error: "not_found", message: "resource not found" })); + return; + } + if (resource.tier === "anonymous" || resource.tier === "free") { + sendJSON( + res, + 403, + errorEnvelope({ + error: "paid_tier_only", + message: "free-tier resources auto-expire and cannot be deleted", + upgrade_url: "https://instanode.dev/pricing", + }) + ); + return; + } + resource.status = "deleted"; + sendJSON(res, 200, { ok: true, token, status: "deleted", message: "resource deleted" }); + return; + } + + // ── POST /api/v1/auth/api-keys ───────────────────────────────────────────── + if (method === "POST" && path === "/api/v1/auth/api-keys") { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const raw = await readBody(req); + let parsed: { name?: unknown } = {}; + try { + parsed = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {}; + } catch { + sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" })); + return; + } + sendJSON(res, 201, { + ok: true, + id: randomUUID(), + name: typeof parsed.name === "string" ? parsed.name : "instanode-mcp", + key: `ik_live_${randomUUID().replace(/-/g, "")}`, + created_at: nowIso(), + }); + return; + } + + // ── POST /deploy/new (multipart) ─────────────────────────────────────────── + if (method === "POST" && path === "/deploy/new") { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "deploy requires authentication" })); + return; + } + const ct = req.headers["content-type"] ?? ""; + const ctStr = Array.isArray(ct) ? ct[0] : ct; + if (!ctStr.startsWith("multipart/form-data")) { + sendJSON( + res, + 400, + errorEnvelope({ error: "bad_request", message: "deploy expects multipart/form-data" }) + ); + return; + } + const raw = await readBody(req); + const { hasTarball, fields } = parseMultipart(raw, ctStr); + if (!hasTarball) { + sendJSON( + res, + 400, + errorEnvelope({ error: "bad_request", message: "tarball file part is required" }) + ); + return; + } + if (!fields["name"] || fields["name"].length === 0) { + sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "name field is required" })); + return; + } + + const isPrivate = fields["private"] === "true"; + // The mock treats the valid token as Pro tier, so private deploys are + // allowed. A dedicated test flips this via the x-mock-tier override below. + const tierOverride = req.headers["x-mock-tier"]; + const effectiveTier = (Array.isArray(tierOverride) ? tierOverride[0] : tierOverride) ?? "pro"; + if (isPrivate && effectiveTier === "hobby") { + sendJSON( + res, + 402, + errorEnvelope({ + error: "tier_upgrade_required", + message: "private deploys require Pro tier or higher", + upgrade_url: "https://instanode.dev/pricing", + agent_action: + "Tell the user private deploys need the Pro plan — have them upgrade at https://instanode.dev/pricing", + }) + ); + return; + } + + let allowedIps: string[] = []; + if (fields["allowed_ips"]) { + try { + const parsedIps = JSON.parse(fields["allowed_ips"]); + if (Array.isArray(parsedIps)) allowedIps = parsedIps.map(String); + } catch { + sendJSON( + res, + 400, + errorEnvelope({ error: "bad_request", message: "allowed_ips must be a JSON array" }) + ); + return; + } + } + let envVars: Record = {}; + if (fields["env_vars"]) { + try { + const parsedEnv = JSON.parse(fields["env_vars"]); + if (parsedEnv && typeof parsedEnv === "object") { + envVars = parsedEnv as Record; + } + } catch { + sendJSON( + res, + 400, + errorEnvelope({ error: "bad_request", message: "env_vars must be a JSON object" }) + ); + return; + } + } + + const id = randomUUID(); + const appId = `app-${id.slice(0, 8)}`; + const deployment: MockDeployment = { + id, + app_id: appId, + token: appId, + port: fields["port"] ? Number(fields["port"]) : 8080, + tier: effectiveTier, + status: "building", + url: "", + env: envVars, + environment: fields["env"] ?? "production", + private: isPrivate, + allowed_ips: allowedIps, + created_at: nowIso(), + updated_at: nowIso(), + }; + state.deployments.set(appId, deployment); + state.deployCalls += 1; + sendJSON(res, 202, { + ok: true, + item: deployment, + note: "Build started — poll get_deployment until status=running.", + }); + return; + } + + // ── GET /api/v1/deployments ──────────────────────────────────────────────── + if (method === "GET" && path === "/api/v1/deployments") { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const items = [...state.deployments.values()].filter((d) => d.status !== "deleted"); + sendJSON(res, 200, { ok: true, items, total: items.length }); + return; + } + + // ── GET /api/v1/deployments/:id ──────────────────────────────────────────── + if (method === "GET" && path.startsWith("/api/v1/deployments/")) { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const id = decodeURIComponent(path.slice("/api/v1/deployments/".length)); + const deployment = state.deployments.get(id); + if (!deployment || deployment.status === "deleted") { + sendJSON(res, 404, errorEnvelope({ error: "not_found", message: "deployment not found" })); + return; + } + // Simulate the build completing: once polled, flip building → running. + if (deployment.status === "building") { + deployment.status = "running"; + deployment.url = `https://${deployment.app_id}.deployment.instanode.dev`; + deployment.updated_at = nowIso(); + } + sendJSON(res, 200, { ok: true, item: deployment }); + return; + } + + // ── POST /deploy/:id/redeploy ────────────────────────────────────────────── + if (method === "POST" && /^\/deploy\/[^/]+\/redeploy$/.test(path)) { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const id = decodeURIComponent(path.slice("/deploy/".length, path.length - "/redeploy".length)); + const deployment = state.deployments.get(id); + if (!deployment || deployment.status === "deleted") { + sendJSON(res, 404, errorEnvelope({ error: "not_found", message: "deployment not found" })); + return; + } + deployment.status = "building"; + deployment.url = ""; + deployment.updated_at = nowIso(); + sendJSON(res, 202, { ok: true, item: deployment }); + return; + } + + // ── DELETE /deploy/:id ───────────────────────────────────────────────────── + if (method === "DELETE" && /^\/deploy\/[^/]+$/.test(path)) { + if (auth !== "valid") { + sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); + return; + } + const id = decodeURIComponent(path.slice("/deploy/".length)); + const deployment = state.deployments.get(id); + if (!deployment || deployment.status === "deleted") { + sendJSON(res, 404, errorEnvelope({ error: "not_found", message: "deployment not found" })); + return; + } + deployment.status = "deleted"; + sendJSON(res, 200, { + ok: true, + id: deployment.id, + token: deployment.app_id, + status: "deleted", + message: "deployment torn down", + }); + return; + } + + // ── Unknown route ────────────────────────────────────────────────────────── + sendJSON(res, 404, errorEnvelope({ error: "not_found", message: `no route for ${method} ${path}` })); +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..26e85d6 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist-test", + "declaration": false, + "declarationMap": false, + "noEmit": false + }, + "include": ["src", "test"], + "exclude": ["node_modules", "dist", "dist-test"] +} From 702cb3146ab3ddb428b71bbdb8ade97347eba65b Mon Sep 17 00:00:00 2001 From: "Claude (Opus 4.7)" Date: Wed, 20 May 2026 03:19:11 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20BugBash=202026-05-20=20Wave=202?= =?UTF-8?q?=20=E2=80=94=20pin=20mock=20to=20openapi.json=20+=205=20client?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the six T17 findings (2 P0s, 4 P1s) flagged in BUGHUNT-2026-05-20-T17.md. Each fix carries a CI-run regression test under "BugBash 2026-05-20 T17 ..." in test/integration.test.ts so a future revert fails the gate immediately. T17 P0-1 — redeploy(): the live api documents POST /deploy/{id}/redeploy as a bare 202 with no body. The previous client typed it as DeployGetResult and the index.ts handler dereferenced result.item.app_id, throwing "TypeError: Cannot read properties of undefined (reading 'app_id')" on every call against the real api. The hermetic mock fabricated {ok, item} so the suite green-lit a contract violation. Now: - client.redeploy() returns {ok, id} and awaits the empty 2xx body without dereferencing anything; - index.ts redeploy handler prints "Redeploy accepted for " and points the agent at get_deployment for the next status; - mock-api.ts emits a bare 202 (Content-Length: 0, no body) so the test path actually exercises the empty-body code. Regression test: "redeploy resolves successfully when the api returns 202 with no body (no TypeError on result.item.app_id)". T17 P0-2 — get_api_token PAT-creating-PAT: the api enforces "PATs cannot mint other PATs" with a 403 + code=pat_cannot_mint_pat. Since the dashboard and get_api_token itself both mint PATs, the typical INSTANODE_TOKEN IS a PAT, so the tool 403'd 100% of the time in its documented "rotate as needed" use case. The previous generic 403 error gave the agent no path forward. Now formatError() maps the code to a clear "use a session JWT — sign in at https://instanode.dev/dashboard, then create a key from the API token UI" headline, and the tool description explicitly states PATs cannot mint PATs. The mock now models a PAT_TOKEN fixture that 403s with the canonical code. Regression tests: - "get_api_token with a PAT bearer surfaces the 'use a session JWT' message (not a generic 403)"; - "get_api_token tool description mentions the session-vs-PAT requirement". T17 P1-1 — name schema regex: the api enforces ^[A-Za-z0-9][A-Za-z0-9 _-]*$ (start-alnum then letters/digits/spaces/ underscores/hyphens). The previous zod schema was min(1).max(64) only — names like "-bad" and "@x" passed locally and 400'd on the api. The shared nameArg and create_deploy.name now both carry the regex, sourced as a named constant API_NAME_PATTERN (matches the MEMORY rule "use named constants, not inline strings"). The mock's name validator also enforces the regex so a single-site fallacy in either layer fails the suite. Regression tests: 11 cases (6 rejected + 5 accepted) per create_postgres, 1 case on create_deploy, plus a registry-iterating coverage test that fails if any future create_* tool ships without the pattern. T17 P1-3 — npm audit clean: 4 vulns (1 high fast-uri, 3 moderate hono + ip-address + express-rate-limit) → 0 vulns after npm audit fix. The CI gate "npm install && npm run build && npm test" now reports "found 0 vulnerabilities". T17 P1-6 — User-Agent sourced from package.json: client.ts no longer contains the hardcoded literal "instanode-mcp/0.11.0" in two places. A single PKG_VERSION + USER_AGENT constant is resolved at module load from the installed package.json — every release naturally rolls forward. Regression test: "client UA string equals 'instanode-mcp/' (no hardcoded 0.11.0 literal)" reads the real package.json, spawns the mcp against a UA-capturing http.Server, and asserts every inbound request carries the resolved UA. T17 P0/P1-5 — mock pinned to openapi.json: pulled the live spec from https://api.instanode.dev/openapi.json and aligned mock-api.ts's response shapes to it. Per-endpoint contract-pinning tests under "BugBash 2026-05-20 T17 P0/P1 — mock-api contract pinning" lock down: - /deploy/:id/redeploy → bare 202, empty body - /api/v1/auth/api-keys → 403 pat_cannot_mint_pat for PATs - /api/v1/auth/api-keys → 400 on missing name, 400 on invalid scope - /claim → 200 ClaimResponse {ok, team_id, user_id, session_token, message}, NOT the legacy 201 direct-claim shape - /db/new (and siblings) → 400 invalid_name on names that fail the api regex Other: - Bumped version 0.11.0 → 0.11.1 (the changes above are non-breaking for the tool surface but reshape internals). - Updated server.json version to match. - Added PAT_TOKEN fixture export to mock-api.ts so future PAT-specific tests can reuse it. Gate output (npm install && npm run build && npm test): - npm install: 0 vulnerabilities - npm run build: tsc clean, 0 errors - npm test: 62 pass / 0 fail / 0 skip (live-smoke SKIP by design) - Previously: 39 pass — the 23 new tests all map to BugBash findings. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 30 +-- package.json | 2 +- server.json | 4 +- src/client.ts | 75 ++++++- src/index.ts | 73 +++++-- test/integration.test.ts | 418 ++++++++++++++++++++++++++++++++++++++- test/mock-api.ts | 174 ++++++++++++---- 7 files changed, 701 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4cb3be3..1088194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "instanode-mcp", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "instanode-mcp", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", @@ -429,12 +429,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -453,9 +453,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -590,9 +590,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.21", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", + "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -641,9 +641,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index 7ac7e62..d8fa925 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server.json b/server.json index b645de9..f2f32b1 100644 --- a/server.json +++ b/server.json @@ -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" }, diff --git a/src/client.ts b/src/client.ts index 466e99d..67abb9e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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; } @@ -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; @@ -360,7 +409,7 @@ export class InstantClient { private headers(): Record { const h: Record = { "Content-Type": "application/json", - "User-Agent": "instanode-mcp/0.11.0", + "User-Agent": USER_AGENT, }; const tok = this.bearerToken(); if (tok) { @@ -375,7 +424,7 @@ export class InstantClient { */ private authHeaders(): Record { const h: Record = { - "User-Agent": "instanode-mcp/0.11.0", + "User-Agent": USER_AGENT, }; const tok = this.bearerToken(); if (tok) { @@ -715,14 +764,30 @@ export class InstantClient { ); } - /** POST /deploy/:id/redeploy — rebuild + rolling update an existing app. */ - async redeploy(id: string): Promise { - return this.request( + /** + * 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 { + // `request` 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( "POST", `/deploy/${encodeURIComponent(id)}/redeploy`, undefined, { requireAuth: true } ); + return { ok: true, id }; } /** DELETE /deploy/:id — tear down the running pod + remove the record. */ diff --git a/src/index.ts b/src/index.ts index 0ae2072..a5e8c7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,18 @@ function formatError(err: unknown): string { } else if (err.status === 403 && err.code === "paid_tier_only") { headline = "Free-tier resource cannot be deleted — it will auto-expire in 24h."; + } else if (err.status === 403 && err.code === "pat_cannot_mint_pat") { + // The /api/v1/auth/api-keys "PAT-creating-a-PAT" gate. The current + // INSTANODE_TOKEN is itself a PAT (the dashboard's "API token" UI and + // get_api_token itself both mint PATs), so this is the overwhelmingly + // common failure mode. Surface a non-generic, actionable headline so + // the agent can route the user to the correct flow rather than retry. + // T17 P0-2. + headline = + "get_api_token requires a browser session JWT (not a Personal Access Token / PAT). " + + "Your current INSTANODE_TOKEN appears to be a PAT — PATs cannot mint new PATs. " + + "Sign in at https://instanode.dev/dashboard, then create a key from the dashboard's API token UI " + + "(or, if you have an existing valid key, just reuse it — PATs are revocation-based, not time-bound)."; } else if (err.status === 429) { headline = "Rate limited (5 anonymous provisions/day per /24 subnet). " + @@ -155,14 +167,32 @@ function appendUpgradeBlock( } } +/** + * Live API name pattern, mirrored verbatim from the openapi.json spec + * (ProvisionRequest.name.pattern + provision_helper.go's runtime validator): + * + * 1-64 chars, must start with a letter or digit, then + * letters / digits / spaces / underscores / hyphens. + * + * Names that pass the previous loose schema (min(1).max(64)) but fail this + * regex were silently accepted by the MCP and 400'd by the api — agents got + * a round-trip + generic "invalid_name" error instead of a precise local + * rejection. T17 P1-1. Sourced as a named constant per the user MEMORY rule + * "use named constants, not inline strings." + */ +const API_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/; +const API_NAME_PATTERN_DESCRIPTION = + "must start with a letter or digit, then letters/digits/spaces/underscores/hyphens"; + // The single-character "name" schema reused by every create_* tool. const nameArg = { name: z .string() .min(1) .max(64) + .regex(API_NAME_PATTERN, `name format: ${API_NAME_PATTERN_DESCRIPTION}`) .describe( - "Human-readable label for this resource (1–64 chars). Surfaced on the dashboard. Example: 'prospector-agent', 'stripe-sandbox'." + `Human-readable label for this resource (1-64 chars; ${API_NAME_PATTERN_DESCRIPTION}). Surfaced on the dashboard. Example: 'prospector-agent', 'stripe-sandbox'.` ), }; @@ -632,15 +662,23 @@ Requires INSTANODE_TOKEN.`, server.tool( "get_api_token", - `Mint a fresh API key for the authenticated caller and return it as plain -text (POST /api/v1/auth/api-keys). The user should paste the returned key -into their MCP server config as INSTANODE_TOKEN (or export it as an env var -for CLI use). - -API keys are revocation-based (not time-bound) — they live until revoked -in the dashboard. Requires an existing INSTANODE_TOKEN to bootstrap a new -one; the typical flow is: claim once via the dashboard (browser), mint a -key, paste it into the MCP env, then use this tool to rotate as needed.`, + `Mint a fresh Personal Access Token (PAT) for the authenticated caller and +return it as plain text (POST /api/v1/auth/api-keys). + +IMPORTANT — this tool requires a browser SESSION JWT, not a PAT. PATs cannot +mint other PATs (the api enforces this with a 403). If your INSTANODE_TOKEN +came from the dashboard's "API token" UI, or from a previous get_api_token +call, it is a PAT and this tool will fail with a clear 403 message. + +The intended flow is: + 1. Sign in at https://instanode.dev/dashboard (cookie-based session). + 2. Use that browser session to create a PAT in the dashboard's API token UI. + 3. Paste the PAT into your MCP config as INSTANODE_TOKEN. + +PATs are revocation-based (not time-bound) — they live until you revoke them +in the dashboard, so "rotation" is best done by minting a new key in the +dashboard UI and revoking the old one, NOT by calling this tool from an +agent context.`, { name: z .string() @@ -729,8 +767,9 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`, .string() .min(1) .max(64) + .regex(API_NAME_PATTERN, `name format: ${API_NAME_PATTERN_DESCRIPTION}`) .describe( - "Human-readable name for this resource, 1-64 chars, letters/numbers/spaces/dashes — shown in the dashboard. Required." + `Human-readable name for this deploy, 1-64 chars (${API_NAME_PATTERN_DESCRIPTION}). Shown in the dashboard. Required.` ), port: z .number() @@ -923,14 +962,16 @@ Requires INSTANODE_TOKEN.`, }, async ({ id }) => { try { + // `redeploy()` resolves with `{ ok, id }` only — the live API returns a + // bare 202 with no body, so we never have a fresh deployment object here. + // The caller will see the new status by polling get_deployment. T17 P0-1. const result = await client.redeploy(id); - const d = result.item; const lines = [ - `Redeploy accepted for ${d.app_id}.`, - `Status: ${d.status}`, + `Redeploy accepted for ${result.id}.`, + `Status: building (rebuild kicked off by the api)`, + ``, + `Poll get_deployment({ id: "${result.id}" }) until status="running".`, ]; - if (d.url) lines.push(`URL: ${d.url}`); - lines.push(``, `Poll get_deployment({ id: "${d.app_id}" }) until status="running".`); return textResult(lines.join("\n")); } catch (err) { return textResult(formatError(err)); diff --git a/test/integration.test.ts b/test/integration.test.ts index 22f8fb4..24568f4 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -42,7 +42,8 @@ import { dirname, resolve } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { startMockApi, type MockApiHandle, VALID_TOKEN, BAD_TOKEN } from "./mock-api.js"; +import { readFileSync } from "node:fs"; +import { startMockApi, type MockApiHandle, VALID_TOKEN, BAD_TOKEN, PAT_TOKEN } from "./mock-api.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); // The server is built to dist/index.js (tsconfig rootDir=src, outDir=dist). @@ -79,7 +80,7 @@ const EXPECTED_TOOLS = [ */ async function connectClient( apiUrl: string, - token: "valid" | "bad" | "none" + token: "valid" | "bad" | "none" | "pat" ): Promise<{ client: Client; close: () => Promise }> { const env: Record = { INSTANODE_API_URL: apiUrl, @@ -88,6 +89,7 @@ async function connectClient( }; if (token === "valid") env["INSTANODE_TOKEN"] = VALID_TOKEN; if (token === "bad") env["INSTANODE_TOKEN"] = BAD_TOKEN; + if (token === "pat") env["INSTANODE_TOKEN"] = PAT_TOKEN; const transport = new StdioClientTransport({ command: process.execPath, @@ -819,4 +821,416 @@ describe("instanode-mcp integration suite", () => { } }); }); + + // ── BugBash 2026-05-20 regression tests ──────────────────────────────────── + // + // Each `it()` here pins exactly one of the T17 findings so a future change + // that reverts a fix fails this suite immediately. Keep them grouped (and + // their issue refs in the title) so the failure message points straight at + // the original report. + + describe("BugBash 2026-05-20 T17 P0-1 — redeploy tolerates bare-202 empty body", () => { + it("redeploy resolves successfully when the api returns 202 with no body (no TypeError on result.item.app_id)", async () => { + // The live api documents POST /deploy/{id}/redeploy as a bare 202 with + // no body schema. The previous client typed it as DeployGetResult and + // the index.ts handler dereferenced `result.item.app_id`, throwing + // `TypeError: Cannot read properties of undefined (reading 'app_id')`. + // The fixed mock now returns a bare 202 (no JSON body) so this test + // genuinely exercises the empty-body path. T17 P0-1. + const { client, close } = await connectClient(mock.url, "valid"); + let appId = ""; + try { + const created = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-redeploy-bare-202" }, + }); + appId = /Deploy ID:\s+(\S+)/.exec(resultText(created))![1]; + + // Promote building → running by polling once. + await client.callTool({ name: "get_deployment", arguments: { id: appId } }); + + // The act: redeploy must not throw, must include a clear "Redeploy + // accepted" headline, and must NOT contain any sign of an undefined + // dereference (the old failure mode). + const res = await client.callTool({ name: "redeploy", arguments: { id: appId } }); + const text = resultText(res); + assert.ok(text.includes("Redeploy accepted"), `expected a clean redeploy headline:\n${text}`); + assert.ok(text.includes(appId), `expected the redeploy output to echo the id ${appId}:\n${text}`); + assert.ok( + !/undefined|cannot read prop|TypeError/i.test(text), + `redeploy output looks like an undefined-deref crash:\n${text}` + ); + // The result must not advertise a status or URL field the api didn't + // give us — the bare-202 contract has neither. + assert.ok( + !text.includes("Status: running"), + `redeploy must not claim a status it did not receive:\n${text}` + ); + } finally { + await close(); + } + // CLEANUP + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + await c2.callTool({ name: "delete_deployment", arguments: { id: appId } }); + } finally { + await close2(); + } + }); + }); + + describe("BugBash 2026-05-20 T17 P0-2 — get_api_token surfaces a clear error for PATs", () => { + it("get_api_token with a PAT bearer surfaces the 'use a session JWT' message (not a generic 403)", async () => { + // The live api enforces "PATs cannot mint other PATs" — a 403 with code + // `pat_cannot_mint_pat` on POST /api/v1/auth/api-keys when the caller is + // a PAT. Since `get_api_token` itself mints PATs, the typical INSTANODE_TOKEN + // value IS a PAT — meaning this tool fails 100% of the time in its + // documented "rotate as needed" use case unless the user surfaces the + // restriction. T17 P0-2. + const { client, close } = await connectClient(mock.url, "pat"); + try { + const res = await client.callTool({ name: "get_api_token", arguments: {} }); + const text = resultText(res); + // Headline must name the constraint plainly. Looking for the canonical + // failure-mode keywords; if the message ever drifts away from these, + // an agent reading the output will be unable to recover. + assert.ok(/Personal Access Token|PAT/i.test(text), `expected a PAT-aware error message:\n${text}`); + assert.ok(/session/i.test(text), `expected the error to mention a session JWT:\n${text}`); + assert.ok(text.includes("instanode.dev/dashboard"), `expected the dashboard link:\n${text}`); + // The generic "instanode.dev error (403):" preamble (which produced + // confused agent retries) MUST NOT be the entire headline. + assert.ok( + !/^instanode\.dev error \(403\): upstream error\s*$/.test(text.trim()), + `error fell through to the generic 403 path:\n${text}` + ); + } finally { + await close(); + } + }); + + it("get_api_token tool description mentions the session-vs-PAT requirement", async () => { + // Locking the description down prevents the "rotate as needed" claim + // from creeping back in. The fix at T17 P0-2 rewrote the description + // to explicitly state PATs can't mint PATs. + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "get_api_token")!; + const desc = tool.description ?? ""; + assert.ok(/PAT|Personal Access Token/i.test(desc), "get_api_token description must mention PATs"); + assert.ok(/session/i.test(desc), "get_api_token description must mention session JWTs"); + assert.ok(!/rotate as needed/i.test(desc), "the misleading 'rotate as needed' line must be gone"); + } finally { + await close(); + } + }); + }); + + describe("BugBash 2026-05-20 T17 P1 — name schema mirrors the live api regex", () => { + // The api enforces `^[A-Za-z0-9][A-Za-z0-9 _-]*$` (start-alnum then + // letters/digits/spaces/underscores/hyphens, 1-64 chars). The previous + // mcp schema used `min(1).max(64)` only — names like "-bad" or "@x" + // passed locally and 400'd on the api with a generic invalid_name. + // T17 P1-1. + const REJECTED = [ + "-leading-dash", + " starts-with-space", + "@invalid", + ".dotty", + "has/slash", + "name\twith\ttab", + ]; + const ACCEPTED = [ + "valid-name", + "valid_name", + "Valid Name 1", + "123start", + "a", + ]; + + for (const bad of REJECTED) { + it(`create_postgres rejects ${JSON.stringify(bad)} at the zod regex layer (no round-trip to the api)`, async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "create_postgres", arguments: { name: bad } }); + assert.ok( + (res as { isError?: boolean }).isError === true, + `create_postgres accepted ${JSON.stringify(bad)}: ${JSON.stringify(res)}` + ); + } finally { + await close(); + } + }); + } + + for (const good of ACCEPTED) { + it(`create_postgres accepts ${JSON.stringify(good)} (matches the live api pattern)`, async () => { + const { client, close } = await connectClient(mock.url, "none"); + try { + const res = await client.callTool({ name: "create_postgres", arguments: { name: good } }); + assert.ok( + (res as { isError?: boolean }).isError !== true, + `create_postgres rejected ${JSON.stringify(good)} which should be valid: ${JSON.stringify(res)}` + ); + } finally { + await close(); + } + }); + } + + it("create_deploy mirrors the same name regex (start-alnum, no leading dash)", async () => { + const { client, close } = await connectClient(mock.url, "valid"); + try { + const res = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "-leading-dash" }, + }); + assert.ok( + (res as { isError?: boolean }).isError === true, + `create_deploy accepted a leading-dash name: ${JSON.stringify(res)}` + ); + } finally { + await close(); + } + }); + + it("every create_* tool's name schema has a non-empty pattern (coverage block — guards against single-site fallacy)", async () => { + // Enumerate every create_* tool the registry advertises and assert + // each one carries a non-empty regex pattern on its `name` field. + // The previous build added the regex to `create_deploy` but missed + // the shared `nameArg` (single-site fallacy); the fix updated both, + // and this test fails if either drifts away from the api contract. + const { client, close } = await connectClient(mock.url, "none"); + try { + const { tools } = await client.listTools(); + const namedCreates = tools.filter((t) => /^create_/.test(t.name)); + assert.ok(namedCreates.length >= 7, `expected ≥7 create_* tools, got ${namedCreates.length}`); + for (const tool of namedCreates) { + const schema = tool.inputSchema as { + properties?: Record; + }; + const nameProp = schema.properties?.["name"]; + assert.ok(nameProp, `${tool.name} has no name property`); + assert.ok( + typeof nameProp.pattern === "string" && nameProp.pattern.length > 0, + `${tool.name} name schema is missing the api regex (pattern=${JSON.stringify(nameProp.pattern)})` + ); + } + } finally { + await close(); + } + }); + }); + + describe("BugBash 2026-05-20 T17 P1 — User-Agent reflects package.json version", () => { + it("client UA string equals 'instanode-mcp/' (no hardcoded 0.11.0 literal)", async () => { + // T17 P1-6: the client previously hardcoded "instanode-mcp/0.11.0" in two + // places. After a version bump, server-side analytics, rate-limit + // attribution, and abuse triage keyed on UA would see stale data forever. + // This test loads the real package.json + spawns the server pointed at + // a UA-capturing mock to confirm the UA is sourced from package.json. + const here = dirname(fileURLToPath(import.meta.url)); + const pkgPath = resolve(here, "..", "..", "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }; + const expectedUA = `instanode-mcp/${pkg.version}`; + + // Capture the UA on a real http.Server pointed at the spawned mcp. + const observedUAs: string[] = []; + const captureServer = await createMockApiThatCapturesUA(observedUAs); + try { + const { client, close } = await connectClient(captureServer.url, "none"); + try { + // Any tool that triggers an HTTP call works — create_postgres on + // anonymous tier hits POST /db/new without auth. + await client.callTool({ name: "create_postgres", arguments: { name: "it-ua-check" } }); + } finally { + await close(); + } + } finally { + await captureServer.close(); + } + + assert.ok(observedUAs.length > 0, "no requests reached the UA-capture server"); + for (const ua of observedUAs) { + assert.equal( + ua, + expectedUA, + `User-Agent mismatch: expected ${expectedUA}, got ${ua}` + ); + } + // Anti-drift guard against the literal that used to be hardcoded. + // If anyone ever re-pins a hardcoded "instanode-mcp/", + // this assert fires the moment package.json moves past that version. + assert.ok( + !observedUAs.some((u) => /instanode-mcp\/0\.11\.0/.test(u)) || + pkg.version === "0.11.0", + `client is still sending the old hardcoded 0.11.0 UA after a version bump (saw ${observedUAs.join(", ")})` + ); + }); + }); + + describe("BugBash 2026-05-20 T17 P0/P1 — mock-api contract pinning", () => { + // These tests don't drive the mcp server — they sanity-check the mock + // itself against the live openapi.json contract. If the mock ever drifts + // back to its earlier fiction (a 202 with `{ok,item}`, an api-keys 201 + // for any caller, the legacy direct-claim shape), these fire. + + it("POST /deploy/:id/redeploy returns a bare 202 with no body (matches openapi.json)", async () => { + // T17 P0-1: the prior mock returned {ok, item: deployment} on 202, + // letting the broken redeploy client pass tests against fiction. + const { client, close } = await connectClient(mock.url, "valid"); + let appId = ""; + try { + const created = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-mock-bare202" }, + }); + appId = /Deploy ID:\s+(\S+)/.exec(resultText(created))![1]; + + // Direct fetch — bypass the mcp client to inspect the raw response. + const resp = await fetch(`${mock.url}/deploy/${appId}/redeploy`, { + method: "POST", + headers: { Authorization: `Bearer ${VALID_TOKEN}` }, + }); + assert.equal(resp.status, 202, `expected 202, got ${resp.status}`); + const body = await resp.text(); + assert.equal(body, "", `expected an empty body, got: ${body}`); + } finally { + await close(); + } + // CLEANUP + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + await c2.callTool({ name: "delete_deployment", arguments: { id: appId } }); + } finally { + await close2(); + } + }); + + it("POST /api/v1/auth/api-keys returns 403 pat_cannot_mint_pat when the caller is a PAT", async () => { + // T17 P0-2: the prior mock unconditionally returned 201 — masked the + // entire PAT-creating-PAT failure mode. + const resp = await fetch(`${mock.url}/api/v1/auth/api-keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${PAT_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "should-be-rejected", scopes: ["read", "write"] }), + }); + assert.equal(resp.status, 403, `expected 403, got ${resp.status}`); + const body = (await resp.json()) as { error?: string; message?: string }; + assert.equal(body.error, "pat_cannot_mint_pat", `unexpected error code: ${JSON.stringify(body)}`); + }); + + it("POST /api/v1/auth/api-keys requires a name field (per openapi.json)", async () => { + // openapi.json marks `name` required on this endpoint; the prior mock + // accepted a missing name. + const resp = await fetch(`${mock.url}/api/v1/auth/api-keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${VALID_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + assert.equal(resp.status, 400, `expected 400 for missing name, got ${resp.status}`); + }); + + it("POST /api/v1/auth/api-keys rejects an invalid scope (per openapi.json enum)", async () => { + const resp = await fetch(`${mock.url}/api/v1/auth/api-keys`, { + method: "POST", + headers: { + Authorization: `Bearer ${VALID_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: "k", scopes: ["god"] }), + }); + assert.equal(resp.status, 400, `expected 400 for bad scope, got ${resp.status}`); + }); + + it("POST /claim returns 200 with the ClaimResponse magic-link shape (not the legacy 201 direct-claim)", async () => { + // T17 P1-5: prior mock returned {id, token, resource_type, tier, status} + // — the legacy 201 shape the live api retired. The real response is + // {ok, team_id, user_id, session_token, message}. + const resp = await fetch(`${mock.url}/claim`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jwt: "ey.valid.jwt", email: "dev@example.com" }), + }); + assert.equal(resp.status, 200, `expected 200, got ${resp.status}`); + const body = (await resp.json()) as Record; + assert.equal(typeof body["ok"], "boolean", "missing ok"); + assert.equal(typeof body["session_token"], "string", "missing session_token"); + assert.ok("team_id" in body, "missing team_id"); + assert.ok("user_id" in body, "missing user_id"); + // The legacy fields MUST be gone — if they reappear, the mock has drifted. + assert.equal(body["resource_type"], undefined, "legacy resource_type leaked"); + assert.equal(body["token"], undefined, "legacy token leaked"); + assert.equal(body["tier"], undefined, "legacy tier leaked"); + }); + + it("provisioning routes 400 on a name that fails the live api pattern (start-alnum + spaces/underscores/dashes)", async () => { + // Names like "-bad", "@x", "has/slash" pass the loose old mock and the + // loose old client schema but 400 on the live api. The mock now mirrors + // the regex so the test catches schema drift. + const resp = await fetch(`${mock.url}/db/new`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "-bad" }), + }); + assert.equal(resp.status, 400, `expected 400 for bad name, got ${resp.status}`); + const body = (await resp.json()) as { error?: string }; + assert.equal(body.error, "invalid_name", `unexpected error: ${JSON.stringify(body)}`); + }); + }); }); + +// ── UA-capturing mock server (used by the User-Agent regression test only) ──── +// +// A tiny http.Server that records every inbound User-Agent header and otherwise +// responds like the anonymous-tier /db/new happy path. Kept separate from the +// main mock so the rest of the suite is unaffected. +import { createServer, type Server as HttpServer } from "node:http"; + +async function createMockApiThatCapturesUA( + observed: string[] +): Promise<{ url: string; close: () => Promise }> { + const server: HttpServer = createServer((req, res) => { + const ua = req.headers["user-agent"]; + if (typeof ua === "string") observed.push(ua); + // Drain the request body so the client sees the response. + req.on("data", () => undefined); + req.on("end", () => { + res.writeHead(201, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + ok: true, + id: "00000000-0000-0000-0000-000000000000", + token: "00000000-0000-0000-0000-000000000000", + name: "ua-check", + tier: "anonymous", + env: "development", + expires_at: null, + limits: { storage_mb: 10, connections: 2, expires_in: "24h" }, + connection_url: "postgres://u:p@host/db", + note: "stub", + upgrade: "https://api.instanode.dev/start?t=stub", + upgrade_jwt: "stub", + }) + ); + }); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const addr = server.address(); + if (addr === null || typeof addr === "string") throw new Error("UA mock failed to bind a port"); + const url = `http://127.0.0.1:${addr.port}`; + return { + url, + close: () => + new Promise((resolveClose, rejectClose) => { + server.closeAllConnections(); + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }; +} diff --git a/test/mock-api.ts b/test/mock-api.ts index 2db2c76..529bad0 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -57,6 +57,23 @@ export const VALID_TOKEN = "test-bearer-pro-tier"; /** A token the mock rejects with 401 — used to exercise bad-auth handling. */ export const BAD_TOKEN = "test-bearer-revoked"; +/** + * A valid bearer token whose AuthMode is "pat" (Personal Access Token). + * + * The live API enforces "PATs cannot mint other PATs" — `POST /api/v1/auth/api-keys` + * returns 403 when the caller is themselves a PAT. The mock mirrors that contract + * via this fixture: any request authenticated with `PAT_TOKEN` is treated as a PAT, + * and `/api/v1/auth/api-keys` will return 403 with the documented error. + * + * Real-world note: the dashboard's "API token" UI mints PATs, and the MCP's + * `get_api_token` tool itself mints PATs. So the typical `INSTANODE_TOKEN` + * value IS a PAT — meaning `get_api_token` would 403 in its documented + * "rotate as needed" use case. The MCP's `get_api_token` tool surfaces a + * clear "use a session token, not a PAT" message on this 403; this fixture + * lets the integration test pin that behavior. + */ +export const PAT_TOKEN = "test-bearer-pat-pro-tier"; + export interface MockApiHandle { /** Base URL the MCP server should be pointed at (INSTANODE_API_URL). */ url: string; @@ -105,16 +122,46 @@ function sendJSON(res: ServerResponse, status: number, payload: unknown): void { } /** Classify the inbound Authorization header. */ -type AuthState = "anonymous" | "valid" | "bad"; +type AuthState = "anonymous" | "valid" | "pat" | "bad"; function classifyAuth(req: IncomingMessage): AuthState { const h = req.headers["authorization"]; if (!h) return "anonymous"; const m = /^Bearer\s+(.+)$/.exec(Array.isArray(h) ? h[0] : h); if (!m) return "bad"; if (m[1] === VALID_TOKEN) return "valid"; + if (m[1] === PAT_TOKEN) return "pat"; return "bad"; } +/** + * Live API name pattern: 1-64 chars, must start with a letter or digit, then + * letters/digits/spaces/underscores/hyphens. Sourced verbatim from + * `ProvisionRequest.name.pattern` in api/internal/handlers/openapi.go (and the + * runtime validator in provision_helper.go:558-662). The previous mock only + * checked `name.length===0` which masked the MCP's name-pattern gap — adding + * the regex here means the mock now rejects bad names with `invalid_name` + * the same way prod does. + */ +const API_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/; + +/** Returns the validation error code (or null if name is valid). */ +function validateName(name: unknown): { error: string; message: string } | null { + if (typeof name !== "string" || name.length === 0) { + return { error: "name_required", message: "name is required" }; + } + if (name.length > 64) { + return { error: "invalid_name", message: "name must be 1-64 characters" }; + } + if (!API_NAME_PATTERN.test(name)) { + return { + error: "invalid_name", + message: + "name must start with a letter or digit, then letters/digits/spaces/underscores/hyphens", + }; + } + return null; +} + /** Standard error envelope, matching the real API's shape. */ function errorEnvelope(opts: { error?: string; @@ -144,7 +191,8 @@ function provisionResponse( ): Record { const id = randomUUID(); const token = randomUUID(); - const tier = auth === "valid" ? "pro" : "anonymous"; + const paid = auth === "valid" || auth === "pat"; + const tier = paid ? "pro" : "anonymous"; const resource: MockResource = { id, token, @@ -153,7 +201,7 @@ function provisionResponse( status: "active", name, created_at: nowIso(), - expires_at: auth === "valid" ? null : expiry24h(), + expires_at: paid ? null : expiry24h(), }; state.resources.set(token, resource); state.provisionCalls += 1; @@ -166,13 +214,12 @@ function provisionResponse( tier, env: "development", expires_at: resource.expires_at, - limits: - auth === "valid" - ? { storage_mb: 10240, connections: 20 } - : { storage_mb: 10, connections: 2, expires_in: "24h" }, + limits: paid + ? { storage_mb: 10240, connections: 20 } + : { storage_mb: 10, connections: 2, expires_in: "24h" }, ...extra, }; - if (auth !== "valid") { + if (!paid) { body["note"] = "Anonymous resource — expires in 24h. Claim it to keep it permanently."; body["upgrade"] = "https://api.instanode.dev/start?t=mock.upgrade.jwt"; @@ -300,15 +347,12 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" })); return; } - const name = typeof parsed.name === "string" ? parsed.name : ""; - if (name.length === 0) { - sendJSON( - res, - 400, - errorEnvelope({ error: "bad_request", message: "name is required" }) - ); + const nameErr = validateName(parsed.name); + if (nameErr) { + sendJSON(res, 400, errorEnvelope(nameErr)); return; } + const name = parsed.name as string; const extra: Record = {}; if (resourceType === "postgres" || resourceType === "cache" || resourceType === "nosql" || resourceType === "queue") { const scheme = @@ -334,7 +378,15 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P return; } + // Any authenticated session — covers session JWTs *and* PATs. The /api/v1/auth/api-keys + // route is the one exception (it requires a session, not a PAT) and handles that + // distinction in its own branch below. + const authed = auth === "valid" || auth === "pat"; + // ── POST /claim ──────────────────────────────────────────────────────────── + // Per openapi.json: returns 200 ClaimResponse {ok, team_id, user_id, session_token, + // message} — the magic-link flow. The legacy 201 direct-claim shape (the old + // {id, token, resource_type, tier, status} body) has been retired in the live API. if (method === "POST" && path === "/claim") { const raw = await readBody(req); let parsed: { jwt?: unknown; email?: unknown }; @@ -362,19 +414,17 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } sendJSON(res, 200, { ok: true, - id: randomUUID(), - token: randomUUID(), - resource_type: "postgres", - name: "claimed-resource", - tier: "free", - status: "active", + team_id: randomUUID(), + user_id: randomUUID(), + session_token: `session.${randomUUID()}.jwt`, + message: "Magic link sent to email", }); return; } // ── GET /api/v1/resources ────────────────────────────────────────────────── if (method === "GET" && path === "/api/v1/resources") { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; } @@ -385,7 +435,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P // ── DELETE /api/v1/resources/:token ──────────────────────────────────────── if (method === "DELETE" && path.startsWith("/api/v1/resources/")) { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; } @@ -413,23 +463,72 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } // ── POST /api/v1/auth/api-keys ───────────────────────────────────────────── + // Per openapi.json: "PATs cannot mint other PATs (the request fails with 403 when + // the caller is themselves a PAT, not a user session)." Mirrors api/internal/ + // handlers/openapi.go and the live api's auth middleware AuthMode==pat gate. + // `name` is required (not optional) and scopes are restricted to read/write/admin. if (method === "POST" && path === "/api/v1/auth/api-keys") { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; } + if (auth === "pat") { + sendJSON( + res, + 403, + errorEnvelope({ + error: "pat_cannot_mint_pat", + message: + "Personal Access Tokens cannot mint other PATs — use a browser session JWT (sign in at https://instanode.dev/dashboard).", + }) + ); + return; + } const raw = await readBody(req); - let parsed: { name?: unknown } = {}; + let parsed: { name?: unknown; scopes?: unknown } = {}; try { parsed = raw.length > 0 ? JSON.parse(raw.toString("utf8")) : {}; } catch { sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "malformed JSON body" })); return; } + if (typeof parsed.name !== "string" || parsed.name.length === 0) { + sendJSON(res, 400, errorEnvelope({ error: "name_required", message: "name is required" })); + return; + } + if (parsed.name.length > 120) { + sendJSON( + res, + 400, + errorEnvelope({ error: "invalid_name", message: "name must be 1-120 characters" }) + ); + return; + } + if (parsed.scopes !== undefined) { + if (!Array.isArray(parsed.scopes)) { + sendJSON( + res, + 400, + errorEnvelope({ error: "invalid_scopes", message: "scopes must be an array of strings" }) + ); + return; + } + for (const s of parsed.scopes) { + if (s !== "read" && s !== "write" && s !== "admin") { + sendJSON( + res, + 400, + errorEnvelope({ error: "invalid_scopes", message: `invalid scope: ${String(s)}` }) + ); + return; + } + } + } sendJSON(res, 201, { ok: true, id: randomUUID(), - name: typeof parsed.name === "string" ? parsed.name : "instanode-mcp", + name: parsed.name, + scopes: Array.isArray(parsed.scopes) ? parsed.scopes : ["read", "write", "admin"], key: `ik_live_${randomUUID().replace(/-/g, "")}`, created_at: nowIso(), }); @@ -438,7 +537,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P // ── POST /deploy/new (multipart) ─────────────────────────────────────────── if (method === "POST" && path === "/deploy/new") { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "deploy requires authentication" })); return; } @@ -462,8 +561,9 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P ); return; } - if (!fields["name"] || fields["name"].length === 0) { - sendJSON(res, 400, errorEnvelope({ error: "bad_request", message: "name field is required" })); + const nameErr = validateName(fields["name"]); + if (nameErr) { + sendJSON(res, 400, errorEnvelope(nameErr)); return; } @@ -547,7 +647,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P // ── GET /api/v1/deployments ──────────────────────────────────────────────── if (method === "GET" && path === "/api/v1/deployments") { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; } @@ -558,7 +658,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P // ── GET /api/v1/deployments/:id ──────────────────────────────────────────── if (method === "GET" && path.startsWith("/api/v1/deployments/")) { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; } @@ -579,8 +679,12 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } // ── POST /deploy/:id/redeploy ────────────────────────────────────────────── + // Per openapi.json: bare 202 response, NO body schema. The previous mock returned + // {ok, item: deployment} — that masked a real prod bug where the MCP client typed + // the response as DeployGetResult and dereferenced .item.app_id, throwing on the + // empty-body 202 from the real API. T17 P0-1. if (method === "POST" && /^\/deploy\/[^/]+\/redeploy$/.test(path)) { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; } @@ -593,13 +697,15 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P deployment.status = "building"; deployment.url = ""; deployment.updated_at = nowIso(); - sendJSON(res, 202, { ok: true, item: deployment }); + // Bare 202 — Content-Length 0, no body. Matches the live API contract verbatim. + res.writeHead(202); + res.end(); return; } // ── DELETE /deploy/:id ───────────────────────────────────────────────────── if (method === "DELETE" && /^\/deploy\/[^/]+$/.test(path)) { - if (auth !== "valid") { + if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); return; }