@lavapayments/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1741 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lava MCP Server
4
+ *
5
+ * Lets an AI agent use Lava (meters, keys, customers, usage, webhooks, checkout, gateway traffic, etc.)
6
+ * without opening the dashboard. Configure with LAVA_API_URL and optionally LAVA_SECRET_KEY,
7
+ * or authenticate interactively via lava_login.
8
+ */
9
+ import { Lava } from '@lavapayments/nodejs';
10
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import { z } from 'zod';
13
+ import { API_TIMEOUT_MS, createLavaClient, gatewayRequest, LavaApiError, parseJsonText, RESERVED_GATEWAY_HEADERS, throwIfNotOk, trimTrailingSlash, } from './client.js';
14
+ // Keep in sync with package.json version
15
+ const VERSION = '0.1.0';
16
+ const TRAILING_SINGLE_SLASH_REGEX = /\/$/;
17
+ const HEADER_NAME_REGEX = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
18
+ const HEADER_VALUE_NEWLINE_REGEX = /[\r\n]/;
19
+ const BASE64_BODY_REGEX = /^[A-Za-z0-9+/]*={0,2}$/;
20
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
21
+ const TRUSTED_APP_HOST_SUFFIXES = ['lava.so', 'lavapayments.com'];
22
+ const BASE_URL = process.env.LAVA_API_URL ?? 'https://api.lava.so';
23
+ const APP_URL = process.env.LAVA_APP_URL ?? resolveDefaultAppUrl(process.env.LAVA_API_URL);
24
+ const MAX_GATEWAY_BODY_BASE64_BYTES = 15 * 1024 * 1024;
25
+ // This MCP currently runs over stdio as a single local process, so
26
+ // in-memory auth state is scoped to that one MCP session.
27
+ const authState = {
28
+ secretKey: process.env.LAVA_SECRET_KEY ?? '',
29
+ walletKey: process.env.LAVA_WALLET_KEY ?? '',
30
+ source: process.env.LAVA_SECRET_KEY || process.env.LAVA_WALLET_KEY ? 'env' : 'none',
31
+ };
32
+ function resolveDefaultAppUrl(apiBaseUrl = BASE_URL) {
33
+ const url = new URL(apiBaseUrl);
34
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
35
+ return new URL('/', url)
36
+ .toString()
37
+ .replace(TRAILING_SINGLE_SLASH_REGEX, '');
38
+ }
39
+ if (url.hostname.includes('sandbox')) {
40
+ return 'https://sandbox.lavapayments.com';
41
+ }
42
+ return 'https://www.lava.so';
43
+ }
44
+ function setAuthState(credentials) {
45
+ authState.secretKey = credentials.secret_key;
46
+ authState.walletKey = credentials.wallet_key;
47
+ authState.secretKeyId = credentials.secret_key_id;
48
+ authState.walletKeyId = credentials.wallet_key_id;
49
+ authState.merchantId = credentials.merchant_id;
50
+ authState.walletId = credentials.wallet_id;
51
+ authState.source = 'login';
52
+ }
53
+ function isLocalHostname(hostname) {
54
+ return hostname === 'localhost' || hostname === '127.0.0.1';
55
+ }
56
+ function isTrustedAppHostname(hostname) {
57
+ if (isLocalHostname(hostname)) {
58
+ return true;
59
+ }
60
+ try {
61
+ if (new URL(APP_URL).hostname.toLowerCase() === hostname) {
62
+ return true;
63
+ }
64
+ }
65
+ catch {
66
+ // APP_URL is already normalized at startup; ignore malformed overrides here.
67
+ }
68
+ return TRUSTED_APP_HOST_SUFFIXES.some((suffix) => {
69
+ return hostname === suffix || hostname.endsWith(`.${suffix}`);
70
+ });
71
+ }
72
+ function normalizeLoginAppUrl(appUrl) {
73
+ const url = new URL(appUrl);
74
+ const hostname = url.hostname.toLowerCase();
75
+ if (!isTrustedAppHostname(hostname)) {
76
+ throw new LavaApiError(`lava_login only supports Lava app hosts. Received "${hostname}".`, 400, 'invalid_request');
77
+ }
78
+ if (url.protocol !== 'https:' && !isLocalHostname(hostname)) {
79
+ throw new LavaApiError('lava_login requires HTTPS unless you are using localhost for development.', 400, 'invalid_request');
80
+ }
81
+ return url.toString().replace(TRAILING_SINGLE_SLASH_REGEX, '');
82
+ }
83
+ function assertGatewayTargetUrl(targetUrl) {
84
+ const url = new URL(targetUrl);
85
+ const hostname = url.hostname.toLowerCase();
86
+ if (url.protocol !== 'https:' && !isLocalHostname(hostname)) {
87
+ throw new LavaApiError('target_url must use HTTPS unless you are forwarding to localhost for development.', 400, 'invalid_request');
88
+ }
89
+ }
90
+ function getClient() {
91
+ if (!authState.secretKey) {
92
+ throw new LavaApiError('No merchant secret key is available. Set LAVA_SECRET_KEY in the MCP environment or call lava_login first.', 401, 'auth_required');
93
+ }
94
+ return createLavaClient({
95
+ baseUrl: BASE_URL,
96
+ secretKey: authState.secretKey,
97
+ });
98
+ }
99
+ function getWalletClient() {
100
+ if (!authState.walletKey) {
101
+ throw new LavaApiError('No wallet API key is available. Set LAVA_WALLET_KEY in the MCP environment or call lava_login first.', 401, 'auth_required');
102
+ }
103
+ return createLavaClient({
104
+ baseUrl: BASE_URL,
105
+ secretKey: authState.walletKey,
106
+ });
107
+ }
108
+ function getGatewayAuthToken(authToken) {
109
+ if (authToken) {
110
+ return authToken;
111
+ }
112
+ if (authState.secretKey) {
113
+ return authState.secretKey;
114
+ }
115
+ throw new LavaApiError('No default auth token is available for gateway requests. Call lava_login first, set LAVA_SECRET_KEY, or pass auth_token explicitly.', 401, 'auth_required');
116
+ }
117
+ function buildGatewayHeaders(headers) {
118
+ return Object.fromEntries(Object.entries(headers ?? {}).flatMap(([key, value]) => {
119
+ if (value === undefined) {
120
+ return [];
121
+ }
122
+ if (RESERVED_GATEWAY_HEADERS.has(key.toLowerCase())) {
123
+ throw new LavaApiError(`Header "${key}" is managed by Lava and cannot be overridden.`, 400, 'invalid_request');
124
+ }
125
+ if (!HEADER_NAME_REGEX.test(key)) {
126
+ throw new LavaApiError(`Header "${key}" is not a valid HTTP header name.`, 400, 'invalid_request');
127
+ }
128
+ if (HEADER_VALUE_NEWLINE_REGEX.test(value)) {
129
+ throw new LavaApiError(`Header "${key}" contains an invalid value.`, 400, 'invalid_request');
130
+ }
131
+ return [[key, value]];
132
+ }));
133
+ }
134
+ function encodeGatewayBody(args) {
135
+ const provided = [
136
+ args.body_json !== undefined,
137
+ args.body_text !== undefined,
138
+ args.body_base64 !== undefined,
139
+ ].filter(Boolean).length;
140
+ if (provided > 1) {
141
+ throw new LavaApiError('Provide only one of body_json, body_text, or body_base64.', 400, 'invalid_request');
142
+ }
143
+ if (args.body_json !== undefined) {
144
+ try {
145
+ return JSON.stringify(args.body_json);
146
+ }
147
+ catch {
148
+ throw new LavaApiError('body_json must be JSON-serializable.', 400, 'invalid_request');
149
+ }
150
+ }
151
+ if (args.body_text !== undefined) {
152
+ return args.body_text;
153
+ }
154
+ if (args.body_base64 !== undefined) {
155
+ const normalized = args.body_base64.trim();
156
+ if ((normalized.length > 0 && normalized.length % 4 !== 0) ||
157
+ !BASE64_BODY_REGEX.test(normalized)) {
158
+ throw new LavaApiError('body_base64 must be valid base64.', 400, 'invalid_request');
159
+ }
160
+ const decodedBody = Buffer.from(normalized, 'base64');
161
+ if (decodedBody.byteLength > MAX_GATEWAY_BODY_BASE64_BYTES) {
162
+ throw new LavaApiError(`body_base64 decodes to ${decodedBody.byteLength} bytes, which exceeds the MCP size limit of ${MAX_GATEWAY_BODY_BASE64_BYTES} bytes. Use the SDK or direct proxy for large binary uploads.`, 400, 'invalid_request');
163
+ }
164
+ return decodedBody;
165
+ }
166
+ return;
167
+ }
168
+ function assertNoStreamingBody(body) {
169
+ if (body &&
170
+ typeof body === 'object' &&
171
+ 'stream' in body &&
172
+ body.stream !== false &&
173
+ body.stream !== null &&
174
+ body.stream !== undefined) {
175
+ throw new LavaApiError('Streaming responses are not supported through the MCP tools yet. Set stream to false and retry, or use the Lava SDK/direct proxy for streaming.', 400, 'unsupported_request');
176
+ }
177
+ }
178
+ function createJsonResource(uri, value) {
179
+ return {
180
+ contents: [
181
+ {
182
+ uri,
183
+ mimeType: 'application/json',
184
+ text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
185
+ },
186
+ ],
187
+ };
188
+ }
189
+ async function withJsonResourceErrorHandling(uri, fn) {
190
+ try {
191
+ return createJsonResource(uri, await fn());
192
+ }
193
+ catch (err) {
194
+ return createJsonResource(uri, { error: formatToolError(err) });
195
+ }
196
+ }
197
+ async function loginWithTimeout(appUrl) {
198
+ return await new Promise((resolve, reject) => {
199
+ const timeout = setTimeout(() => {
200
+ reject(new LavaApiError('lava_login timed out waiting for browser authentication. Retry when you are ready to complete the login flow.', 408, 'timeout'));
201
+ }, LOGIN_TIMEOUT_MS);
202
+ const loginPromise = Lava.login({ baseUrl: appUrl });
203
+ loginPromise.then((credentials) => {
204
+ clearTimeout(timeout);
205
+ resolve(credentials);
206
+ }, (error) => {
207
+ clearTimeout(timeout);
208
+ reject(error);
209
+ });
210
+ });
211
+ }
212
+ async function executeGatewayRequest(args) {
213
+ const authToken = getGatewayAuthToken(args.authToken);
214
+ const headers = buildGatewayHeaders(args.headers);
215
+ const body = encodeGatewayBody(args);
216
+ const hasBody = body !== undefined;
217
+ const hasContentType = Object.keys(headers).some((key) => key.toLowerCase() === 'content-type');
218
+ if (args.body_json !== undefined && !hasContentType) {
219
+ headers['Content-Type'] = 'application/json';
220
+ }
221
+ if (args.body_text !== undefined && !hasContentType) {
222
+ headers['Content-Type'] = 'text/plain; charset=utf-8';
223
+ }
224
+ const res = await gatewayRequest({
225
+ baseUrl: BASE_URL,
226
+ method: args.method,
227
+ path: args.path,
228
+ secretKey: authToken,
229
+ headers,
230
+ body,
231
+ });
232
+ return {
233
+ ...res,
234
+ request: {
235
+ method: args.method,
236
+ path: args.path,
237
+ used_default_auth: !args.authToken,
238
+ has_body: hasBody,
239
+ },
240
+ };
241
+ }
242
+ /** Fetch a public endpoint (no auth). Used for /v1/services so models can be listed without LAVA_SECRET_KEY. */
243
+ async function fetchPublic(path) {
244
+ const base = trimTrailingSlash(BASE_URL);
245
+ const url = path.startsWith('/') ? `${base}${path}` : `${base}/${path}`;
246
+ const res = await fetch(url, {
247
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
248
+ });
249
+ const text = await res.text();
250
+ const json = parseJsonText(text);
251
+ throwIfNotOk(res, json);
252
+ return json;
253
+ }
254
+ function jsonContent(value) {
255
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
256
+ return { content: [{ type: 'text', text }] };
257
+ }
258
+ /** Wraps a tool handler so API errors are returned as MCP error content instead of uncaught exceptions. */
259
+ function formatToolError(err) {
260
+ if (err instanceof LavaApiError) {
261
+ const metadata = [
262
+ `status ${err.status}`,
263
+ ...(err.code ? [`code ${err.code}`] : []),
264
+ ].join(', ');
265
+ return metadata ? `${err.message} (${metadata})` : err.message;
266
+ }
267
+ if (err instanceof Error) {
268
+ return err.message;
269
+ }
270
+ return String(err);
271
+ }
272
+ function withErrorHandling(fn) {
273
+ return async (args) => {
274
+ try {
275
+ return await fn(args);
276
+ }
277
+ catch (err) {
278
+ return {
279
+ content: [{ type: 'text', text: formatToolError(err) }],
280
+ isError: true,
281
+ };
282
+ }
283
+ };
284
+ }
285
+ async function main() {
286
+ const server = new McpServer({
287
+ name: 'lava',
288
+ version: VERSION,
289
+ }, {
290
+ capabilities: { tools: {}, resources: {}, prompts: {} },
291
+ instructions: `Lava MCP: manage meters, API keys, customers, usage, webhooks, plans, checkout sessions, and gateway traffic. If no env keys are preconfigured, call lava_login first to bootstrap this MCP session with the existing Lava browser auth flow. Merchant ops use the active merchant secret key; spend key ops use the active wallet key. To onboard an end-user: lava_create_checkout_session(checkout_mode:"onboarding", origin_url). To generate proxy auth: lava_generate_forward_token. To route real traffic through Lava: lava_chat_completions, lava_messages, lava_forward, or lava_rewrite. Resources: lava://models-guide (proxy/chat API), lava://webhook-events, lava://openapi.`,
292
+ });
293
+ const listPaginationSchema = {
294
+ cursor: z
295
+ .string()
296
+ .optional()
297
+ .describe('Pagination cursor from a previous list response'),
298
+ limit: z
299
+ .number()
300
+ .min(1)
301
+ .max(100)
302
+ .optional()
303
+ .default(20)
304
+ .describe('Max items to return (1-100)'),
305
+ };
306
+ const gatewayMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
307
+ const gatewayHeadersSchema = z
308
+ .record(z.string())
309
+ .optional()
310
+ .describe('Optional request headers to forward. Do not include Authorization or hop-by-hop transport headers; Lava sets those itself.');
311
+ const gatewayBodySchema = {
312
+ body_json: z
313
+ .unknown()
314
+ .optional()
315
+ .describe('JSON request body. Provide at most one of body_json, body_text, or body_base64.'),
316
+ body_text: z
317
+ .string()
318
+ .optional()
319
+ .describe('Raw text request body. Provide at most one of body_json, body_text, or body_base64.'),
320
+ body_base64: z
321
+ .string()
322
+ .optional()
323
+ .describe('Base64-encoded binary request body. Provide at most one of body_json, body_text, or body_base64.'),
324
+ };
325
+ server.registerTool('lava_login', {
326
+ title: 'Authenticate this MCP session',
327
+ description: 'Run the existing Lava browser login flow and store the returned merchant secret key plus wallet key in this MCP session. Use this for first-run auth when no LAVA_SECRET_KEY or LAVA_WALLET_KEY is preconfigured.',
328
+ inputSchema: z.object({
329
+ app_url: z
330
+ .string()
331
+ .url()
332
+ .optional()
333
+ .describe('Optional Lava app base URL for the browser login flow. Defaults to LAVA_APP_URL, or is derived from LAVA_API_URL when unset.'),
334
+ }),
335
+ }, withErrorHandling(async ({ app_url }) => {
336
+ const credentials = await loginWithTimeout(normalizeLoginAppUrl(app_url ?? APP_URL));
337
+ setAuthState(credentials);
338
+ return jsonContent({
339
+ authenticated: true,
340
+ source: authState.source,
341
+ merchant_id: credentials.merchant_id,
342
+ wallet_id: credentials.wallet_id,
343
+ secret_key_id: credentials.secret_key_id,
344
+ wallet_key_id: credentials.wallet_key_id,
345
+ message: 'Lava login succeeded. This MCP session now has both merchant and wallet credentials loaded in memory.',
346
+ });
347
+ }));
348
+ server.registerTool('lava_generate_forward_token', {
349
+ title: 'Generate a forward token',
350
+ description: 'Generate a base64 forward token for /v1/forward, /v1/rewrite, or direct proxy use. Use null/null for customer_id and meter_slug to create a merchant-billed token; provide both to bill a specific customer with a specific meter.',
351
+ inputSchema: z.object({
352
+ customer_id: z
353
+ .string()
354
+ .nullable()
355
+ .optional()
356
+ .describe('Customer ID to bill. Set null for merchant-billed proxy traffic.'),
357
+ meter_slug: z
358
+ .string()
359
+ .nullable()
360
+ .optional()
361
+ .describe('Meter slug to bill against. Set null for merchant-billed proxy traffic.'),
362
+ provider_key: z
363
+ .string()
364
+ .optional()
365
+ .describe('Optional provider API key for unmanaged/BYOK forwarding.'),
366
+ }),
367
+ }, withErrorHandling(({ customer_id, meter_slug, provider_key }) => {
368
+ const normalizedCustomerId = customer_id ?? null;
369
+ const normalizedMeterSlug = meter_slug ?? null;
370
+ if ((normalizedCustomerId === null) !== (normalizedMeterSlug === null)) {
371
+ throw new LavaApiError('customer_id and meter_slug must either both be set or both be null.', 400, 'invalid_request');
372
+ }
373
+ const lava = new Lava(getGatewayAuthToken());
374
+ const token = normalizedCustomerId === null
375
+ ? lava.generateForwardToken({
376
+ customer_id: null,
377
+ meter_slug: null,
378
+ ...(provider_key ? { provider_key } : {}),
379
+ })
380
+ : lava.generateForwardToken({
381
+ customer_id: normalizedCustomerId,
382
+ meter_slug: normalizedMeterSlug,
383
+ ...(provider_key ? { provider_key } : {}),
384
+ });
385
+ return jsonContent({
386
+ token,
387
+ mode: normalizedCustomerId === null ? 'merchant_billed' : 'customer_billed',
388
+ customer_id: normalizedCustomerId,
389
+ meter_slug: normalizedMeterSlug,
390
+ has_provider_key: Boolean(provider_key),
391
+ });
392
+ }));
393
+ // ── Meters ──
394
+ server.registerTool('lava_list_meters', {
395
+ title: 'List meters',
396
+ description: 'List all usage meters (products) for the merchant. Use this to see pricing and meter IDs.',
397
+ inputSchema: z.object(listPaginationSchema),
398
+ }, withErrorHandling(async ({ cursor, limit }) => {
399
+ const api = getClient();
400
+ const res = await api.list('/v1/meters', { cursor, limit });
401
+ return jsonContent(res);
402
+ }));
403
+ server.registerTool('lava_create_meter', {
404
+ title: 'Create a meter',
405
+ description: `Create a new usage meter that defines how Lava prices and tracks one type of AI usage. You need at least one meter before you can charge users.
406
+
407
+ rate_type:
408
+ "flat" — one price for all usage (most common). Use a single tier with start "0".
409
+ "tiered" — price changes at volume thresholds (e.g. cheaper after 1M tokens).
410
+
411
+ tier_type:
412
+ "per_unit" — rate is charged per unit of usage. E.g. rate "0.000001" = $0.000001 per token = $1 per million tokens.
413
+ "flat" — rate is a flat fee for the entire tier, regardless of how many units.
414
+
415
+ tiers: array of { start, rate }. The first tier must have start "0". For flat rate_type, one tier is enough.
416
+ rate is always a decimal USD amount as a string. For per_unit token pricing, common values:
417
+ "0.000001" = $1 / million tokens
418
+ "0.000002" = $2 / million tokens
419
+ "0.00001" = $10 / million tokens
420
+
421
+ Example — simple $1/M token meter:
422
+ rate_type: "flat", tier_type: "per_unit", tiers: [{ start: "0", rate: "0.000001" }]`,
423
+ inputSchema: z.object({
424
+ name: z
425
+ .string()
426
+ .min(1)
427
+ .describe('Display name for the meter (e.g. "Chat API")'),
428
+ meter_slug: z
429
+ .string()
430
+ .optional()
431
+ .describe('Optional URL-safe slug used in lava_track_request; auto-generated if omitted'),
432
+ rate_type: z
433
+ .enum(['flat', 'tiered'])
434
+ .describe('"flat" = one rate for all usage (most common). "tiered" = rate changes at volume thresholds.'),
435
+ tier_type: z
436
+ .enum(['per_unit', 'flat'])
437
+ .describe('"per_unit" = rate charged per token/character/second. "flat" = flat fee per tier regardless of units.'),
438
+ tiers: z
439
+ .array(z.object({
440
+ start: z
441
+ .string()
442
+ .describe('Usage count where this tier begins. First tier must be "0".'),
443
+ rate: z
444
+ .string()
445
+ .describe('USD rate as decimal string. For per_unit: rate per single unit (e.g. "0.000001" = $1 per million). For flat: total fee for the tier.'),
446
+ }))
447
+ .min(1)
448
+ .describe('Pricing tiers. For flat rate_type, one tier starting at "0" is all you need.'),
449
+ }),
450
+ }, withErrorHandling(async (args) => {
451
+ const api = getClient();
452
+ const res = await api.post('/v1/meters', args);
453
+ return jsonContent(res);
454
+ }));
455
+ server.registerTool('lava_get_meter', {
456
+ title: 'Get a meter',
457
+ description: 'Fetch a single meter by ID.',
458
+ inputSchema: z.object({ meter_id: z.string().describe('The meter ID') }),
459
+ }, withErrorHandling(async ({ meter_id }) => {
460
+ const api = getClient();
461
+ const res = await api.get(`/v1/meters/${encodeURIComponent(meter_id)}`);
462
+ return jsonContent(res);
463
+ }));
464
+ server.registerTool('lava_update_meter', {
465
+ title: 'Update a meter',
466
+ description: 'Update a meter name and/or tiers. Rate type, tier type, and slug cannot be changed.',
467
+ inputSchema: z.object({
468
+ meter_id: z.string().describe('The meter ID to update'),
469
+ name: z.string().min(1).optional().describe('New display name'),
470
+ tiers: z
471
+ .array(z.object({
472
+ start: z.string().describe('Tier start (first tier must be "0")'),
473
+ rate: z.string().describe('Rate for this tier (decimal string)'),
474
+ }))
475
+ .min(1)
476
+ .optional()
477
+ .describe('New tiers (replaces existing); first tier start must be 0'),
478
+ }),
479
+ }, withErrorHandling(async (args) => {
480
+ const { meter_id, ...body } = args;
481
+ const api = getClient();
482
+ const res = await api.patch(`/v1/meters/${encodeURIComponent(meter_id)}`, body);
483
+ return jsonContent(res);
484
+ }));
485
+ server.registerTool('lava_delete_meter', {
486
+ title: 'Delete a meter',
487
+ description: 'Soft-delete a meter. May be subject to cooldown after creation.',
488
+ inputSchema: z.object({
489
+ meter_id: z.string().describe('The meter ID to delete'),
490
+ }),
491
+ }, withErrorHandling(async ({ meter_id }) => {
492
+ const api = getClient();
493
+ const res = await api.delete(`/v1/meters/${encodeURIComponent(meter_id)}`);
494
+ return jsonContent(res);
495
+ }));
496
+ // ── Secret keys ──
497
+ server.registerTool('lava_list_secret_keys', {
498
+ title: 'List secret keys',
499
+ description: 'List API secret keys for the merchant. Use these to authenticate REST and proxy requests.',
500
+ inputSchema: z.object(listPaginationSchema),
501
+ }, withErrorHandling(async ({ cursor, limit }) => {
502
+ const api = getClient();
503
+ const res = await api.list('/v1/secret_keys', { cursor, limit });
504
+ return jsonContent(res);
505
+ }));
506
+ server.registerTool('lava_create_secret_key', {
507
+ title: 'Create a secret key',
508
+ description: 'Create a new API secret key. The secret value is only returned once at creation.',
509
+ inputSchema: z.object({
510
+ name: z
511
+ .string()
512
+ .min(1)
513
+ .describe('Label for the key (e.g. "Production" or "Dev")'),
514
+ }),
515
+ }, withErrorHandling(async (args) => {
516
+ const api = getClient();
517
+ const res = await api.post('/v1/secret_keys', args);
518
+ return jsonContent(res);
519
+ }));
520
+ server.registerTool('lava_revoke_secret_key', {
521
+ title: 'Revoke a secret key',
522
+ description: 'Permanently revoke a secret key by ID. You must keep at least one key. May be subject to cooldown after creation.',
523
+ inputSchema: z.object({
524
+ secret_key_id: z.string().describe('The secret key ID to revoke'),
525
+ }),
526
+ }, withErrorHandling(async ({ secret_key_id }) => {
527
+ const api = getClient();
528
+ const res = await api.delete(`/v1/secret_keys/${encodeURIComponent(secret_key_id)}`);
529
+ return jsonContent(res);
530
+ }));
531
+ // ── Customers (end-users linked to merchant) ──
532
+ server.registerTool('lava_list_customers', {
533
+ title: 'List customers',
534
+ description: 'List end-user customers linked to your merchant. Useful to see who has onboarded and their subscription status.',
535
+ inputSchema: z.object(listPaginationSchema),
536
+ }, withErrorHandling(async ({ cursor, limit }) => {
537
+ const api = getClient();
538
+ const res = await api.list('/v1/customers', { cursor, limit });
539
+ return jsonContent(res);
540
+ }));
541
+ server.registerTool('lava_get_customer', {
542
+ title: 'Get a customer',
543
+ description: 'Fetch a single customer by ID (includes subscription summary if active).',
544
+ inputSchema: z.object({
545
+ customer_id: z.string().describe('The customer ID'),
546
+ }),
547
+ }, withErrorHandling(async ({ customer_id }) => {
548
+ const api = getClient();
549
+ const res = await api.get(`/v1/customers/${encodeURIComponent(customer_id)}`);
550
+ return jsonContent(res);
551
+ }));
552
+ server.registerTool('lava_get_customer_subscription', {
553
+ title: 'Get customer subscription',
554
+ description: 'Get the active subscription and current cycle for a customer.',
555
+ inputSchema: z.object({
556
+ customer_id: z.string().describe('The customer ID'),
557
+ }),
558
+ }, withErrorHandling(async ({ customer_id }) => {
559
+ const api = getClient();
560
+ const res = await api.get(`/v1/customers/${encodeURIComponent(customer_id)}/subscription`);
561
+ return jsonContent(res);
562
+ }));
563
+ server.registerTool('lava_delete_customer', {
564
+ title: 'Delete a customer',
565
+ description: 'Permanently remove a customer from your merchant.',
566
+ inputSchema: z.object({
567
+ customer_id: z.string().describe('The customer ID to delete'),
568
+ }),
569
+ }, withErrorHandling(async ({ customer_id }) => {
570
+ const api = getClient();
571
+ const res = await api.delete(`/v1/customers/${encodeURIComponent(customer_id)}`);
572
+ return jsonContent(res);
573
+ }));
574
+ // ── Usage ──
575
+ server.registerTool('lava_get_usage', {
576
+ title: 'Get usage',
577
+ description: 'Get aggregated usage for the merchant within a date range. Optionally filter by customer or meter.',
578
+ inputSchema: z.object({
579
+ start: z
580
+ .string()
581
+ .describe('Start date (ISO 8601, e.g. 2025-01-01T00:00:00Z)'),
582
+ end: z
583
+ .string()
584
+ .optional()
585
+ .describe('End date (ISO 8601). Omit for open-ended.'),
586
+ customer_id: z.string().optional().describe('Filter by customer ID'),
587
+ meter_id: z.string().optional().describe('Filter by meter ID'),
588
+ metadata_filters: z
589
+ .array(z.tuple([z.string(), z.string()]))
590
+ .optional()
591
+ .describe('Filter by metadata key-value pairs, e.g. [["user_id", "u_123"]]'),
592
+ }),
593
+ }, withErrorHandling(async (args) => {
594
+ const api = getClient();
595
+ const params = { start: args.start };
596
+ if (args.end) {
597
+ params.end = args.end;
598
+ }
599
+ if (args.customer_id) {
600
+ params.customer_id = args.customer_id;
601
+ }
602
+ if (args.meter_id) {
603
+ params.meter_id = args.meter_id;
604
+ }
605
+ if (args.metadata_filters && args.metadata_filters.length > 0) {
606
+ params.metadata_filters = JSON.stringify(args.metadata_filters);
607
+ }
608
+ const res = await api.get('/v1/usage', params);
609
+ return jsonContent(res);
610
+ }));
611
+ // ── Plans ──
612
+ server.registerTool('lava_list_plans', {
613
+ title: 'List plans',
614
+ description: 'List plans that customers can subscribe to.',
615
+ inputSchema: z.object(listPaginationSchema),
616
+ }, withErrorHandling(async ({ cursor, limit }) => {
617
+ const api = getClient();
618
+ const res = await api.list('/v1/plans', { cursor, limit });
619
+ return jsonContent(res);
620
+ }));
621
+ server.registerTool('lava_get_plan', {
622
+ title: 'Get a plan',
623
+ description: 'Fetch a single plan by ID.',
624
+ inputSchema: z.object({
625
+ plan_id: z.string().describe('The plan ID'),
626
+ }),
627
+ }, withErrorHandling(async ({ plan_id }) => {
628
+ const api = getClient();
629
+ const res = await api.get(`/v1/plans/${encodeURIComponent(plan_id)}`);
630
+ return jsonContent(res);
631
+ }));
632
+ server.registerTool('lava_create_plan', {
633
+ title: 'Create a plan',
634
+ description: 'Create a new plan with an optional recurring fee, included credit, linked meters, and optional credit bundles.',
635
+ inputSchema: z.object({
636
+ name: z.string().min(1),
637
+ period_amount: z
638
+ .string()
639
+ .describe('Price per period (decimal string, e.g. "9.99")'),
640
+ billing_interval: z.enum(['day', 'week', 'month', 'year']),
641
+ rollover_type: z.enum(['full', 'none']).optional(),
642
+ bundle_rollover_type: z.enum(['full', 'none']).optional(),
643
+ included_credit: z
644
+ .string()
645
+ .optional()
646
+ .describe('Included credit amount (decimal string)'),
647
+ default_auto_top_up_bundle_index: z
648
+ .number()
649
+ .int()
650
+ .nullable()
651
+ .optional()
652
+ .describe('Optional index into credit_bundles to mark the default auto top-up bundle.'),
653
+ meter_ids: z.array(z.string()).optional().describe('Linked meter IDs'),
654
+ credit_bundles: z
655
+ .array(z.object({
656
+ name: z.string(),
657
+ cost: z.string(),
658
+ credit_amount: z.string(),
659
+ }))
660
+ .optional(),
661
+ }),
662
+ }, withErrorHandling(async (args) => {
663
+ const api = getClient();
664
+ const res = await api.post('/v1/plans', args);
665
+ return jsonContent(res);
666
+ }));
667
+ server.registerTool('lava_update_plan', {
668
+ title: 'Update a plan',
669
+ description: 'Update a plan name, rollover settings, included credit, linked meters, or credit bundles. period_amount and billing_interval are immutable.',
670
+ inputSchema: z.object({
671
+ plan_id: z.string(),
672
+ name: z.string().min(1).optional(),
673
+ rollover_type: z.enum(['full', 'none']).optional(),
674
+ bundle_rollover_type: z.enum(['full', 'none']).optional(),
675
+ included_credit: z.string().optional(),
676
+ meter_ids: z.array(z.string()).optional(),
677
+ credit_bundles: z
678
+ .array(z.object({
679
+ credit_bundle_id: z.string().optional(),
680
+ name: z.string(),
681
+ cost: z.string(),
682
+ credit_amount: z.string(),
683
+ is_deleted: z.boolean().optional(),
684
+ }))
685
+ .optional(),
686
+ }),
687
+ }, withErrorHandling(async (args) => {
688
+ const { plan_id, ...body } = args;
689
+ const api = getClient();
690
+ const res = await api.patch(`/v1/plans/${encodeURIComponent(plan_id)}`, body);
691
+ return jsonContent(res);
692
+ }));
693
+ server.registerTool('lava_delete_plan', {
694
+ title: 'Delete a plan',
695
+ description: 'Soft-delete a plan. Fails if it has active subscriptions.',
696
+ inputSchema: z.object({ plan_id: z.string() }),
697
+ }, withErrorHandling(async ({ plan_id }) => {
698
+ const api = getClient();
699
+ const res = await api.delete(`/v1/plans/${encodeURIComponent(plan_id)}`);
700
+ return jsonContent(res);
701
+ }));
702
+ // ── Webhooks ──
703
+ server.registerTool('lava_list_webhooks', {
704
+ title: 'List webhooks',
705
+ description: 'List webhook endpoints that receive Lava events.',
706
+ inputSchema: z.object(listPaginationSchema),
707
+ }, withErrorHandling(async ({ cursor, limit }) => {
708
+ const api = getClient();
709
+ const res = await api.list('/v1/webhooks', { cursor, limit });
710
+ return jsonContent(res);
711
+ }));
712
+ server.registerTool('lava_create_webhook', {
713
+ title: 'Create a webhook',
714
+ description: 'Register a URL to receive Lava event notifications.',
715
+ inputSchema: z.object({
716
+ name: z.string().min(1).describe('Label for the webhook'),
717
+ url: z
718
+ .string()
719
+ .url()
720
+ .describe('HTTPS URL that will receive POST events'),
721
+ }),
722
+ }, withErrorHandling(async (args) => {
723
+ const api = getClient();
724
+ const res = await api.post('/v1/webhooks', args);
725
+ return jsonContent(res);
726
+ }));
727
+ server.registerTool('lava_get_webhook', {
728
+ title: 'Get a webhook',
729
+ description: 'Fetch a single webhook by ID.',
730
+ inputSchema: z.object({ webhook_id: z.string() }),
731
+ }, withErrorHandling(async ({ webhook_id }) => {
732
+ const api = getClient();
733
+ const res = await api.get(`/v1/webhooks/${encodeURIComponent(webhook_id)}`);
734
+ return jsonContent(res);
735
+ }));
736
+ server.registerTool('lava_update_webhook', {
737
+ title: 'Update a webhook',
738
+ description: 'Update a webhook name and/or URL.',
739
+ inputSchema: z.object({
740
+ webhook_id: z.string(),
741
+ name: z.string().min(1).optional(),
742
+ url: z.string().url().optional(),
743
+ }),
744
+ }, withErrorHandling(async (args) => {
745
+ const { webhook_id, ...body } = args;
746
+ const api = getClient();
747
+ const res = await api.patch(`/v1/webhooks/${encodeURIComponent(webhook_id)}`, body);
748
+ return jsonContent(res);
749
+ }));
750
+ server.registerTool('lava_delete_webhook', {
751
+ title: 'Delete a webhook',
752
+ description: 'Permanently delete a webhook.',
753
+ inputSchema: z.object({ webhook_id: z.string() }),
754
+ }, withErrorHandling(async ({ webhook_id }) => {
755
+ const api = getClient();
756
+ const res = await api.delete(`/v1/webhooks/${encodeURIComponent(webhook_id)}`);
757
+ return jsonContent(res);
758
+ }));
759
+ // ── Spend keys (require an active wallet key in env or via lava_login) ──
760
+ const spendLimitSchema = z
761
+ .object({
762
+ amount: z.string().describe('Decimal spend limit (e.g. "10.00")'),
763
+ cycle: z.enum(['daily', 'weekly', 'monthly', 'total']),
764
+ })
765
+ .nullable();
766
+ const requestLimitSchema = z
767
+ .object({
768
+ count: z.number().int().min(1).describe('Max number of requests'),
769
+ cycle: z.enum(['daily', 'weekly', 'monthly', 'total']),
770
+ })
771
+ .nullable();
772
+ const rateLimitSchema = z
773
+ .object({
774
+ rpm: z.number().int().min(1).describe('Max requests per minute'),
775
+ burst: z
776
+ .number()
777
+ .int()
778
+ .nullable()
779
+ .optional()
780
+ .describe('Max burst size; must be ≤ rpm'),
781
+ })
782
+ .nullable();
783
+ server.registerTool('lava_list_spend_keys', {
784
+ title: 'List spend keys',
785
+ description: 'List spend keys owned by the active wallet key in this MCP session. Spend keys are scoped service keys for client-side or restricted proxy access.',
786
+ inputSchema: z.object(listPaginationSchema),
787
+ }, withErrorHandling(async ({ cursor, limit }) => {
788
+ const api = getWalletClient();
789
+ const res = await api.list('/v1/spend_keys', { cursor, limit });
790
+ return jsonContent(res);
791
+ }));
792
+ server.registerTool('lava_create_spend_key', {
793
+ title: 'Create a spend key',
794
+ description: 'Create a spend key for the active wallet key in this MCP session. The full key value is only returned once at creation. Optionally restrict by allowed models, providers, spend, request count, rate, or expiry.',
795
+ inputSchema: z.object({
796
+ name: z.string().min(1).max(255).describe('Label for the key'),
797
+ status: z
798
+ .enum(['active', 'paused'])
799
+ .optional()
800
+ .describe('Initial status; defaults to active'),
801
+ request_shape: z
802
+ .enum(['openai', 'anthropic'])
803
+ .optional()
804
+ .describe('Expected request format; defaults to openai'),
805
+ allowed_models: z
806
+ .array(z.string())
807
+ .nullable()
808
+ .optional()
809
+ .describe('Model IDs this key may use; null = all models'),
810
+ allowed_providers: z
811
+ .array(z.string())
812
+ .nullable()
813
+ .optional()
814
+ .describe('Provider names this key may use; null = all providers'),
815
+ spend_limit: spendLimitSchema
816
+ .optional()
817
+ .describe('Spend cap; null = no limit'),
818
+ request_limit: requestLimitSchema
819
+ .optional()
820
+ .describe('Request count cap; null = no limit'),
821
+ rate_limit: rateLimitSchema
822
+ .optional()
823
+ .describe('Rate limit; null = no limit'),
824
+ expires_at: z
825
+ .string()
826
+ .datetime()
827
+ .nullable()
828
+ .optional()
829
+ .describe('ISO 8601 expiry; null = never expires'),
830
+ }),
831
+ }, withErrorHandling(async (args) => {
832
+ const api = getWalletClient();
833
+ const res = await api.post('/v1/spend_keys', args);
834
+ return jsonContent(res);
835
+ }));
836
+ server.registerTool('lava_get_spend_key', {
837
+ title: 'Get a spend key',
838
+ description: 'Fetch a single spend key by ID (includes current spend and request counts).',
839
+ inputSchema: z.object({
840
+ spend_key_id: z.string().describe('The spend key ID'),
841
+ }),
842
+ }, withErrorHandling(async ({ spend_key_id }) => {
843
+ const api = getWalletClient();
844
+ const res = await api.get(`/v1/spend_keys/${encodeURIComponent(spend_key_id)}`);
845
+ return jsonContent(res);
846
+ }));
847
+ server.registerTool('lava_update_spend_key', {
848
+ title: 'Update a spend key',
849
+ description: "Update a spend key's name, status, restrictions, or expiry. All fields are optional; omitted fields are unchanged. Pass null to a limit field to remove that limit.",
850
+ inputSchema: z.object({
851
+ spend_key_id: z.string().describe('The spend key ID to update'),
852
+ name: z.string().min(1).max(255).optional(),
853
+ status: z.enum(['active', 'paused']).optional(),
854
+ request_shape: z.enum(['openai', 'anthropic']).optional(),
855
+ allowed_models: z
856
+ .array(z.string())
857
+ .nullable()
858
+ .optional()
859
+ .describe('null = allow all models'),
860
+ allowed_providers: z
861
+ .array(z.string())
862
+ .nullable()
863
+ .optional()
864
+ .describe('null = allow all providers'),
865
+ spend_limit: spendLimitSchema
866
+ .optional()
867
+ .describe('null = remove spend limit'),
868
+ request_limit: requestLimitSchema
869
+ .optional()
870
+ .describe('null = remove request limit'),
871
+ rate_limit: rateLimitSchema
872
+ .optional()
873
+ .describe('null = remove rate limit'),
874
+ expires_at: z
875
+ .string()
876
+ .datetime()
877
+ .nullable()
878
+ .optional()
879
+ .describe('null = never expires'),
880
+ }),
881
+ }, withErrorHandling(async (args) => {
882
+ const { spend_key_id, ...body } = args;
883
+ const api = getWalletClient();
884
+ const res = await api.put(`/v1/spend_keys/${encodeURIComponent(spend_key_id)}`, body);
885
+ return jsonContent(res);
886
+ }));
887
+ server.registerTool('lava_delete_spend_key', {
888
+ title: 'Delete a spend key',
889
+ description: 'Permanently revoke a spend key. The key will immediately stop working for proxy requests.',
890
+ inputSchema: z.object({
891
+ spend_key_id: z.string().describe('The spend key ID to delete'),
892
+ }),
893
+ }, withErrorHandling(async ({ spend_key_id }) => {
894
+ const api = getWalletClient();
895
+ const res = await api.delete(`/v1/spend_keys/${encodeURIComponent(spend_key_id)}`);
896
+ return jsonContent(res);
897
+ }));
898
+ server.registerTool('lava_rotate_spend_key', {
899
+ title: 'Rotate a spend key',
900
+ description: 'Generate a new secret for a spend key. The old secret is immediately invalidated. The new full key value is returned once — save it.',
901
+ inputSchema: z.object({
902
+ spend_key_id: z.string().describe('The spend key ID to rotate'),
903
+ }),
904
+ }, withErrorHandling(async ({ spend_key_id }) => {
905
+ const api = getWalletClient();
906
+ const res = await api.post(`/v1/spend_keys/${encodeURIComponent(spend_key_id)}/rotate`, {});
907
+ return jsonContent(res);
908
+ }));
909
+ // ── Checkout sessions ──
910
+ server.registerTool('lava_create_checkout_session', {
911
+ title: 'Create a checkout session',
912
+ description: 'Create a checkout session URL so an end-user can onboard, subscribe, or top up. Return the URL to the user to open in a browser.',
913
+ inputSchema: z.object({
914
+ checkout_mode: z.enum([
915
+ 'onboarding',
916
+ 'topup',
917
+ 'subscription',
918
+ 'credit_bundle',
919
+ ]),
920
+ origin_url: z
921
+ .string()
922
+ .url()
923
+ .describe('URL to return to after checkout (your app)'),
924
+ customer_id: z
925
+ .string()
926
+ .optional()
927
+ .describe('Required for topup/credit_bundle; optional for subscription'),
928
+ plan_id: z
929
+ .string()
930
+ .optional()
931
+ .describe('Required for subscription mode (plan ID)'),
932
+ credit_bundle_id: z
933
+ .string()
934
+ .optional()
935
+ .describe('Required for credit_bundle mode'),
936
+ }),
937
+ }, withErrorHandling(async (args) => {
938
+ const api = getClient();
939
+ const body = {
940
+ checkout_mode: args.checkout_mode,
941
+ origin_url: args.origin_url,
942
+ };
943
+ if (args.customer_id !== undefined) {
944
+ body.customer_id = args.customer_id;
945
+ }
946
+ if (args.plan_id !== undefined) {
947
+ body.plan_id = args.plan_id;
948
+ }
949
+ if (args.credit_bundle_id !== undefined) {
950
+ body.credit_bundle_id = args.credit_bundle_id;
951
+ }
952
+ const res = await api.post('/v1/checkout_sessions', body);
953
+ return jsonContent(res);
954
+ }));
955
+ // ── Subscriptions ──
956
+ server.registerTool('lava_list_subscriptions', {
957
+ title: 'List subscriptions',
958
+ description: 'List subscriptions (customer + plan associations). Filter by customer_id to check a specific customer\'s subscription. status defaults to "active"; pass "cancelled" or "all" to see others.',
959
+ inputSchema: z.object({
960
+ ...listPaginationSchema,
961
+ customer_id: z
962
+ .string()
963
+ .optional()
964
+ .describe("Filter by customer ID (e.g. to check one customer's subscription)"),
965
+ status: z
966
+ .enum(['active', 'cancelled', 'all'])
967
+ .optional()
968
+ .default('active')
969
+ .describe('Filter by status; default is "active"'),
970
+ }),
971
+ }, withErrorHandling(async (args) => {
972
+ const api = getClient();
973
+ const params = {};
974
+ if (args.cursor) {
975
+ params.cursor = args.cursor;
976
+ }
977
+ if (args.limit !== undefined) {
978
+ params.limit = String(args.limit);
979
+ }
980
+ if (args.customer_id) {
981
+ params.customer_id = args.customer_id;
982
+ }
983
+ if (args.status) {
984
+ params.status = args.status;
985
+ }
986
+ const res = await api.get('/v1/subscriptions', params);
987
+ return jsonContent(res);
988
+ }));
989
+ server.registerTool('lava_update_subscription', {
990
+ title: 'Update a subscription',
991
+ description: 'Update subscription settings (e.g. auto top-up bundle) by active subscription ID.',
992
+ inputSchema: z.object({
993
+ subscription_id: z.string().describe('Active subscription ID'),
994
+ auto_top_up_bundle_id: z
995
+ .string()
996
+ .nullable()
997
+ .describe('Credit bundle ID for auto top-up, or null to clear'),
998
+ }),
999
+ }, withErrorHandling(async (args) => {
1000
+ const { subscription_id, ...body } = args;
1001
+ const api = getClient();
1002
+ const res = await api.patch(`/v1/subscriptions/${encodeURIComponent(subscription_id)}`, body);
1003
+ return jsonContent(res);
1004
+ }));
1005
+ server.registerTool('lava_cancel_subscription', {
1006
+ title: 'Cancel a subscription',
1007
+ description: 'Schedule subscription cancellation at end of current billing cycle.',
1008
+ inputSchema: z.object({
1009
+ subscription_id: z
1010
+ .string()
1011
+ .describe('Active subscription ID to cancel'),
1012
+ }),
1013
+ }, withErrorHandling(async ({ subscription_id }) => {
1014
+ const api = getClient();
1015
+ const res = await api.delete(`/v1/subscriptions/${encodeURIComponent(subscription_id)}`);
1016
+ return jsonContent(res);
1017
+ }));
1018
+ // ── Credit bundles ──
1019
+ server.registerTool('lava_list_credit_bundles', {
1020
+ title: 'List credit bundles',
1021
+ description: 'List credit bundles (optionally filter by plan_id).',
1022
+ inputSchema: z.object({
1023
+ ...listPaginationSchema,
1024
+ plan_id: z.string().optional(),
1025
+ }),
1026
+ }, withErrorHandling(async (args) => {
1027
+ const api = getClient();
1028
+ const params = {};
1029
+ if (args.cursor) {
1030
+ params.cursor = args.cursor;
1031
+ }
1032
+ if (args.limit !== undefined) {
1033
+ params.limit = String(args.limit);
1034
+ }
1035
+ if (args.plan_id) {
1036
+ params.plan_id = args.plan_id;
1037
+ }
1038
+ const res = await api.get('/v1/credit_bundles', params);
1039
+ return jsonContent(res);
1040
+ }));
1041
+ server.registerTool('lava_get_credit_bundle', {
1042
+ title: 'Get a credit bundle',
1043
+ description: 'Fetch a single credit bundle by ID.',
1044
+ inputSchema: z.object({ credit_bundle_id: z.string() }),
1045
+ }, withErrorHandling(async ({ credit_bundle_id }) => {
1046
+ const api = getClient();
1047
+ const res = await api.get(`/v1/credit_bundles/${encodeURIComponent(credit_bundle_id)}`);
1048
+ return jsonContent(res);
1049
+ }));
1050
+ // ── Requests (proxy request history + manual tracking) ──
1051
+ server.registerTool('lava_list_requests', {
1052
+ title: 'List requests',
1053
+ description: 'List recent AI proxy requests (usage events) for debugging or auditing. Supports metadata_filters to find requests tagged with specific key-value pairs.',
1054
+ inputSchema: z.object({
1055
+ ...listPaginationSchema,
1056
+ customer_id: z.string().optional(),
1057
+ meter_id: z.string().optional(),
1058
+ metadata_filters: z
1059
+ .array(z.tuple([z.string(), z.string()]))
1060
+ .optional()
1061
+ .describe('Filter by metadata key-value pairs, e.g. [["user_id", "u_123"], ["session", "s_456"]]'),
1062
+ }),
1063
+ }, withErrorHandling(async (args) => {
1064
+ const api = getClient();
1065
+ const params = {};
1066
+ if (args.cursor) {
1067
+ params.cursor = args.cursor;
1068
+ }
1069
+ if (args.limit !== undefined) {
1070
+ params.limit = String(args.limit);
1071
+ }
1072
+ if (args.customer_id) {
1073
+ params.customer_id = args.customer_id;
1074
+ }
1075
+ if (args.meter_id) {
1076
+ params.meter_id = args.meter_id;
1077
+ }
1078
+ if (args.metadata_filters && args.metadata_filters.length > 0) {
1079
+ params.metadata_filters = JSON.stringify(args.metadata_filters);
1080
+ }
1081
+ const res = await api.get('/v1/requests', params);
1082
+ return jsonContent(res);
1083
+ }));
1084
+ server.registerTool('lava_get_request', {
1085
+ title: 'Get a request',
1086
+ description: 'Fetch a single proxy request by ID (usage event detail).',
1087
+ inputSchema: z.object({
1088
+ request_id: z.string().describe('The request ID'),
1089
+ }),
1090
+ }, withErrorHandling(async ({ request_id }) => {
1091
+ const api = getClient();
1092
+ const res = await api.get(`/v1/requests/${encodeURIComponent(request_id)}`);
1093
+ return jsonContent(res);
1094
+ }));
1095
+ server.registerTool('lava_track_request', {
1096
+ title: 'Track a manual request',
1097
+ description: "Record a usage event for billing outside the AI proxy — e.g. if you call an AI provider directly and want Lava to meter and charge for it. Idempotent: submitting the same request_id twice returns the existing record. The meter is identified by meter_slug (not meter_id). Usage units depend on the meter's tier_type: pass input_tokens/output_tokens for token-based meters, input_characters/output_characters for character-based meters, or input_seconds/output_seconds for time-based meters.",
1098
+ inputSchema: z.object({
1099
+ request_id: z
1100
+ .string()
1101
+ .describe('Unique ID for this request (caller-generated, used for idempotency)'),
1102
+ customer_id: z
1103
+ .string()
1104
+ .describe('The customer ID of the end-user being charged'),
1105
+ meter_slug: z
1106
+ .string()
1107
+ .describe('The slug of the meter to bill against (see lava_list_meters)'),
1108
+ input_tokens: z
1109
+ .number()
1110
+ .min(0)
1111
+ .optional()
1112
+ .describe('Input token count (for token-based meters)'),
1113
+ output_tokens: z
1114
+ .number()
1115
+ .min(0)
1116
+ .optional()
1117
+ .describe('Output token count (for token-based meters)'),
1118
+ input_characters: z
1119
+ .number()
1120
+ .min(0)
1121
+ .optional()
1122
+ .describe('Input character count (for character-based meters)'),
1123
+ output_characters: z
1124
+ .number()
1125
+ .min(0)
1126
+ .optional()
1127
+ .describe('Output character count (for character-based meters)'),
1128
+ input_seconds: z
1129
+ .number()
1130
+ .min(0)
1131
+ .optional()
1132
+ .describe('Input duration in seconds (for time-based meters)'),
1133
+ output_seconds: z
1134
+ .number()
1135
+ .min(0)
1136
+ .optional()
1137
+ .describe('Output duration in seconds (for time-based meters)'),
1138
+ metadata: z
1139
+ .record(z.string())
1140
+ .optional()
1141
+ .describe('Key-value metadata to attach to the request (queryable via metadata_filters)'),
1142
+ }),
1143
+ }, withErrorHandling(async (args) => {
1144
+ const api = getClient();
1145
+ const res = await api.post('/v1/requests', args);
1146
+ return jsonContent(res);
1147
+ }));
1148
+ // ── Models (catalog is public; no merchant auth required for list) ──
1149
+ server.registerTool('lava_list_models', {
1150
+ title: 'List Lava models',
1151
+ description: `List all AI models and services available through Lava's gateway (OpenAI, Anthropic, Google, etc.). Returns model id, provider, pricing, and capabilities. Use these model ids when calling the proxy (POST /v1/chat/completions). Read the lava://models-guide resource to explain how to use them. No API key required.`,
1152
+ inputSchema: z.object({}),
1153
+ }, withErrorHandling(async () => {
1154
+ const res = await fetchPublic('/v1/services');
1155
+ return jsonContent(res);
1156
+ }));
1157
+ server.registerTool('lava_chat_completions', {
1158
+ title: 'Call /v1/chat/completions',
1159
+ description: "Execute a real request against Lava's unified OpenAI-compatible chat endpoint. Pass the exact JSON body you would normally send to /v1/chat/completions. Uses the current MCP merchant session by default, or pass auth_token explicitly. Streaming is not supported through the MCP wrapper.",
1160
+ inputSchema: z.object({
1161
+ body_json: z
1162
+ .unknown()
1163
+ .describe('OpenAI-compatible chat completions request body.'),
1164
+ auth_token: z
1165
+ .string()
1166
+ .optional()
1167
+ .describe('Optional explicit auth token (for example a forward token or spend key). Defaults to the active MCP merchant session key.'),
1168
+ headers: gatewayHeadersSchema,
1169
+ }),
1170
+ }, withErrorHandling(async ({ body_json, auth_token, headers }) => {
1171
+ assertNoStreamingBody(body_json);
1172
+ const normalizedHeaders = buildGatewayHeaders(headers);
1173
+ normalizedHeaders['Content-Type'] = 'application/json';
1174
+ const res = await executeGatewayRequest({
1175
+ method: 'POST',
1176
+ path: '/v1/chat/completions',
1177
+ authToken: auth_token,
1178
+ headers: normalizedHeaders,
1179
+ body_json,
1180
+ });
1181
+ return jsonContent(res);
1182
+ }));
1183
+ server.registerTool('lava_messages', {
1184
+ title: 'Call /v1/messages',
1185
+ description: "Execute a real request against Lava's Anthropic-compatible messages endpoint. Pass the exact JSON body you would normally send to /v1/messages. Uses the current MCP merchant session by default, or pass auth_token explicitly. Streaming is not supported through the MCP wrapper.",
1186
+ inputSchema: z.object({
1187
+ body_json: z
1188
+ .unknown()
1189
+ .describe('Anthropic-compatible messages request body.'),
1190
+ auth_token: z
1191
+ .string()
1192
+ .optional()
1193
+ .describe('Optional explicit auth token (for example a forward token or spend key). Defaults to the active MCP merchant session key.'),
1194
+ headers: gatewayHeadersSchema,
1195
+ }),
1196
+ }, withErrorHandling(async ({ body_json, auth_token, headers }) => {
1197
+ assertNoStreamingBody(body_json);
1198
+ const normalizedHeaders = buildGatewayHeaders(headers);
1199
+ normalizedHeaders['Content-Type'] = 'application/json';
1200
+ if (!Object.keys(normalizedHeaders).some((key) => key.toLowerCase() === 'anthropic-version')) {
1201
+ normalizedHeaders['anthropic-version'] = '2023-06-01';
1202
+ }
1203
+ const res = await executeGatewayRequest({
1204
+ method: 'POST',
1205
+ path: '/v1/messages',
1206
+ authToken: auth_token,
1207
+ headers: normalizedHeaders,
1208
+ body_json,
1209
+ });
1210
+ return jsonContent(res);
1211
+ }));
1212
+ server.registerTool('lava_forward', {
1213
+ title: 'Call /v1/forward',
1214
+ description: "Generic passthrough to Lava's native provider proxy. Use this for LLM and non-LLM provider APIs such as TTS, transcription, phone calls, search, and profile lookup. target_url must be a Lava-supported provider URL.",
1215
+ inputSchema: z.object({
1216
+ target_url: z
1217
+ .string()
1218
+ .url()
1219
+ .describe('Full upstream provider URL that Lava should forward to.'),
1220
+ method: gatewayMethodSchema
1221
+ .optional()
1222
+ .default('POST')
1223
+ .describe('HTTP method to use for the forwarded request.'),
1224
+ auth_token: z
1225
+ .string()
1226
+ .optional()
1227
+ .describe('Optional explicit auth token (for example a forward token or spend key). Defaults to the active MCP merchant session key.'),
1228
+ headers: gatewayHeadersSchema,
1229
+ ...gatewayBodySchema,
1230
+ }),
1231
+ }, withErrorHandling(async (args) => {
1232
+ assertNoStreamingBody(args.body_json);
1233
+ assertGatewayTargetUrl(args.target_url);
1234
+ const res = await executeGatewayRequest({
1235
+ method: args.method,
1236
+ path: `/v1/forward?u=${encodeURIComponent(args.target_url)}`,
1237
+ authToken: args.auth_token,
1238
+ headers: args.headers,
1239
+ body_json: args.body_json,
1240
+ body_text: args.body_text,
1241
+ body_base64: args.body_base64,
1242
+ });
1243
+ return jsonContent(res);
1244
+ }));
1245
+ server.registerTool('lava_rewrite', {
1246
+ title: 'Call /v1/rewrite',
1247
+ description: "Generic passthrough to Lava's rewrite proxy for cross-provider request/response translation. Use this when your client request format differs from the upstream provider format. target_url must be a Lava-supported provider URL. Streaming is not supported through the MCP wrapper.",
1248
+ inputSchema: z.object({
1249
+ client_format: z
1250
+ .enum(['openai', 'anthropic', 'google', 'bedrock'])
1251
+ .describe('The request/response format your client is sending and expects back.'),
1252
+ target_url: z
1253
+ .string()
1254
+ .url()
1255
+ .describe('Full upstream provider URL that Lava should call after translation.'),
1256
+ method: gatewayMethodSchema
1257
+ .optional()
1258
+ .default('POST')
1259
+ .describe('HTTP method to use for the rewrite request.'),
1260
+ auth_token: z
1261
+ .string()
1262
+ .optional()
1263
+ .describe('Optional explicit auth token (for example a forward token or spend key). Defaults to the active MCP merchant session key.'),
1264
+ input_format: z
1265
+ .enum(['openai', 'anthropic', 'google', 'bedrock'])
1266
+ .optional()
1267
+ .describe('Optional x-lava-input-format override when Lava should treat the request body as a different format than client_format.'),
1268
+ headers: gatewayHeadersSchema,
1269
+ ...gatewayBodySchema,
1270
+ }),
1271
+ }, withErrorHandling(async (args) => {
1272
+ assertNoStreamingBody(args.body_json);
1273
+ assertGatewayTargetUrl(args.target_url);
1274
+ const normalizedHeaders = buildGatewayHeaders(args.headers);
1275
+ if (args.input_format) {
1276
+ normalizedHeaders['x-lava-input-format'] = args.input_format;
1277
+ }
1278
+ const res = await executeGatewayRequest({
1279
+ method: args.method,
1280
+ path: `/v1/rewrite/${args.client_format}?u=${encodeURIComponent(args.target_url)}`,
1281
+ authToken: args.auth_token,
1282
+ headers: normalizedHeaders,
1283
+ body_json: args.body_json,
1284
+ body_text: args.body_text,
1285
+ body_base64: args.body_base64,
1286
+ });
1287
+ return jsonContent(res);
1288
+ }));
1289
+ // Resource: how to use Lava's models (for the AI to explain to the user)
1290
+ server.registerResource('lava_models_guide', 'lava://models-guide', {
1291
+ title: 'How to use Lava models',
1292
+ description: "Explains how to call Lava's proxy to use the listed models. Use when the user or agent asks how to use the models, how to send requests, or how to integrate the gateway.",
1293
+ mimeType: 'text/plain',
1294
+ }, () => {
1295
+ const base = trimTrailingSlash(BASE_URL);
1296
+ const guide = `How to use Lava's models and proxy endpoints
1297
+ =============================================
1298
+
1299
+ 1. List models
1300
+ Call lava_list_models (or GET ${base}/v1/services) to get the full catalog:
1301
+ model id, provider, pricing (tokens_1m, free, etc.), and capabilities
1302
+ (chat, streaming, embeddings, etc.).
1303
+
1304
+ GET ${base}/v1/models returns the same list in OpenAI API format
1305
+ ({ object: "list", data: [{ id, owned_by }] }) for SDK compatibility.
1306
+
1307
+ 2. Chat completions — OpenAI-compatible unified endpoint
1308
+ The simplest way to use any model regardless of its native provider.
1309
+
1310
+ POST ${base}/v1/chat/completions
1311
+
1312
+ Headers:
1313
+ Authorization: Bearer <your_key>
1314
+ Content-Type: application/json
1315
+
1316
+ Body:
1317
+ { "model": "<model_id>", "messages": [{"role": "user", "content": "Hello"}] }
1318
+
1319
+ Use the exact "id" from lava_list_models as "model".
1320
+ Examples: "gpt-4o", "claude-opus-4-5", "gemini-2.0-flash", "deepseek-chat".
1321
+ The Lava API supports streaming, but these MCP tools do not. For MCP usage,
1322
+ keep "stream": false and use the SDK or direct proxy if you need streaming.
1323
+
1324
+ Lava authenticates the key, routes to the right provider, meters usage,
1325
+ and bills according to the merchant's meter config.
1326
+
1327
+ 3. Forward proxy — drop-in SDK replacement (no format translation)
1328
+ Point any existing SDK at Lava without changing your code.
1329
+ Lava forwards the request as-is to the provider and handles auth + billing.
1330
+
1331
+ URL structure:
1332
+ ${base}/v1/forward/<provider_url>
1333
+
1334
+ The provider URL can omit the scheme (https:// is assumed).
1335
+
1336
+ Examples:
1337
+
1338
+ a) OpenAI SDK pointing at Lava:
1339
+ Change base URL from https://api.openai.com
1340
+ to ${base}/v1/forward/api.openai.com
1341
+ The SDK appends /v1/chat/completions → Lava forwards to OpenAI.
1342
+
1343
+ b) Anthropic SDK pointing at Lava:
1344
+ Change base URL from https://api.anthropic.com
1345
+ to ${base}/v1/forward/api.anthropic.com
1346
+ The SDK appends /v1/messages → Lava forwards to Anthropic.
1347
+
1348
+ Auth: send your Lava key as the Bearer token.
1349
+ Lava substitutes it with the real provider key server-side.
1350
+
1351
+ 4. Rewrite proxy — cross-format translation
1352
+ Send requests in one SDK format, have Lava translate them to any provider's
1353
+ native format (and translate the response back). Lets you use a single SDK
1354
+ across all providers, including Bedrock and Google native APIs.
1355
+
1356
+ URL structure:
1357
+ ${base}/v1/rewrite/<clientFormat>/<provider_url>
1358
+
1359
+ <clientFormat> is the format YOUR code sends and expects back:
1360
+ openai | anthropic | google | bedrock
1361
+
1362
+ <provider_url> is the full provider endpoint URL (scheme required).
1363
+ The provider's native format is auto-detected from the hostname.
1364
+
1365
+ Auth: send your Lava key as Bearer. Lava injects the real provider key.
1366
+
1367
+ Optional header:
1368
+ x-lava-input-format: <format> Override auto-detected input format
1369
+ (useful when request and response formats differ)
1370
+
1371
+ The Lava API supports streaming, but these MCP tools do not. For MCP usage,
1372
+ keep "stream": false and use the SDK or direct proxy if you need streaming.
1373
+
1374
+ Examples:
1375
+
1376
+ a) OpenAI SDK → Anthropic (use claude models with the OpenAI SDK):
1377
+ POST ${base}/v1/rewrite/openai/https://api.anthropic.com/v1/messages
1378
+ Body: OpenAI chat completions format { model, messages, ... }
1379
+ Response: OpenAI chat completions format (translated from Anthropic)
1380
+
1381
+ b) Anthropic SDK → OpenAI (use GPT models with the Anthropic SDK):
1382
+ POST ${base}/v1/rewrite/anthropic/https://api.openai.com/v1/chat/completions
1383
+ Body: Anthropic messages format { model, messages, max_tokens, ... }
1384
+ Response: Anthropic messages format (translated from OpenAI)
1385
+
1386
+ c) OpenAI SDK → Bedrock (use Bedrock models with the OpenAI SDK):
1387
+ POST ${base}/v1/rewrite/openai/https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-sonnet-20241022-v2:0/converse
1388
+ Body: OpenAI chat completions format
1389
+ Response: OpenAI chat completions format (translated from Bedrock Converse)
1390
+ Note: Bedrock streaming auto-rewrites /converse → /converse-stream.
1391
+
1392
+ d) Anthropic SDK → Bedrock:
1393
+ POST ${base}/v1/rewrite/anthropic/https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-sonnet-20241022-v2:0/converse
1394
+ Body: Anthropic messages format
1395
+ Response: Anthropic messages format (translated from Bedrock Converse)
1396
+
1397
+ e) OpenAI SDK → Google (Gemini native API):
1398
+ POST ${base}/v1/rewrite/openai/https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent
1399
+ Body: OpenAI chat completions format
1400
+ Response: OpenAI chat completions format (translated from Google)
1401
+
1402
+ Provider URL → format auto-detection (by hostname):
1403
+ *.amazonaws.com → bedrock
1404
+ *.googleapis.com → google
1405
+ api.anthropic.com → anthropic
1406
+ everything else → openai (any OpenAI-compatible API)
1407
+
1408
+ Input format auto-detection (for x-lava-input-format override or ambiguous cases):
1409
+ Resolution order:
1410
+ 1. x-lava-input-format header (explicit override — always wins)
1411
+ 2. anthropic-version header present → anthropic
1412
+ 3. x-goog-api-key header present → google
1413
+ 4. Body structure heuristics:
1414
+ contents[] array → google
1415
+ messages[] without model field → bedrock
1416
+ max_tokens or system + no system role → anthropic
1417
+ default → openai
1418
+
1419
+ Other notes:
1420
+ - anthropic-version: 2023-06-01 is auto-injected for Anthropic targets.
1421
+ - Bedrock streaming auto-rewrites /converse → /converse-stream in the URL.
1422
+ - Use lava_list_models to find the correct model IDs for each provider.
1423
+
1424
+ 5. Anthropic Messages endpoint (direct Anthropic SDK compatibility)
1425
+ An alternative to the rewrite proxy specifically for Anthropic SDK users.
1426
+ Accepts the Anthropic Messages API format natively — no format specified in URL.
1427
+
1428
+ POST ${base}/v1/messages
1429
+
1430
+ Headers:
1431
+ Authorization: Bearer <your_key>
1432
+ anthropic-version: 2023-06-01
1433
+ Content-Type: application/json
1434
+
1435
+ Body (Anthropic Messages format):
1436
+ { "model": "claude-opus-4-5", "max_tokens": 1024,
1437
+ "messages": [{"role": "user", "content": "Hello"}] }
1438
+
1439
+ Response: Anthropic Messages format.
1440
+
1441
+ Use this when you want to point an existing Anthropic SDK at Lava by changing
1442
+ only the base URL to ${base} — no URL rewriting needed.
1443
+ For full cross-provider format translation, use /v1/rewrite instead (section 4).
1444
+
1445
+ 6. Authentication and keys
1446
+ MCP bootstrap:
1447
+ - If the MCP server starts without LAVA_SECRET_KEY / LAVA_WALLET_KEY, call
1448
+ lava_login. It runs the same browser auth flow as Lava.login() from the
1449
+ Node SDK and stores the returned keys in this MCP session.
1450
+ - To create proxy auth from the active merchant session, call
1451
+ lava_generate_forward_token.
1452
+
1453
+ - Merchant secret key (lava_sk_...): full merchant access; use for server-side
1454
+ proxy calls on behalf of the merchant.
1455
+ - Wallet key (lava_wk_...): authenticates on behalf of an end-user's wallet;
1456
+ used to manage spend keys.
1457
+ - Spend key: a scoped key owned by a wallet. Can restrict to specific models,
1458
+ providers, spend limits, request counts, rate limits, and expiry.
1459
+ Manage via lava_create_spend_key / lava_list_spend_keys (requires an active wallet key from env or lava_login).`;
1460
+ return {
1461
+ contents: [
1462
+ {
1463
+ uri: 'lava://models-guide',
1464
+ mimeType: 'text/plain',
1465
+ text: guide,
1466
+ },
1467
+ ],
1468
+ };
1469
+ });
1470
+ // Resource: merchant overview (read-only summary)
1471
+ server.registerResource('lava_overview', 'lava://overview', {
1472
+ title: 'Lava account overview',
1473
+ description: 'Summary of the Lava account (meters, keys, customers count). Use when the user asks "what do I have set up?"',
1474
+ mimeType: 'application/json',
1475
+ }, async () => {
1476
+ return await withJsonResourceErrorHandling('lava://overview', async () => {
1477
+ const api = getClient();
1478
+ const [metersRes, keysRes, customersRes] = await Promise.all([
1479
+ api.list('/v1/meters', {
1480
+ limit: 5,
1481
+ }),
1482
+ api.list('/v1/secret_keys', { limit: 5 }),
1483
+ api.list('/v1/customers', { limit: 5 }),
1484
+ ]);
1485
+ const meters = metersRes.data;
1486
+ const keys = keysRes.data;
1487
+ const customers = customersRes.data;
1488
+ return {
1489
+ summary: 'Lava account overview (first 5 of each)',
1490
+ meters_count: meters.length,
1491
+ secret_keys_count: keys.length,
1492
+ customers_count: customers.length,
1493
+ meters: meters.map((m) => ({ id: m.meter_id, name: m.name })),
1494
+ recent_customers: customers.map((c) => c.customer_id),
1495
+ };
1496
+ });
1497
+ });
1498
+ // Resource: full OpenAPI spec (for agent self-discovery of schemas and endpoints)
1499
+ server.registerResource('lava_openapi', 'lava://openapi', {
1500
+ title: 'Lava OpenAPI spec',
1501
+ description: 'Full OpenAPI 3.x spec for the Lava REST API. Use when you need exact request/response schemas, field names, or to discover endpoints not exposed as MCP tools.',
1502
+ mimeType: 'application/json',
1503
+ }, async () => {
1504
+ return await withJsonResourceErrorHandling('lava://openapi', async () => {
1505
+ return await fetchPublic('/v1/openapi');
1506
+ });
1507
+ });
1508
+ // Resource: webhook event types reference
1509
+ server.registerResource('lava_webhook_events', 'lava://webhook-events', {
1510
+ title: 'Lava webhook event types',
1511
+ description: 'Reference for all webhook event types Lava sends to registered webhook URLs. Use when setting up webhooks or handling incoming webhook payloads.',
1512
+ mimeType: 'text/plain',
1513
+ }, () => {
1514
+ const guide = `Lava Webhook Events
1515
+ ===================
1516
+
1517
+ Lava sends POST requests to your registered webhook URL when these events occur.
1518
+ Register a URL with lava_create_webhook; Lava will POST JSON to it for each event.
1519
+
1520
+ Event types
1521
+ -----------
1522
+
1523
+ customer.created
1524
+ Fired when an end-user completes the onboarding checkout flow and is linked to your merchant.
1525
+ Use this to provision access, send a welcome message, or update your database.
1526
+
1527
+ customer.wallet.balance.updated
1528
+ Fired when a wallet's balance changes — e.g. after a top-up, a usage charge, or a credit allocation.
1529
+ Use this to show the user their new balance or trigger low-balance warnings.
1530
+
1531
+ customer.deleted
1532
+ Fired when a customer relationship is removed (either by the merchant or the user).
1533
+ Use this to revoke access or clean up associated records.
1534
+
1535
+ Payload shape
1536
+ -------------
1537
+ Every webhook POST has this JSON body:
1538
+
1539
+ {
1540
+ "event": "<event_type>",
1541
+ "data": {
1542
+ "customer_id": "cus_...",
1543
+ "merchant_id": "mer_...",
1544
+ "wallet_id": "wal_...",
1545
+ "account": { ... },
1546
+ "email": { ... },
1547
+ "phone": { ... }
1548
+ }
1549
+ }
1550
+
1551
+ Verification
1552
+ ------------
1553
+ Lava signs each request with an HMAC-SHA256 signature in the X-Webhook-Signature header.
1554
+ Verify it using the webhook secret from your dashboard to ensure the payload is authentic.`;
1555
+ return {
1556
+ contents: [
1557
+ {
1558
+ uri: 'lava://webhook-events',
1559
+ mimeType: 'text/plain',
1560
+ text: guide,
1561
+ },
1562
+ ],
1563
+ };
1564
+ });
1565
+ // Prompt: how to sign up an end-user for Lava
1566
+ server.registerPrompt('lava_sign_up_user', {
1567
+ title: 'Sign up a user for Lava',
1568
+ description: 'Use when the user or developer wants to sign up an end-user for Lava (create wallet, link to merchant). Returns exact steps and tool call.',
1569
+ }, async () => ({
1570
+ messages: [
1571
+ {
1572
+ role: 'user',
1573
+ content: {
1574
+ type: 'text',
1575
+ text: `The user wants to sign up an end-user for Lava. Do this:
1576
+
1577
+ 1. Call lava_create_checkout_session with:
1578
+ - checkout_mode: "onboarding"
1579
+ - origin_url: the URL of the app or page the end-user should return to after completing sign-up (must be a valid URL the merchant controls)
1580
+
1581
+ 2. The API returns a checkout_url. Tell the user to open that URL in a browser. The end-user will complete Lava sign-up (e.g. phone/email, wallet creation) and then be redirected back to origin_url.
1582
+
1583
+ 3. After onboarding, the end-user becomes a customer on the merchant; use lava_list_customers to see them. To let them subscribe or top up later, use lava_create_checkout_session with checkout_mode "subscription" or "topup" and the customer_id.
1584
+
1585
+ Run lava_create_checkout_session now if you have an origin_url (ask for it if not).`,
1586
+ },
1587
+ },
1588
+ ],
1589
+ }));
1590
+ // Prompt: guide for new users
1591
+ server.registerPrompt('lava_get_started', {
1592
+ title: 'Get started with Lava',
1593
+ description: 'Use when the user wants to set up Lava, understand what Lava can do, or get step-by-step guidance. Returns a concrete ordered flow from zero to charging users.',
1594
+ }, async () => ({
1595
+ messages: [
1596
+ {
1597
+ role: 'user',
1598
+ content: {
1599
+ type: 'text',
1600
+ text: `You're helping someone get started with Lava (usage-based billing for AI). Walk them through the exact steps to go from zero to charging real users. Be concrete and offer to run each tool for them.
1601
+
1602
+ **What Lava is in one sentence**: Lava is a billing layer you drop in front of any AI API — your users pay Lava, Lava pays you, and you set the prices.
1603
+
1604
+ **Zero-to-charging-users in 5 steps**:
1605
+
1606
+ Step 0 — Authenticate this MCP session
1607
+ If the MCP was started without env keys, call lava_login first. That loads both the merchant secret key and wallet key into this MCP session.
1608
+
1609
+ Step 1 — Create a meter (how you price usage)
1610
+ Call lava_create_meter. A meter defines what you count (tokens, requests, etc.) and what you charge.
1611
+ Simplest example: flat rate, $1 per million tokens:
1612
+ name: "Chat API", slug: "chat-api", unit_label: "token"
1613
+ rate_type: "flat", tier_type: "per_unit"
1614
+ tiers: [{ start: "0", rate: "0.000001" }]
1615
+ Ask: "What do you want to charge for? Tokens, requests, or something else?"
1616
+
1617
+ Step 2 — Create a plan (optional but recommended)
1618
+ Call lava_create_plan to create a plan that can include an optional recurring fee, included credit, and linked meters.
1619
+ Example: name: "Pro", period_amount: "10", billing_interval: "month", included_credit: "10.00", meter_ids: ["mtr_..."]
1620
+ Skip this step if you just want pay-as-you-go with no monthly fee.
1621
+
1622
+ Step 3 — Sign up your first user
1623
+ Call lava_create_checkout_session with checkout_mode: "onboarding" and origin_url (your app's URL).
1624
+ Give the returned checkout_url to your user to open in a browser. They create a Lava wallet and get linked to you.
1625
+ After they complete it, call lava_list_customers to see them. They now have a customer_id.
1626
+
1627
+ Step 4 — Point your app at Lava's proxy
1628
+ Change your AI client's base URL to https://api.lava.so and use a Lava forward token as the bearer token.
1629
+ The proxy bills the user automatically for every request. No code changes beyond base URL + auth.
1630
+ If you want to prove the route inside this MCP before changing app code, call lava_chat_completions, lava_messages, lava_forward, or lava_rewrite directly.
1631
+ Or, if you run your own integration, call lava_track_request after each AI call to record usage manually.
1632
+
1633
+ **After that**: call lava_get_usage to see billing data, lava_list_customers to see your users, lava_create_checkout_session (mode "topup" or "subscription") to let users add funds or subscribe.
1634
+
1635
+ Ask the user which step they want to start with, then run the relevant tool.`,
1636
+ },
1637
+ },
1638
+ ],
1639
+ }));
1640
+ // Prompt: how to integrate Lava into an existing app
1641
+ server.registerPrompt('lava_integrate', {
1642
+ title: 'Integrate Lava into your app',
1643
+ description: "Use when the user wants to add Lava billing to an existing app. Returns concrete code examples for pointing OpenAI/Anthropic SDKs at Lava's proxy.",
1644
+ }, async () => ({
1645
+ messages: [
1646
+ {
1647
+ role: 'user',
1648
+ content: {
1649
+ type: 'text',
1650
+ text: `You're helping a developer integrate Lava billing into their existing app. Show them the exact code change needed — it's just changing a base URL and auth token. Be concrete.
1651
+
1652
+ **The core idea**: Lava's proxy accepts standard OpenAI, Anthropic, and other AI API formats. You point your existing SDK at Lava instead of the AI provider, and Lava handles billing automatically.
1653
+
1654
+ **What you need first**:
1655
+ - A merchant auth context. Inside this MCP, either call lava_login or use an existing merchant secret key from lava_list_secret_keys / lava_create_secret_key.
1656
+ - A forward token (from lava_generate_forward_token) or another valid Lava bearer token for the gateway
1657
+ - Your user must have an active Lava customer record (created via lava_create_checkout_session with checkout_mode "onboarding")
1658
+
1659
+ **Code examples**:
1660
+
1661
+ Python (OpenAI SDK → Lava proxy):
1662
+ \`\`\`python
1663
+ from openai import OpenAI
1664
+
1665
+ client = OpenAI(
1666
+ base_url="https://api.lava.so/v1",
1667
+ api_key="YOUR_LAVA_GATEWAY_TOKEN", # forward token or spend key
1668
+ )
1669
+
1670
+ response = client.chat.completions.create(
1671
+ model="openai/gpt-4o", # prefix with provider: openai/, anthropic/, google/, etc.
1672
+ messages=[{"role": "user", "content": "Hello!"}],
1673
+ )
1674
+ \`\`\`
1675
+
1676
+ TypeScript/JavaScript (OpenAI SDK → Lava proxy):
1677
+ \`\`\`typescript
1678
+ import OpenAI from 'openai';
1679
+
1680
+ const client = new OpenAI({
1681
+ baseURL: 'https://api.lava.so/v1',
1682
+ apiKey: 'YOUR_LAVA_GATEWAY_TOKEN', // forward token or spend key
1683
+ });
1684
+
1685
+ const response = await client.chat.completions.create({
1686
+ model: 'openai/gpt-4o',
1687
+ messages: [{ role: 'user', content: 'Hello!' }],
1688
+ });
1689
+ \`\`\`
1690
+
1691
+ Python (Anthropic SDK → Lava proxy using /v1/messages):
1692
+ \`\`\`python
1693
+ import anthropic
1694
+
1695
+ client = anthropic.Anthropic(
1696
+ base_url="https://api.lava.so",
1697
+ api_key="YOUR_LAVA_GATEWAY_TOKEN",
1698
+ )
1699
+
1700
+ message = client.messages.create(
1701
+ model="anthropic/claude-sonnet-4-6",
1702
+ max_tokens=1024,
1703
+ messages=[{"role": "user", "content": "Hello!"}],
1704
+ )
1705
+ \`\`\`
1706
+
1707
+ TypeScript (Anthropic SDK → Lava proxy):
1708
+ \`\`\`typescript
1709
+ import Anthropic from '@anthropic-ai/sdk';
1710
+
1711
+ const client = new Anthropic({
1712
+ baseURL: 'https://api.lava.so',
1713
+ apiKey: 'YOUR_LAVA_GATEWAY_TOKEN',
1714
+ });
1715
+ \`\`\`
1716
+
1717
+ **Model names**: Prefix with the provider slug. Examples:
1718
+ - OpenAI: \`openai/gpt-4o\`, \`openai/gpt-4o-mini\`
1719
+ - Anthropic: \`anthropic/claude-opus-4-6\`, \`anthropic/claude-sonnet-4-6\`
1720
+ - Google: \`google/gemini-2.0-flash\`
1721
+ Call lava_list_models to see all available models and their exact slugs.
1722
+
1723
+ **Per-customer billing**: Generate one forward token per customer using lava_generate_forward_token with the customer_id and meter_slug. When the user makes a request, pass that forward token as the bearer token so Lava bills the right customer and meter.
1724
+
1725
+ **If you can't change the base URL** (e.g. third-party lib): Use lava_track_request after each AI call to manually record usage. You'll need the customer_id, meter_slug, and token counts.
1726
+
1727
+ Ask which language/SDK they use and offer to help adapt the example.`,
1728
+ },
1729
+ },
1730
+ ],
1731
+ }));
1732
+ const transport = new StdioServerTransport();
1733
+ await server.connect(transport);
1734
+ }
1735
+ main().catch((err) => {
1736
+ const message = err instanceof Error ? err.message : String(err);
1737
+ // biome-ignore lint/suspicious/noConsole: ok
1738
+ console.error(`Lava MCP failed to start: ${message}`);
1739
+ process.exit(1);
1740
+ });
1741
+ //# sourceMappingURL=index.js.map