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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@
backup_restore_enabled: boolean;
manual_backups_per_day: number;
rpo_minutes: number;
rto_minutes: number;

Check warning on line 294 in src/client.ts

View workflow job for this annotation

GitHub Actions / typos

"rto" should be "to".
annual_discount_percent: number;
/**
* Pricing-page URL for upgrading INTO a higher tier, or null on the terminal
Expand Down Expand Up @@ -363,7 +363,7 @@
* Response shape from POST /deploy/:id/redeploy.
*
* The live API documents this as a bare 202 with NO body (see openapi.json),
* not a deployment record. The previous client mis-typed it as DeployGetResult

Check warning on line 366 in src/client.ts

View workflow job for this annotation

GitHub Actions / typos

"mis" should be "miss" or "mist".
* and the index.ts handler dereferenced `result.item.app_id`, blowing up
* with "Cannot read properties of undefined (reading 'app_id')" on every
* real call. BugBash B16 F1 (regression of task #170): use a body-less type
Expand Down Expand Up @@ -497,6 +497,33 @@
connection_url: string;
}

/**
* Response shape from POST /api/v1/leads (LeadsHandler.Create).
*
* Enterprise interest / contact form. Only `email` is required;
* `name`, `company`, and `use_case` are optional enrichment fields.
* No authentication required — anonymous callers are accepted. When
* called with a valid Bearer token the lead is automatically linked to
* the caller's team so sales can see the account.
*/
export interface LeadResult {
ok: boolean;
/** UUID of the newly created enterprise_leads row. */
id?: string;
}

/** Caller-supplied params for create_lead. */
export interface CreateLeadParams {
/** Contact email address. Required. RFC 5322 + 254-char cap. */
email: string;
/** Contact full name. Optional — max 128 chars. */
name?: string;
/** Company or organisation name. Optional — max 128 chars. */
company?: string;
/** Plain-text description of the use case / scale requirements. Optional — max 1024 chars. */
use_case?: string;
}

/**
* Response shape from POST /deploy/:id/wake (DeployHandler.Wake).
*
Expand Down Expand Up @@ -1701,4 +1728,14 @@
{ requireAuth: true }
);
}

/**
* POST /api/v1/leads — submit an enterprise contact / interest form.
*
* No authentication required; authenticated callers auto-link the lead
* to their team. Returns the UUID of the created record on 201.
*/
async createLead(params: CreateLeadParams): Promise<LeadResult> {
return this.request<LeadResult>("POST", "/api/v1/leads", params);
}
}
64 changes: 64 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
* connection_url (POST .../rotate-credentials)
* wake_deployment — explicitly wake a scaled-to-zero deployment
* (POST /deploy/:id/wake; 501 when the flag is off)
* create_lead — submit an enterprise contact form (POST /api/v1/leads)
* when the user needs capacity beyond Pro (dedicated
* infra, SSO, compliance). No auth required.
*
* Every create_* tool surfaces the API's `note` and `upgrade` fields so the
* agent can show the user the exact CTA + claim URL needed to keep the
Expand Down Expand Up @@ -1845,7 +1848,7 @@
- deployments_apps — max concurrent deployed apps; -1 = unlimited
- price_usd_monthly, paid_from_day_one, annual_discount_percent
- backup_retention_days / backup_restore_enabled / manual_backups_per_day
- rpo_minutes / rto_minutes (durability promise; 0 = not promised)

Check warning on line 1851 in src/index.ts

View workflow job for this annotation

GitHub Actions / typos

"rto" should be "to".
- upgrade_url (null on the terminal Team tier) + is_terminal_tier

Tiers are returned in upgrade order (cheapest first). NO INSTANODE_TOKEN
Expand Down Expand Up @@ -2355,6 +2358,67 @@
}
);

// ── Tool: create_lead ─────────────────────────────────────────────────────────

