diff --git a/src/client.ts b/src/client.ts index faafb1c..ca3aad4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -497,6 +497,33 @@ export interface RotateCredentialsResult { 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). * @@ -1701,4 +1728,14 @@ export class InstantClient { { 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 { + return this.request("POST", "/api/v1/leads", params); + } } diff --git a/src/index.ts b/src/index.ts index 2cab82f..adc24f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -2355,6 +2358,67 @@ Requires INSTANODE_TOKEN. A deployment id not on your team returns a clean 404.` } ); +// ── 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 diff --git a/test/integration.test.ts b/test/integration.test.ts index c4ba7e3..6521b2c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -84,6 +84,7 @@ const EXPECTED_TOOLS = [ "resume_resource", "rotate_credentials", "wake_deployment", + "create_lead", ] as const; /** @@ -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(); @@ -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; diff --git a/test/mock-api.ts b/test/mock-api.ts index 6de79a0..71ab143 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -157,6 +157,8 @@ export interface MockApiHandle { stackEnvFor(slug: string): Record | undefined; /** Read the current env map a deployment carries (post-PATCH). */ deployEnvFor(appId: string): Record | undefined; + /** Total count of POST /api/v1/leads calls received. */ + leadCount(): number; /** Shut the server down. */ close(): Promise; } @@ -199,6 +201,7 @@ interface State { provisionCalls: number; deployCalls: number; stackCalls: number; + leadCalls: number; } function nowIso(): string { @@ -406,6 +409,7 @@ export function startMockApi(): Promise { provisionCalls: 0, deployCalls: 0, stackCalls: 0, + leadCalls: 0, }; const server = createServer(async (req, res) => { @@ -435,6 +439,7 @@ export function startMockApi(): Promise { provisionCount: () => state.provisionCalls, deployCount: () => state.deployCalls, stackCount: () => state.stackCalls, + leadCount: () => state.leadCalls, seedResource: (opts) => { const token = opts?.token ?? randomUUID(); const resource: MockResource = { @@ -705,6 +710,25 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P 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) { diff --git a/test/tool-contract.test.ts b/test/tool-contract.test.ts index c62a058..c10558d 100644 --- a/test/tool-contract.test.ts +++ b/test/tool-contract.test.ts @@ -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); + }); }); diff --git a/test/tool-coverage.test.ts b/test/tool-coverage.test.ts index 0a52c30..4dfb7c6 100644 --- a/test/tool-coverage.test.ts +++ b/test/tool-coverage.test.ts @@ -78,6 +78,7 @@ const MAPPED_TOOLS: Record = { 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; @@ -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(", ")}` ); });