Skip to content
Draft
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
4 changes: 2 additions & 2 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <product> 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.
Expand Down
73 changes: 12 additions & 61 deletions packages/mcp/src/dashboard-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
11 changes: 2 additions & 9 deletions packages/mcp/src/output-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
24 changes: 1 addition & 23 deletions packages/mcp/src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
8 changes: 3 additions & 5 deletions packages/mcp/src/tools/create-checkout-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)
}
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/get-checkout-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/list-plans.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Expand Down
133 changes: 41 additions & 92 deletions packages/mcp/test/onboarding-tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]', 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: '[email protected]',
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'
Expand All @@ -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: '[email protected]',
planId: 'pro',
label: 'production',
idempotencyKey: 'logical-call-123'
},
{ email: '[email protected]', 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: '[email protected]',
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: '[email protected]',
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(
Expand All @@ -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 => {
Expand Down Expand Up @@ -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: '[email protected]', 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: '[email protected]',
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)
})
Loading