server.tool(
"create_lead",
`Submit an enterprise contact / interest form to instanode.dev
(POST /api/v1/leads).

Use this when the user needs capacity or features beyond the Pro tier —
dedicated infrastructure, SAML/SSO, SOC 2 compliance, a custom SLA, or any
other Enterprise-tier requirement. It directly reaches the instanode.dev team
and is faster than a cold email.

Only 'email' is required. Providing 'company' and 'use_case' gives the team
context to respond with an accurate quote without a back-and-forth.

No INSTANODE_TOKEN needed — anonymous callers are accepted. When called with
a valid bearer token the lead is automatically linked to the caller's team
so the sales team can see the account's current usage.

Returns the UUID of the created lead record on success.`,
{
email: z
.string()
.email()
.max(254)
.describe("Contact email address. Required. Must be a valid RFC 5322 address (max 254 chars)."),
contact_name: z
.string()
.max(128)
.optional()
.describe("Contact full name. Optional — max 128 chars."),
company: z
.string()
.max(128)
.optional()
.describe("Company or organisation name. Optional — max 128 chars."),
use_case: z
.string()
.max(1024)
.optional()
.describe(
"Plain-text description of scale requirements or the use case driving the Enterprise inquiry. Optional — max 1024 chars."
),
},
async ({ email, contact_name, company, use_case }) => {
try {
const result = await client.createLead({ email, name: contact_name, company, use_case });
const lines = [
`Enterprise inquiry submitted.`,
`Lead ID: ${result.id ?? "(pending)"}`,
``,
`The instanode.dev team will follow up at ${email}.`,
`Typical response time: 1–2 business days.`,
];
return textResult(lines.join("\n"));
} catch (err) {
return errorResult(err);
}
}
);

// ── CLI flags (BUG-MCP-017) ────────────────────────────────────────────────────

// `instanode-mcp --version` and `instanode-mcp --help` short-circuit before
Expand Down
10 changes: 7 additions & 3 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const EXPECTED_TOOLS = [
"resume_resource",
"rotate_credentials",
"wake_deployment",
"create_lead",
] as const;

/**
Expand Down Expand Up @@ -215,7 +216,7 @@ describe("instanode-mcp integration suite", () => {
// ── Tool registry + schemas ─────────────────────────────────────────────────

describe("tool registry", () => {
it("registers exactly the 30 contract tools, no dead ones", async () => {
it("registers exactly the 31 contract tools, no dead ones", async () => {
const { client, close } = await connectClient(mock.url, "none");
try {
const { tools } = await client.listTools();
Expand Down Expand Up @@ -1058,8 +1059,11 @@ describe("instanode-mcp integration suite", () => {
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}`);
// create_lead is a contact form (no resource `name` field) — exclude from
// the provisioning name-schema check. All other create_* tools provision
// named resources and must carry the api name regex.
const namedCreates = tools.filter((t) => /^create_/.test(t.name) && t.name !== "create_lead");
assert.ok(namedCreates.length >= 7, `expected ≥7 create_* tools (excluding create_lead), got ${namedCreates.length}`);
for (const tool of namedCreates) {
const schema = tool.inputSchema as {
properties?: Record<string, { type?: string; pattern?: string }>;
Expand Down
24 changes: 24 additions & 0 deletions test/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@
stackEnvFor(slug: string): Record<string, string> | undefined;
/** Read the current env map a deployment carries (post-PATCH). */
deployEnvFor(appId: string): Record<string, string> | undefined;
/** Total count of POST /api/v1/leads calls received. */
leadCount(): number;
/** Shut the server down. */
close(): Promise<void>;
}
Expand Down Expand Up @@ -199,6 +201,7 @@
provisionCalls: number;
deployCalls: number;
stackCalls: number;
leadCalls: number;
}

function nowIso(): string {
Expand Down Expand Up @@ -406,6 +409,7 @@
provisionCalls: 0,
deployCalls: 0,
stackCalls: 0,
leadCalls: 0,
};

