From 04f3d3669d487989e77971027498048b6b86164c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 04:15:32 +0000 Subject: [PATCH] fix(mcp): open guest signup instead of authenticated checkout POST /api/v1/checkout/sessions now requires a connect token, so microlink_create_checkout_session always returned 401. Use the public signup endpoint and strip apiKey from poll results. --- packages/mcp/README.md | 4 +- packages/mcp/src/dashboard-client.js | 73 ++-------- packages/mcp/src/output-schemas.js | 11 +- packages/mcp/src/schemas.js | 24 +--- .../mcp/src/tools/create-checkout-session.js | 8 +- .../mcp/src/tools/get-checkout-session.js | 2 +- packages/mcp/src/tools/list-plans.js | 2 +- packages/mcp/test/onboarding-tools.test.js | 133 ++++++------------ 8 files changed, 63 insertions(+), 194 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index cc304a1..09dfbbe 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -145,8 +145,8 @@ Tools can also be invoked directly. URL-processing tools take a `url`; onboardin URL-processing tools are thin wrappers over a [`microlink.io`](https://github.com/microlinkhq/microlink/tree/master/packages/core) library method — same inputs, same result, one source of truth. Onboarding tools call the public dashboard Checkout API instead. `microlink_docs` loads canonical product markdown from microlink.io (the same source as `microlink docs`). - `microlink_list_plans`: list plans available to a new customer. -- `microlink_create_checkout_session`: create an idempotent subscription Checkout Session. Give its `checkoutUrl` to the human. -- `microlink_get_checkout_session`: poll checkout state until `ready` or `expired`. `ready` includes `keyId` (a non-secret key handle); the API key secret is not returned here (welcome email / dashboard). +- `microlink_create_checkout_session`: open guest Sign up Checkout (starter creatable plan; Stripe collects the email). Give its `checkoutUrl` to the human. +- `microlink_get_checkout_session`: poll checkout state until `ready` or `expired`. The API key secret is not returned here (welcome email / dashboard). - `microlink_docs`: canonical parameter docs for a product. Call this before a product tool whose parameters you do not know well. - `microlink_metadata`: normalized metadata extraction with include/exclude config. - `microlink_logo`: brand logo extraction. diff --git a/packages/mcp/src/dashboard-client.js b/packages/mcp/src/dashboard-client.js index 3b9260c..e2fb199 100644 --- a/packages/mcp/src/dashboard-client.js +++ b/packages/mcp/src/dashboard-client.js @@ -30,78 +30,29 @@ export async function listPlans () { return request('/api/v1/plans') } -export async function createCheckoutSession ({ - email, - planId, - label = 'default', - idempotencyKey = crypto.randomUUID() -}) { +// Public guest Sign up. `/api/v1/checkout/sessions` now requires a connect +// token (CLI `microlink buy`); MCP has no local /connect handshake. +export async function createCheckoutSession () { try { - const session = await request('/api/v1/checkout/sessions', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'idempotency-key': idempotencyKey - }, - body: JSON.stringify({ email, planId, label }) - }) - - return { ...session, idempotencyKey } + return await request('/api/v1/checkout/signup', { method: 'POST' }) } catch (error) { - if ( - error instanceof DashboardApiError && - error.payload.statusCode === 400 && - /unknown plan/i.test(error.message) - ) { - try { - const { plans } = await listPlans() - throw new DashboardApiError({ - message: `Unknown planId \`${planId}\`.`, - reason: 'unknown_plan', - statusCode: error.payload.statusCode, - availablePlans: plans, - idempotencyKey, - hint: 'Choose an `id` from `availablePlans` and call this tool again with that `planId` and the same `idempotencyKey`.' - }) - } catch (plansError) { - if ( - plansError instanceof DashboardApiError && - plansError.payload.availablePlans - ) { - throw plansError - } - throw new DashboardApiError({ - message: `Unknown planId \`${planId}\`; the plan catalog could not be loaded.`, - reason: 'unknown_plan', - statusCode: error.payload.statusCode, - idempotencyKey, - hint: 'Call `microlink_list_plans`, then retry with an available `planId` and the same `idempotencyKey`.' - }) - } - } - - const payload = - error instanceof DashboardApiError - ? error.payload - : { - message: error?.message || String(error), - reason: 'dashboard_request_failed', - hint: 'Check network access before retrying this logical checkout call.' - } - + if (error instanceof DashboardApiError) throw error throw new DashboardApiError({ - ...payload, - idempotencyKey, - hint: `${payload.hint} Reuse \`idempotencyKey\` when retrying this logical checkout call.` + message: error?.message || String(error), + reason: 'dashboard_request_failed', + hint: 'Check network access before retrying this logical checkout call.' }) } } export async function getCheckoutSession ({ sessionId }) { try { - return await request( + const body = await request( `/api/v1/checkout/sessions/${encodeURIComponent(sessionId)}` ) + if (body == null || typeof body !== 'object') return body + const { apiKey: _secret, ...safe } = body + return safe } catch (error) { if ( error instanceof DashboardApiError && diff --git a/packages/mcp/src/output-schemas.js b/packages/mcp/src/output-schemas.js index b253f3e..79bbdb3 100644 --- a/packages/mcp/src/output-schemas.js +++ b/packages/mcp/src/output-schemas.js @@ -104,20 +104,13 @@ const checkoutSessionSchema = z .object({ sessionId: z.string(), checkoutUrl: z.string().url(), - idempotencyKey: z.string() + expiresAt: z.number().optional() }) .catchall(z.unknown()) const checkoutStatusSchema = z .object({ - state: z.enum(['open', 'expired', 'paid', 'ready']), - sessionId: z.string(), - email: z.string().nullable(), - planId: z.string().nullable(), - sessionStatus: z.string().nullable(), - paymentStatus: z.string(), - subscriptionId: z.string().nullable(), - keyId: z.string().nullable() + state: z.enum(['open', 'expired', 'paid', 'ready']) }) .catchall(z.unknown()) diff --git a/packages/mcp/src/schemas.js b/packages/mcp/src/schemas.js index 75fd9e4..8dba51f 100644 --- a/packages/mcp/src/schemas.js +++ b/packages/mcp/src/schemas.js @@ -605,29 +605,7 @@ export const docsInputSchema = z export const listPlansInputSchema = z.object({}).strict() -export const createCheckoutSessionInputSchema = z - .object({ - email: z - .string() - .email() - .describe('Email address for the Microlink account and Stripe Checkout.'), - planId: z.string().min(1).describe('Plan id from `microlink_list_plans`.'), - label: z - .string() - .min(1) - .optional() - .default('default') - .describe('Label for the API key that onboarding creates.'), - idempotencyKey: z - .string() - .min(1) - .max(255) - .optional() - .describe( - 'Stable idempotency key for this logical checkout call. Omit on the first call to generate a UUID; reuse the returned value when retrying.' - ) - }) - .strict() +export const createCheckoutSessionInputSchema = z.object({}).passthrough() export const getCheckoutSessionInputSchema = z .object({ diff --git a/packages/mcp/src/tools/create-checkout-session.js b/packages/mcp/src/tools/create-checkout-session.js index dffb0f4..c808fa4 100644 --- a/packages/mcp/src/tools/create-checkout-session.js +++ b/packages/mcp/src/tools/create-checkout-session.js @@ -7,16 +7,14 @@ export function checkoutCreate (server) { server, 'microlink_create_checkout_session', [ - 'Create a Microlink subscription Checkout Session for a plan returned by `microlink_list_plans`.', - 'Generate an idempotency UUID automatically, or accept `idempotencyKey` so a retry of the same logical call cannot create a duplicate session during Stripe’s 24-hour deduplication window.', - 'If the email already has a subscription, this adds a key to that subscription and still returns `checkoutUrl` for any extra-key payment.', + 'Create a Microlink Sign up Checkout Session (the same guest flow as the dashboard Sign up button).', + 'Stripe Checkout collects the email. The session is the starter creatable plan; it does not add an extra key to an existing subscription.', 'Give `checkoutUrl` to the human and wait for them to complete payment; never open or complete it on their behalf.', 'Then poll `microlink_get_checkout_session` with `sessionId` until `state` is `ready` (or stop on `expired`).', - '`ready` means the account is provisioned and includes `keyId` (a non-secret key handle, not the API secret).', 'These tools never return the API key secret; the human receives it via welcome email or the dashboard.' ].join(' '), createCheckoutSessionInputSchema, - (_client, input) => createCheckoutSession(input), + () => createCheckoutSession(), { ...INTERACTIVE_ANNOTATIONS, destructiveHint: false } ) } diff --git a/packages/mcp/src/tools/get-checkout-session.js b/packages/mcp/src/tools/get-checkout-session.js index 63d2fbd..2bcc1d8 100644 --- a/packages/mcp/src/tools/get-checkout-session.js +++ b/packages/mcp/src/tools/get-checkout-session.js @@ -10,7 +10,7 @@ export function checkoutStatus (server) { 'Get the current state of a Microlink Checkout Session: `open`, `expired`, `paid`, or `ready`.', 'After giving the human the `checkoutUrl`, poll this tool at a reasonable interval until `ready`; stop if it becomes `expired`.', '`paid` means payment succeeded while provisioning is still linking the API key.', - '`ready` includes `keyId` (a non-secret key handle used to identify the provisioned key — not the API secret).', + '`ready` means payment succeeded and the key is provisioned.', 'These tools never return the API key secret; the human receives it via welcome email or the dashboard.' ].join(' '), getCheckoutSessionInputSchema, diff --git a/packages/mcp/src/tools/list-plans.js b/packages/mcp/src/tools/list-plans.js index 5ff9833..fc5cda3 100644 --- a/packages/mcp/src/tools/list-plans.js +++ b/packages/mcp/src/tools/list-plans.js @@ -6,7 +6,7 @@ export function plans (server) { register( server, 'microlink_list_plans', - 'List the Microlink plans available to a new customer. Use an `id` from this result as `planId` in `microlink_create_checkout_session`.', + 'List the Microlink plans available to a new customer. Share these with the human before they pay. `microlink_create_checkout_session` opens guest Sign up for the starter creatable plan.', listPlansInputSchema, () => listPlans() ) diff --git a/packages/mcp/test/onboarding-tools.test.js b/packages/mcp/test/onboarding-tools.test.js index 0b3f1f1..0c2d30e 100644 --- a/packages/mcp/test/onboarding-tools.test.js +++ b/packages/mcp/test/onboarding-tools.test.js @@ -47,40 +47,38 @@ test('microlink_list_plans returns the sellable plan catalog', async t => { assert.deepEqual(result.structuredContent.data, catalog) }) -test('microlink_create_checkout_session generates and returns an idempotency key', async t => { +test('microlink_create_checkout_session posts the public signup endpoint', async t => { let request stubFetch(t, async (input, options) => { request = { input, options } return jsonResponse({ sessionId: 'cs_test_123', - checkoutUrl: 'https://checkout.stripe.com/c/pay/test' + checkoutUrl: 'https://checkout.stripe.com/c/pay/test', + expiresAt: 1_700_000_000 }) }) const result = await captureTool( checkoutCreate - ).microlink_create_checkout_session( - { email: 'agent@example.com', planId: 'pro' }, - {} - ) + ).microlink_create_checkout_session({}, {}) - const key = request.options.headers['idempotency-key'] - assert.match( - key, - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + assert.equal( + request.input, + 'https://dashboard.microlink.io/api/v1/checkout/signup' ) - assert.equal(result.structuredContent.data.idempotencyKey, key) assert.equal(request.options.method, 'POST') - assert.deepEqual(JSON.parse(request.options.body), { - email: 'agent@example.com', - planId: 'pro', - label: 'default' + assert.equal(request.options.body, undefined) + assert.deepEqual(result.structuredContent.data, { + sessionId: 'cs_test_123', + checkoutUrl: 'https://checkout.stripe.com/c/pay/test', + expiresAt: 1_700_000_000 }) }) -test('microlink_create_checkout_session forwards a caller idempotency key', async t => { - stubFetch(t, async (_input, options) => { - assert.equal(options.headers['idempotency-key'], 'logical-call-123') +test('microlink_create_checkout_session ignores leftover email and planId fields', async t => { + let request + stubFetch(t, async (input, options) => { + request = { input, options } return jsonResponse({ sessionId: 'cs_test_123', checkoutUrl: 'https://checkout.stripe.com/c/pay/test' @@ -90,63 +88,42 @@ test('microlink_create_checkout_session forwards a caller idempotency key', asyn const result = await captureTool( checkoutCreate ).microlink_create_checkout_session( - { - email: 'agent@example.com', - planId: 'pro', - label: 'production', - idempotencyKey: 'logical-call-123' - }, + { email: 'agent@example.com', planId: 'pro', idempotencyKey: 'logical-call-123' }, {} ) - assert.equal(result.structuredContent.data.idempotencyKey, 'logical-call-123') + assert.equal( + request.input, + 'https://dashboard.microlink.io/api/v1/checkout/signup' + ) + assert.equal(request.options.headers, undefined) + assert.equal(result.isError, false) + assert.equal(result.structuredContent.data.sessionId, 'cs_test_123') }) -test('unknown plan error includes available plans and a retry hint', async t => { - let calls = 0 - stubFetch(t, async () => { - calls++ - if (calls === 1) return jsonResponse({ error: 'Unknown plan' }, 400) - return jsonResponse({ - plans: [{ id: 'pro', limit: 100000, price: 20, currency: 'usd' }] - }) - }) +test('signup errors surface the dashboard message', async t => { + stubFetch(t, async () => jsonResponse({ error: 'No plans available' }, 503)) const result = await captureTool( checkoutCreate - ).microlink_create_checkout_session( - { - email: 'agent@example.com', - planId: 'unknown', - idempotencyKey: 'logical-call-123' - }, - {} - ) + ).microlink_create_checkout_session({}, {}) const error = JSON.parse(result.content[0].text) assert.equal(result.isError, true) assert.equal(result.structuredContent, undefined) - assert.equal(error.reason, 'unknown_plan') - assert.equal(error.idempotencyKey, 'logical-call-123') - assert.equal(error.availablePlans[0].id, 'pro') - assert.match(error.hint, /same `idempotencyKey`/) + assert.equal(error.reason, 'dashboard_request_failed') + assert.equal(error.statusCode, 503) + assert.equal(error.message, 'No plans available') }) -test('microlink_get_checkout_session returns ready state and key id', async t => { - const status = { - state: 'ready', - sessionId: 'cs_test_123', - email: 'agent@example.com', - planId: 'pro', - sessionStatus: 'complete', - paymentStatus: 'paid', - subscriptionId: 'sub_123', - keyId: 'key_123' - } +test('microlink_get_checkout_session returns ready state without the API secret', async t => { let requestUrl stubFetch(t, async input => { requestUrl = input - return jsonResponse(status) + return jsonResponse({ + state: 'ready', + apiKey: 'ml_secret' + }) }) const result = await captureTool( @@ -157,7 +134,8 @@ test('microlink_get_checkout_session returns ready state and key id', async t => requestUrl, 'https://dashboard.microlink.io/api/v1/checkout/sessions/cs_test_123' ) - assert.deepEqual(result.structuredContent.data, status) + assert.deepEqual(result.structuredContent.data, { state: 'ready' }) + assert.equal(result.content[0].text.includes('ml_secret'), false) }) test('unknown checkout session explains how to recover', async t => { @@ -212,47 +190,18 @@ test('onboarding tools expose MCP titles, output schemas and safe annotations', ) }) -test('transport errors preserve the generated idempotency key', async t => { +test('transport errors stay recoverable without an idempotency key', async t => { stubFetch(t, async () => { throw new TypeError('fetch failed') }) const result = await captureTool( checkoutCreate - ).microlink_create_checkout_session( - { email: 'agent@example.com', planId: 'pro' }, - {} - ) + ).microlink_create_checkout_session({}, {}) const error = JSON.parse(result.content[0].text) assert.equal(result.isError, true) - assert.match(error.idempotencyKey, /^[0-9a-f-]{36}$/) assert.equal(error.reason, 'dashboard_request_failed') - assert.match(error.hint, /Reuse `idempotencyKey`/) -}) - -test('unknown plan keeps idempotency key when catalog lookup fails', async t => { - let calls = 0 - stubFetch(t, async () => { - calls++ - if (calls === 1) return jsonResponse({ error: 'Unknown plan' }, 400) - throw new TypeError('fetch failed') - }) - - const result = await captureTool( - checkoutCreate - ).microlink_create_checkout_session( - { - email: 'agent@example.com', - planId: 'unknown', - idempotencyKey: 'logical-call-123' - }, - {} - ) - const error = JSON.parse(result.content[0].text) - - assert.equal(error.reason, 'unknown_plan') - assert.equal(error.idempotencyKey, 'logical-call-123') - assert.match(error.hint, /microlink_list_plans/) - assert.match(error.hint, /same `idempotencyKey`/) + assert.match(error.hint, /network access/) + assert.equal(error.idempotencyKey, undefined) })