const server = createServer(async (req, res) => {
Expand Down Expand Up @@ -435,6 +439,7 @@
provisionCount: () => state.provisionCalls,
deployCount: () => state.deployCalls,
stackCount: () => state.stackCalls,
leadCount: () => state.leadCalls,
seedResource: (opts) => {
const token = opts?.token ?? randomUUID();
const resource: MockResource = {
Expand Down Expand Up @@ -638,7 +643,7 @@
backup_restore_enabled: false,
manual_backups_per_day: 0,
rpo_minutes: 0,
rto_minutes: 0,

Check warning on line 646 in test/mock-api.ts

View workflow job for this annotation

GitHub Actions / typos

"rto" should be "to".
annual_discount_percent: 0,
upgrade_url: "https://instanode.dev/pricing/",
is_terminal_tier: false,
Expand All @@ -656,7 +661,7 @@
backup_restore_enabled: true,
manual_backups_per_day: 1,
rpo_minutes: 1440,
rto_minutes: 30,

Check warning on line 664 in test/mock-api.ts

View workflow job for this annotation

GitHub Actions / typos

"rto" should be "to".
annual_discount_percent: 17,
upgrade_url: "https://instanode.dev/pricing/",
is_terminal_tier: false,
Expand All @@ -674,7 +679,7 @@
backup_restore_enabled: true,
manual_backups_per_day: 5,
rpo_minutes: 60,
rto_minutes: 15,

Check warning on line 682 in test/mock-api.ts

View workflow job for this annotation

GitHub Actions / typos

"rto" should be "to".
annual_discount_percent: 17,
upgrade_url: "https://instanode.dev/pricing/",
is_terminal_tier: false,
Expand All @@ -692,7 +697,7 @@
backup_restore_enabled: true,
manual_backups_per_day: 20,
rpo_minutes: 15,
rto_minutes: 10,

Check warning on line 700 in test/mock-api.ts

View workflow job for this annotation

GitHub Actions / typos

"rto" should be "to".
annual_discount_percent: 17,
// Terminal tier — nothing to upgrade to. Real api emits null here.
upgrade_url: null,
Expand All @@ -705,6 +710,25 @@
return;
}

// ── POST /api/v1/leads ─────────────────────────────────────────────────────
if (method === "POST" && path === "/api/v1/leads") {
const raw = await readBody(req);
let parsed: { email?: unknown; name?: unknown; company?: unknown; use_case?: 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 (!parsed.email || typeof parsed.email !== "string" || parsed.email.trim() === "") {
sendJSON(res, 400, errorEnvelope({ error: "missing_email", message: "email is required" }));
return;
}
state.leadCalls += 1;
sendJSON(res, 201, { ok: true, id: randomUUID() });
return;
}

// ── GET /api/v1/resources ──────────────────────────────────────────────────
if (method === "GET" && path === "/api/v1/resources") {
if (!authed) {
Expand Down
22 changes: 22 additions & 0 deletions test/tool-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,4 +390,26 @@ describe("operate-tools endpoint contract (J22-J30)", () => {
const text = flat(await handlerFor("wake_deployment")({ id: appId }));
assert.match(text, new RegExp(`Deployment ${appId} woken`));
});

it("J31 create_lead → POST /api/v1/leads, returns lead id (anon caller)", async () => {
delete process.env["INSTANODE_TOKEN"];
const before = mock.leadCount();
const text = flat(
await handlerFor("create_lead")({
email: "cto@enterprise.com",
contact_name: "Alice Smith",
company: "Big Corp",
use_case: "Need dedicated Postgres with 1TB storage and SOC 2 compliance.",
})
);
assert.equal(mock.leadCount(), before + 1, "create_lead did not call POST /api/v1/leads");
assert.match(text, /Enterprise inquiry submitted/);
assert.match(text, /cto@enterprise\.com/);
});

it("J31 create_lead → 400 missing_email when email is absent", async () => {
delete process.env["INSTANODE_TOKEN"];
const text = flat(await handlerFor("create_lead")({ email: "" }));
assert.match(text, /missing_email|invalid/i);
});
});
7 changes: 4 additions & 3 deletions test/tool-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const MAPPED_TOOLS: Record<string, ToolMapping> = {
resume_resource: { flow: "J28", endpoint: "POST /api/v1/resources/:id/resume" },
rotate_credentials: { flow: "J29", endpoint: "POST /api/v1/resources/:id/rotate-credentials" },
wake_deployment: { flow: "J30", endpoint: "POST /deploy/:id/wake" },
create_lead: { flow: "J31", endpoint: "POST /api/v1/leads" },
};

let registry: Record<string, { description?: string; inputSchema?: unknown; handler?: unknown }>;
Expand Down Expand Up @@ -115,11 +116,11 @@ before(async () => {
});

describe("MCP tool-coverage done-bar (drift guard, matrix §4.2)", () => {
it("registers exactly 30 tools (sanity vs matrix §1.J)", () => {
it("registers exactly 31 tools (sanity vs matrix §1.J)", () => {
assert.equal(
registeredNames.length,
30,
`expected 30 registered tools, got ${registeredNames.length}: ${registeredNames.join(", ")}`
31,
`expected 31 registered tools, got ${registeredNames.length}: ${registeredNames.join(", ")}`
);
});

Expand Down
Loading