@paytaca/opencode-plugin 0.2.2 → 0.3.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.
@@ -1,2 +1,2 @@
1
- export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\n// LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).\n// The same token id is used by the payment wrapper's payWithLift().\nconst LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.';\n }\n return parts.join('\\n');\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n');\n}\n\n// Plan pricing as a markdown table per model, optionally filtered to one model\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n if (models.length === 0) {\n return 'No models available.';\n }\n const lines = [];\n for (const m of models) {\n const name = m.display_name || m.id;\n const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';\n const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;\n if (lines.length > 0) lines.push('');\n lines.push('**' + name + (tier && !tierInName ? ' (' + tier + ')' : '') + '**');\n lines.push('');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n if (tiers.length === 0) {\n lines.push('No pricing configured.');\n continue;\n }\n lines.push('| Duration | USD | BCH | Sats |');\n lines.push('|---|---|---|---|');\n const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));\n for (const t of sorted) {\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';\n const satsStr = Number(sats).toLocaleString('en-US');\n lines.push('| ' + (t.minutes || 0) + ' min | ' + usd + ' | ' + bch + ' | ' + satsStr + ' |');\n }\n }\n return lines.join('\\n');\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:s*([d.]+)s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// LIFT token balance in base units (2 decimals), null when the CLI output\n// cannot be parsed.\nasync function getLiftBalanceUnits() {\n const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);\n const match = out.match(/Balance:s*([d.]+)s*LIFT/i);\n if (!match) return null;\n return BigInt(Math.round(parseFloat(match[1]) * 100));\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH \u2014 the\n // LIFT path funds the plan by selling tokens, so no BCH balance is required.\n if (paymentMethod === 'bch') {\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n } else {\n // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting\n // for approval and then failing inside the wrapper.\n const liftBalanceUnits = await getLiftBalanceUnits();\n if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method \"bch\".');\n }\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n const config = {\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n };\n if (paymentMethod === 'lift') {\n config.paymentMethod = 'lift';\n }\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT \u2014 LIFT phrasing: when the user says \"pay with LIFT\", \"pay with LIFT tokens\", \"use LIFT\", or mentions paying a plan with their LIFT token balance, set payment_method to \"lift\". LIFT is the Paytaca token users hold to pay for AI plans; \"pay with LIFT\" is NOT asking to buy a plan called LIFT. Default to \"bch\" unless the user explicitly mentions LIFT/tokens.',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says \"pay with LIFT\" or \"pay with LIFT tokens\" \u2014 this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
1
+ export declare const MCP_SERVER_CONTENT = "#!/usr/bin/env node\n/**\n * Paytaca MCP Server\n *\n * Registers Paytaca tools with opencode so the assistant can work with real\n * data instead of guessing. Two groups:\n * - Paytaca AI account (backend): credits, models, plan pricing\n * - Paytaca wallet (paytaca CLI): balance, transactions, receiving address,\n * token holdings, and sending funds\n *\n * The send tool moves real funds \u2014 opencode is configured (via the plugin's\n * config hook) to require explicit user approval before it runs.\n *\n * Loaded automatically via the plugin's config hook (cfg.mcp['paytaca']).\n * Uses only Node.js built-in modules.\n */\n\nconst http = require('http');\nconst https = require('https');\nconst { spawn } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\nconst os = require('os');\n\nconst CONFIG_DIR = process.env.PAYTACA_CONFIG_DIR || path.join(os.homedir(), '.opencode-paytaca');\nconst PAYTACA_CMD = process.env.PAYTACA_CMD || 'paytaca';\nconst DEFAULT_BACKEND = process.env.PAYTACA_BACKEND_URL || 'https://api.paytaca.ai';\n\n// LIFT is the Paytaca token users can pay AI plans with (sold via Cauldron).\n// The same token id is used by the payment wrapper's payWithLift().\nconst LIFT_TOKEN_ID = '5932b2fd4915d6a75d3ec53282cd49118149a2176ee67ed68b1111ff0786f7fc';\n\nconst PROTOCOL_VERSION = '2025-06-18';\n\n// Logging setup\nconst LOG_FILE = path.join(CONFIG_DIR, 'mcp.log');\nconst logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });\nfunction log(message) {\n const timestamp = new Date().toISOString();\n logStream.write(timestamp + ' [MCP] ' + message + '\\n');\n}\n\n// Load fresh config on every call so walletHash/backendUrl never go stale\nfunction loadConfig() {\n try {\n return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'config.json'), 'utf8'));\n } catch (e) {\n return {};\n }\n}\n\nlet BACKEND_URL = DEFAULT_BACKEND;\nlet WALLET_HASH = '';\nfunction refreshConfig() {\n const cfg = loadConfig();\n BACKEND_URL = process.env.PAYTACA_BACKEND_URL || cfg.backendUrl || DEFAULT_BACKEND;\n WALLET_HASH = cfg.walletHash || '';\n}\n\n// Fetch a JSON payload over HTTP(S)\nfunction getJson(url, headers) {\n return new Promise((resolve, reject) => {\n let u;\n try {\n u = new URL(url);\n } catch (e) {\n return reject(new Error('Invalid URL: ' + url));\n }\n const requester = u.protocol === 'https:' ? https : http;\n const req = requester.get({\n hostname: u.hostname,\n port: u.port || (u.protocol === 'https:' ? 443 : 80),\n path: u.pathname + u.search,\n headers: headers || {},\n }, (res) => {\n let data = '';\n res.on('data', (chunk) => { data += chunk; });\n res.on('end', () => {\n if (res.statusCode >= 400) {\n reject(new Error('HTTP ' + res.statusCode + ': ' + data.substring(0, 200)));\n return;\n }\n try {\n resolve(JSON.parse(data));\n } catch (e) {\n reject(new Error('Invalid JSON response'));\n }\n });\n });\n req.on('error', reject);\n req.setTimeout(15000, () => {\n req.destroy();\n reject(new Error('Request timed out'));\n });\n });\n}\n\n// Run a shell command with a timeout\nfunction runCommand(cmd, args, timeoutMs) {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { shell: false });\n let stdout = '';\n let stderr = '';\n let settled = false;\n const timer = setTimeout(() => {\n if (settled) return;\n settled = true;\n try { child.kill(); } catch (e) {}\n reject(new Error('Command timed out'));\n }, timeoutMs || 15000);\n child.stdout.on('data', (d) => { stdout += d.toString(); });\n child.stderr.on('data', (d) => { stderr += d.toString(); });\n child.on('close', (code) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n if (code === 0) resolve(stdout.trim());\n else reject(new Error(stderr.trim() || 'Command exited with code ' + code));\n });\n child.on('error', (err) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n reject(err);\n });\n });\n}\n\n// Format seconds as MM:SS or HH:MM:SS\nfunction formatDuration(totalSeconds) {\n const hours = Math.floor(totalSeconds / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const secs = totalSeconds % 60;\n if (hours > 0) {\n return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(secs).padStart(2, '0');\n }\n return minutes + ':' + String(secs).padStart(2, '0');\n}\n\n// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.\n// The assistant fetches this when the user asks \"how do I...\", \"what is...\",\n// or anything about funding, paying, or how the service works.\nconst FAQ = [\n ['What is Paytaca AI?', 'Paytaca AI is a service that gives you affordable, convenient access to AI inference through your Paytaca wallet. Instead of per-token metering or a monthly subscription, you buy short time-based plans in advance \u2014 15, 30, or 60 minutes of a model \u2014 and use them up as you chat.'],\n ['What makes Paytaca AI different from other AI services?', 'We keep AI access affordable and simple by billing in pre-purchased blocks of time rather than per token. Pick a model, buy a 15/30/60-minute plan with your wallet, and the time is yours to use. No surprises per message, no recurring billing.'],\n ['Did this plugin create a wallet for me?', 'Yes. Installing the Paytaca AI opencode plugin automatically created a Paytaca wallet on this device (a Bitcoin Cash wallet). Any BCH or LIFT tokens you send to its address are available to this plugin to spend.'],\n ['How do I fund my wallet?', 'Get your wallet\\'s receiving address (ask \"what\\'s my address?\" or call get_receiving_address), then send BCH or LIFT tokens to it from any Bitcoin Cash wallet or exchange. Funding must happen BEFORE you can buy a plan. Check your balance anytime with get_balance.'],\n ['How do I buy a plan?', 'Three steps: (1) make sure your wallet has funds \u2014 get_receiving_address to deposit, get_balance to confirm; (2) see pricing with get_plans; (3) ask to buy, e.g. \"Buy a DeepSeek V4 Flash plan for 30 minutes.\" opencode will ask you to approve the payment.'],\n ['Can I pay with LIFT tokens?', 'Yes. Plans can be paid in BCH or LIFT tokens. To pay with tokens, add \"pay with LIFT\" to your buy request, e.g. \"Buy a 15-minute GLM plan and pay with LIFT.\" Paying with LIFT applies a discount \u2014 see the current rate below (the backend sets it, so it can change anytime).'],\n ['Why can\\'t I buy again while I still have credits?', 'Plans are time blocks, not balances that stack. While a model still has active time, buying another plan for it would charge nothing extra. Use up or wait out the remaining credits, then buy again. Check remaining time with get_credits.'],\n ['What is Bitcoin Cash (BCH)?', 'Bitcoin Cash is peer-to-peer electronic cash. Transactions are confirmed in seconds to minutes with extremely low fees (fractions of a cent), which makes it practical for small, everyday payments like buying a plan. It is the currency Paytaca AI payments run on.'],\n ['What are CashTokens and LIFT?', 'CashTokens are fungible and non-fungible tokens issued on the Bitcoin Cash blockchain. LIFT is a Paytaca CashToken you can use to pay for AI plans, and it can be bought/sold for BCH on the Cauldron DEX.'],\n ['What is Paytaca?', 'Paytaca is a Bitcoin Cash wallet and payments ecosystem, and Paytaca AI is its AI inference service. The Paytaca wallet app holds your BCH and CashTokens, and Paytaca AI lets you spend them on AI usage.'],\n].map(([q, a]) => '- **' + q + '**\\n ' + a).join('\\n');\n\n// Appended to Paytaca AI info tool outputs (plans, credits, models): asks the\n// user what they want to do next and steers them toward buying a plan, with\n// concrete example prompts they can reuse to place the order.\nasync function nextSteps() {\n const percent = await getLiftDiscountPercent();\n const liftLine = percent > 0\n ? '- Pay with LIFT tokens instead of BCH: just add \"pay with LIFT\" to your request, e.g. \"Buy a 30-minute GLM plan and pay with LIFT.\" \u2014 you get **' + percent + '% off**.'\n : '- Pay with LIFT tokens instead of BCH: just add \"pay with LIFT\" to your request, e.g. \"Buy a 30-minute GLM plan and pay with LIFT.\"';\n return [\n '',\n 'What would you like to do next?',\n '- Buy a plan: pick a model and duration from the list above, then say something like: \"Buy a DeepSeek V4 Flash plan for 15 minutes.\"',\n liftLine,\n '- Check your credits: \"How much time do I have left?\"',\n '- See prices for another model: \"Show me the plans for <model>.\"',\n ].join('\\n');\n}\n\n// Remaining time credits per model session\nasync function getCredits() {\n const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(data.sessions) ? data.sessions : [];\n const active = sessions.filter((s) => s.time_remaining_seconds > 0 && s.model_active);\n const inactive = sessions.filter((s) => s.time_remaining_seconds > 0 && !s.model_active);\n const parts = [];\n if (active.length > 0) {\n parts.push('Active time credits:');\n for (const s of active) {\n const total = formatDuration(s.time_credits_seconds);\n const remaining = formatDuration(s.time_remaining_seconds);\n const used = formatDuration(s.time_used_seconds);\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + remaining + ' remaining of ' + total + ' (' + used + ' used)');\n }\n }\n if (inactive.length > 0) {\n parts.push('');\n parts.push('Inactive models (credits but session not active):');\n for (const s of inactive) {\n parts.push('- ' + (s.display_name || s.ai_model) + ': ' + formatDuration(s.time_remaining_seconds) + ' remaining');\n }\n }\n if (parts.length === 0) {\n return 'No active time credits.' + await nextSteps();\n }\n return parts.join('\\n') + await nextSteps();\n}\n\n// Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.\nasync function getHelp() {\n let liftLine = '';\n const percent = await getLiftDiscountPercent();\n if (percent > 0) {\n liftLine = '\\n\\n\uD83D\uDCA1 **Current LIFT discount: ' + percent + '%** off plans paid with LIFT tokens.';\n }\n return 'Paytaca AI and your wallet \u2014 frequently asked questions:\\n\\n' + FAQ + liftLine + await nextSteps();\n}\n\n// Wallet BCH balance via the paytaca CLI\nasync function getBalance() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info']);\n const match = out.match(/Balance:\\s*([\\d.]+)\\s*BCH/i);\n if (match) {\n return 'Wallet balance: ' + match[1] + ' BCH.';\n }\n return 'Could not parse balance. Raw output:\\n' + out.split('\\n').slice(0, 5).join('\\n');\n}\n\n// List all available models\nasync function getModels() {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const lines = ['Available models:'];\n for (const m of models) {\n let line = m.id || 'unknown';\n if (m.display_name && m.display_name !== m.id) line += ' (' + m.display_name + ')';\n if (m.tier) line += ' [' + m.tier + ']';\n lines.push('- ' + line);\n }\n return lines.join('\\n') + await nextSteps();\n}\n\n// Plan pricing as a compact matrix: models as rows, durations as columns,\n// with USD and BCH price in each cell. Durations are always 15/30/60 min.\nasync function getPlans(filterModel) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n let models = Array.isArray(data.models) ? data.models : [];\n if (filterModel) {\n const f = String(filterModel).toLowerCase();\n models = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n }\n if (models.length === 0) {\n return 'No models available.' + await nextSteps();\n }\n const durationColumns = [15, 30, 60];\n const lines = ['| Model | 15 min | 30 min | 60 min |', '|---|---|---|---|'];\n for (const m of models) {\n const name = m.display_name || m.id;\n const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';\n const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;\n const label = name + (tier && !tierInName ? ' (' + tier + ')' : '');\n const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];\n const byMinutes = {};\n for (const t of tiers) {\n byMinutes[Number(t.minutes)] = t;\n }\n const cells = durationColumns.map((mins) => {\n const t = byMinutes[mins];\n if (!t) return '-';\n const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;\n const bch = (sats / 100000000).toFixed(8);\n const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';\n return usd + ' / ' + bch + ' BCH';\n });\n lines.push('| ' + label + ' | ' + cells.join(' | ') + ' |');\n }\n return lines.join('\\n') + await nextSteps();\n}\n\n// Resolve a model (by id or display name) and its price tier by minutes.\n// Uses the same /v1/config data as get_plans.\nasync function resolvePlan(modelFilter, minutes) {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n const models = Array.isArray(data.models) ? data.models : [];\n const f = String(modelFilter || '').toLowerCase();\n const matches = models.filter((m) => {\n const id = String(m.id || '').toLowerCase();\n const name = String(m.display_name || '').toLowerCase();\n return id.indexOf(f) !== -1 || name.indexOf(f) !== -1;\n });\n if (matches.length === 0) {\n const ids = models.map((m) => m.id).join(', ');\n throw new Error('Model not found: ' + modelFilter + '. Available models: ' + ids);\n }\n const model = matches[0];\n const tiers = Array.isArray(model.price_tiers) ? model.price_tiers : [];\n const want = Number(minutes);\n const tier = tiers.find((t) => Number(t.minutes) === want);\n if (!tier) {\n const avail = tiers.map((t) => t.minutes).join(', ');\n throw new Error('No ' + minutes + '-minute plan for ' + (model.display_name || model.id) + '. Available minutes: ' + avail);\n }\n return { model, tier };\n}\n\n// Wallet balance in sats (mirrors the proxy's pre-payment check)\nasync function getBalanceSats() {\n const out = await runCommand(PAYTACA_CMD, ['wallet', 'info'], 20000);\n const match = out.match(/Balance:s*([d.]+)s*BCH/i);\n if (!match) return null;\n return Math.floor(parseFloat(match[1]) * 100000000);\n}\n\n// LIFT token balance in base units (2 decimals), null when the CLI output\n// cannot be parsed.\nasync function getLiftBalanceUnits() {\n const out = await runCommand(PAYTACA_CMD, ['token', 'info', LIFT_TOKEN_ID], 20000);\n const match = out.match(/Balance:s*([d.]+)s*LIFT/i);\n if (!match) return null;\n return BigInt(Math.round(parseFloat(match[1]) * 100));\n}\n\n// LIFT payment discount percent advertised by the backend (/v1/config), cached\n// for ~5 minutes. Returns 0 when unset/unavailable so callers can fall back to\n// no-discount messaging.\nlet liftDiscountCache = { at: 0, percent: 0 };\nasync function getLiftDiscountPercent() {\n const now = Date.now();\n if (liftDiscountCache.at && now - liftDiscountCache.at < 300000) {\n return liftDiscountCache.percent;\n }\n let percent = 0;\n try {\n const data = await getJson(BACKEND_URL + '/v1/config', {});\n percent = Number(data.lift_payment_discount_percent) || 0;\n } catch (e) {\n log('Failed to fetch LIFT discount config: ' + e.message);\n }\n liftDiscountCache = { at: now, percent };\n return percent;\n}\n\n// Format a satoshi amount as a BCH string with up to 8 decimals.\nfunction formatBch(sats) {\n return (Number(sats) / 100000000).toFixed(8);\n}\n\n// Purchase time credits for a specific model and plan duration. Reuses the\n// same payment wrapper the proxy runs on a 402 (x402 payment + retry with the\n// PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks \u2014\n// not just the model active in the current session. Spends real BCH.\nasync function buyPlan(args) {\n const modelFilter = String(args.model || '').trim();\n const minutes = Number(args.minutes);\n const paymentMethod = args.payment_method === 'lift' ? 'lift' : 'bch';\n if (!modelFilter) {\n throw new Error('Missing model. Pass the model id or display name (e.g. deepseek/deepseek-v4-flash).');\n }\n if (isNaN(minutes) || minutes <= 0) {\n throw new Error('Missing or invalid minutes. Pass the plan duration, e.g. 30 for the 30-minute plan.');\n }\n\n const { model, tier } = await resolvePlan(modelFilter, minutes);\n const priceSats = Number(tier.price_sats) || 0;\n\n // Explicit restriction: a plan cannot be purchased while the model still has\n // active credits. The backend serves requests without a 402 once credits are\n // active, so buying again would silently charge nothing and the user would\n // think they stacked time. Block the purchase and tell them to use up the\n // remaining credits (or wait for them to expire) before buying again.\n let status;\n try {\n status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n } catch (e) {\n throw new Error('Could not check existing credits before purchase: ' + e.message + '. Try again, or check credits with get_credits.');\n }\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const existing = sessions.find((s) => s.ai_model === model.id && s.time_remaining_seconds > 0);\n if (existing) {\n const remaining = formatDuration(existing.time_remaining_seconds);\n throw new Error((model.display_name || model.id) + ' still has ' + remaining + ' of active credits. A new plan cannot be purchased until the remaining credits are used up or expire. Check credits with get_credits and buy again once they run out.');\n }\n\n // Fail fast on insufficient balance instead of sending a txn that cannot\n // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH \u2014 the\n // LIFT path funds the plan by selling tokens, so no BCH balance is required.\n if (paymentMethod === 'bch') {\n const balanceSats = await getBalanceSats();\n if (balanceSats !== null && balanceSats < priceSats) {\n const addr = await getReceivingAddress({});\n const shortfall = (priceSats - balanceSats) / 100000000;\n throw new Error('Insufficient balance: ' + (balanceSats / 100000000).toFixed(8) + ' BCH available but the ' + minutes + '-minute plan costs ' + (priceSats / 100000000).toFixed(8) + ' BCH. Top up at least ' + shortfall.toFixed(8) + ' BCH to: ' + addr);\n }\n } else {\n // LIFT path: fail fast if the wallet holds no LIFT, instead of prompting\n // for approval and then failing inside the wrapper.\n const liftBalanceUnits = await getLiftBalanceUnits();\n if (liftBalanceUnits !== null && liftBalanceUnits <= 0n) {\n throw new Error('No LIFT tokens in the wallet. Add LIFT to pay this plan with tokens, or call again with payment_method \"bch\".');\n }\n }\n\n const wrapper = path.join(CONFIG_DIR, 'paytaca-pay-wrapper.mjs');\n if (!fs.existsSync(wrapper)) {\n throw new Error('Payment wrapper not found at ' + wrapper + '. Restart opencode so the plugin writes it.');\n }\n\n // Minimal chat request for the target model; the payment flow only needs a\n // valid request that triggers the x402 PaymentRequired for that model.\n const body = JSON.stringify({\n model: model.id,\n messages: [{ role: 'user', content: 'Purchase plan' }],\n stream: false,\n });\n\n const url = BACKEND_URL + '/v1/chat/completions?wallet_hash=' + encodeURIComponent(WALLET_HASH || '');\n const extraHeaders = {\n 'X-Model-Id': model.id,\n 'X-Duration-Minutes': String(tier.minutes),\n };\n if (paymentMethod === 'lift') {\n extraHeaders['X-Payment-Method'] = 'lift';\n }\n\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));\n const bodyFile = path.join(tmpDir, 'body.json');\n const configFile = path.join(tmpDir, 'config.json');\n try {\n fs.writeFileSync(bodyFile, body, 'utf8');\n const config = {\n url: url,\n method: 'POST',\n headers: Object.assign({ 'Content-Type': 'application/json' }, extraHeaders),\n bodyFile: bodyFile,\n confirmed: true,\n };\n if (paymentMethod === 'lift') {\n config.paymentMethod = 'lift';\n }\n fs.writeFileSync(configFile, JSON.stringify(config), 'utf8');\n } catch (e) {\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n throw new Error('Failed to write payment files: ' + e.message);\n }\n\n log('Buy plan requested: ' + model.id + ' ' + tier.minutes + ' min (' + paymentMethod + ')');\n let stdout;\n try {\n stdout = await runCommand('node', [wrapper, configFile], 250000);\n } finally {\n try { fs.unlinkSync(bodyFile); } catch (e2) {}\n try { fs.unlinkSync(configFile); } catch (e2) {}\n try { fs.rmdirSync(tmpDir); } catch (e2) {}\n }\n\n let result;\n try {\n result = JSON.parse(stdout);\n } catch (e) {\n throw new Error('Could not parse payment result: ' + stdout.substring(0, 200));\n }\n\n if (result.timeout) {\n return 'Payment was processed but the response timed out. Check your credits with get_credits.';\n }\n if (!result.success) {\n throw new Error(result.error || 'Payment failed (status ' + result.status + ').');\n }\n\n const paid = result.payment && result.payment.required;\n if (!paid) {\n // No payment was required \u2014 the model already had active credits, so the\n // server served the request for free. Report this instead of claiming a\n // purchase so the user knows nothing was charged.\n return 'No purchase made: ' + (model.display_name || model.id) + ' already has active credits, so the request was served without payment. Check your credits with get_credits.';\n }\n\n const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];\n if (paymentMethod === 'lift') {\n const discountPercent = await getLiftDiscountPercent();\n if (discountPercent > 0) {\n const discountSats = Math.round(priceSats * (discountPercent / 100));\n lines.push('**LIFT discount applied: ' + discountPercent + '% \u2014 you saved ' + formatBch(discountSats) + ' BCH.**');\n } else {\n lines.push('Paid with LIFT tokens.');\n }\n }\n if (result.payment && result.payment.txid) {\n lines.push('Transaction: ' + result.payment.txid);\n }\n try {\n const status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });\n const sessions = Array.isArray(status.sessions) ? status.sessions : [];\n const found = sessions.find((s) => s.ai_model === model.id);\n if (found && found.time_remaining_seconds > 0) {\n lines.push('Credits: ' + formatDuration(found.time_remaining_seconds) + ' remaining.');\n }\n } catch (e) {}\n return lines.join('\\n');\n}\n\n// Recent wallet transactions via the paytaca CLI\nasync function getTransactions(args) {\n const cmdArgs = ['history'];\n if (args.type === 'incoming' || args.type === 'outgoing') {\n cmdArgs.push('--type', args.type);\n }\n const page = parseInt(args.page, 10);\n if (!isNaN(page) && page > 0) {\n cmdArgs.push('--page', String(page));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out || 'No transactions found.';\n}\n\n// Receiving address via the paytaca CLI (QR art suppressed)\nasync function getReceivingAddress(args) {\n const cmdArgs = ['receive', '--no-qr'];\n const amount = parseFloat(args.amount);\n if (!isNaN(amount) && amount > 0) {\n cmdArgs.push('--amount', String(amount));\n }\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || 'Could not get receiving address.';\n}\n\n// CashToken holdings via the paytaca CLI (all tokens, or one category)\nasync function getTokens(args) {\n const category = args.category ? String(args.category).trim() : '';\n const cmdArgs = category ? ['token', 'info', category] : ['token', 'list'];\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 30000);\n return out.trim() || (category ? 'Token not found: ' + category : 'No tokens found.');\n}\n\n// Send BCH or CashTokens. This spends real funds \u2014 opencode requires manual\n// user approval for this tool (permission 'paytaca_send' set to 'ask' by the\n// plugin's config hook), so it must never be called without the user asking\n// for the send.\nasync function sendFunds(args) {\n const address = String(args.address || '').trim();\n const amount = String(args.amount || '').trim();\n const unit = args.unit === 'sats' ? 'sats' : 'bch';\n const tokenCategory = args.token_category ? String(args.token_category).trim() : '';\n if (!address) {\n throw new Error('Missing recipient address.');\n }\n if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {\n throw new Error('Missing or invalid amount.');\n }\n const cmdArgs = tokenCategory\n ? ['token', 'send', address, amount, '--token', tokenCategory]\n : ['send', address, amount];\n if (!tokenCategory && unit === 'sats') {\n cmdArgs.push('--unit', 'sats');\n }\n log('Send requested: ' + cmdArgs.join(' '));\n const out = await runCommand(PAYTACA_CMD, cmdArgs, 90000);\n log('Send completed');\n return 'Transaction sent.\\n\\n' + out;\n}\n\n// Tool schemas (concise descriptions so MCP tool context stays small)\nconst TOOLS = [\n {\n name: 'get_help',\n description: 'Get a Q&A guide about the Paytaca wallet, funding it, buying plans, paying with BCH or LIFT, what makes Paytaca AI unique, Bitcoin Cash, CashTokens/LIFT, and Paytaca itself. Use when the user asks how the wallet or buying works, how to fund or top up, what Paytaca AI is, what makes it different, or anything about Bitcoin Cash, CashTokens, or LIFT.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_credits',\n description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_balance',\n description: 'Get the BCH balance of the user\\'s Paytaca wallet. Use when the user asks about wallet balance or funds.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_models',\n description: 'List the AI models available on Paytaca AI (id, display name, tier). Use when the user asks which models are available.',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_plans',\n description: 'Get Paytaca AI plan pricing: time tiers in minutes with USD and BCH prices, grouped by tier. Optionally pass a model id/name to filter one model. Use when the user asks about plans, pricing, costs, or how much a model costs.',\n inputSchema: {\n type: 'object',\n properties: {\n model: { type: 'string', description: 'Optional model id or display name to filter pricing to a single model.' },\n },\n },\n },\n {\n name: 'buy_plan',\n description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) \u2014 only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. IMPORTANT RESTRICTION: a plan CANNOT be purchased while the model still has active credits \u2014 the backend serves requests without payment once credits are active, so buying again charges nothing and does not stack time. Before calling, check the model\\'s credits with get_credits; if the model still has time remaining, do NOT buy \u2014 tell the user to use up or wait out the remaining credits first. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT \u2014 LIFT phrasing: when the user says \"pay with LIFT\", \"pay with LIFT tokens\", \"use LIFT\", or mentions paying a plan with their LIFT token balance, set payment_method to \"lift\". LIFT is the Paytaca token users hold to pay for AI plans; \"pay with LIFT\" is NOT asking to buy a plan called LIFT. Default to \"bch\" unless the user explicitly mentions LIFT/tokens. Paying with LIFT gets a discount (rate set server-side; see get_help or get_plans for the current percent).',\n inputSchema: {\n type: 'object',\n required: ['model', 'minutes'],\n properties: {\n model: { type: 'string', description: 'Model id or display name, e.g. deepseek/deepseek-v4-flash or DeepSeek V4 Flash.' },\n minutes: { type: 'number', description: 'Plan duration in minutes, e.g. 30 for the 30-minute tier.' },\n payment_method: { type: 'string', enum: ['bch', 'lift'], description: 'Payment method. bch (default) pays from the BCH balance. Set to lift when the user says \"pay with LIFT\" or \"pay with LIFT tokens\" \u2014 this sells the wallet LIFT token balance via Cauldron to fund the plan. Use lift when the wallet lacks BCH but holds LIFT, or when the user explicitly asks to pay with LIFT.' },\n },\n },\n },\n {\n name: 'get_transactions',\n description: 'Get recent Paytaca wallet transactions (sent/received BCH). Use when the user asks about transaction history or latest transactions.',\n inputSchema: {\n type: 'object',\n properties: {\n type: { type: 'string', enum: ['incoming', 'outgoing'], description: 'Optional direction filter.' },\n page: { type: 'number', description: 'Optional 1-based page number for older history.' },\n },\n },\n },\n {\n name: 'get_receiving_address',\n description: 'Get a Paytaca wallet receiving address for depositing BCH, optionally as a BIP21 URI with an amount. Use when the user wants to fund the wallet or needs their address.',\n inputSchema: {\n type: 'object',\n properties: {\n amount: { type: 'number', description: 'Optional BCH amount to embed in a BIP21 payment URI.' },\n },\n },\n },\n {\n name: 'get_tokens',\n description: 'List CashToken holdings of the Paytaca wallet, or get details (name, symbol, balance, NFTs) for one token category. Use when the user asks about tokens or NFTs.',\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', description: 'Optional token category id for details of a single token.' },\n },\n },\n },\n {\n name: 'send',\n description: 'Send BCH or CashTokens from the Paytaca wallet to an address. SPENDS REAL FUNDS \u2014 only call when the user explicitly asks to send; opencode will prompt the user for approval and that prompt must never be bypassed. Token amounts are in base units; recipients of tokens should use token-aware (z-prefix) addresses.',\n inputSchema: {\n type: 'object',\n required: ['address', 'amount'],\n properties: {\n address: { type: 'string', description: 'Recipient CashAddr (e.g. bitcoincash:qp...).' },\n amount: { type: 'string', description: 'Amount to send.' },\n unit: { type: 'string', enum: ['bch', 'sats'], description: 'Amount unit, default bch. Ignored for token sends.' },\n token_category: { type: 'string', description: 'Token category id to send CashTokens instead of BCH.' },\n },\n },\n },\n];\n\n// JSON-RPC over stdio (newline-delimited)\nlet buffer = '';\nfunction handleMessage(msg) {\n if (msg.method === 'initialize') {\n const requestedVersion = msg.params && msg.params.protocolVersion;\n send(msg.id, {\n protocolVersion: requestedVersion || PROTOCOL_VERSION,\n capabilities: { tools: {} },\n serverInfo: { name: 'paytaca', version: '1.2.0' },\n });\n return;\n }\n if (msg.method === 'notifications/initialized') {\n return;\n }\n if (msg.method === 'ping') {\n send(msg.id, {});\n return;\n }\n if (msg.method === 'tools/list') {\n send(msg.id, { tools: TOOLS });\n return;\n }\n if (msg.method === 'tools/call') {\n const name = msg.params && msg.params.name;\n const args = (msg.params && msg.params.arguments) || {};\n refreshConfig();\n (async () => {\n let text;\n try {\n switch (name) {\n case 'get_help': text = await getHelp(); break;\n case 'get_credits': text = await getCredits(); break;\n case 'get_balance': text = await getBalance(); break;\n case 'get_models': text = await getModels(); break;\n case 'get_plans': text = await getPlans(args.model); break;\n case 'buy_plan': text = await buyPlan(args); break;\n case 'get_transactions': text = await getTransactions(args); break;\n case 'get_receiving_address': text = await getReceivingAddress(args); break;\n case 'get_tokens': text = await getTokens(args); break;\n case 'send': text = await sendFunds(args); break;\n default: throw new Error('Unknown tool: ' + name);\n }\n send(msg.id, { content: [{ type: 'text', text: text }] });\n } catch (e) {\n log('Tool ' + name + ' failed: ' + e.message);\n send(msg.id, { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true });\n }\n })();\n return;\n }\n // Unknown request \u2014 respond with an empty result so the client never hangs\n if (typeof msg.id !== 'undefined' && msg.id !== null) {\n send(msg.id, {});\n }\n}\n\nfunction send(id, result) {\n const payload = { jsonrpc: '2.0', id, result };\n process.stdout.write(JSON.stringify(payload) + '\\n');\n}\n\nprocess.stdin.on('data', (chunk) => {\n buffer += chunk.toString();\n let idx;\n while ((idx = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.substring(0, idx).trim();\n buffer = buffer.substring(idx + 1);\n if (!line) continue;\n let msg;\n try {\n msg = JSON.parse(line);\n } catch (e) {\n continue;\n }\n try {\n handleMessage(msg);\n } catch (e) {\n log('handleMessage error: ' + e.message);\n }\n }\n});\n\nrefreshConfig();\nlog('MCP server started (backend=' + BACKEND_URL + ')');\n";
2
2
  //# sourceMappingURL=mcp.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,25yBAgnB9B,CAAC"}
1
+ {"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,qhkCAguB9B,CAAC"}
@@ -142,6 +142,40 @@ function formatDuration(totalSeconds) {
142
142
  return minutes + ':' + String(secs).padStart(2, '0');
143
143
  }
144
144
 
145
+ // Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.
146
+ // The assistant fetches this when the user asks "how do I...", "what is...",
147
+ // or anything about funding, paying, or how the service works.
148
+ const FAQ = [
149
+ ['What is Paytaca AI?', 'Paytaca AI is a service that gives you affordable, convenient access to AI inference through your Paytaca wallet. Instead of per-token metering or a monthly subscription, you buy short time-based plans in advance — 15, 30, or 60 minutes of a model — and use them up as you chat.'],
150
+ ['What makes Paytaca AI different from other AI services?', 'We keep AI access affordable and simple by billing in pre-purchased blocks of time rather than per token. Pick a model, buy a 15/30/60-minute plan with your wallet, and the time is yours to use. No surprises per message, no recurring billing.'],
151
+ ['Did this plugin create a wallet for me?', 'Yes. Installing the Paytaca AI opencode plugin automatically created a Paytaca wallet on this device (a Bitcoin Cash wallet). Any BCH or LIFT tokens you send to its address are available to this plugin to spend.'],
152
+ ['How do I fund my wallet?', 'Get your wallet\\'s receiving address (ask "what\\'s my address?" or call get_receiving_address), then send BCH or LIFT tokens to it from any Bitcoin Cash wallet or exchange. Funding must happen BEFORE you can buy a plan. Check your balance anytime with get_balance.'],
153
+ ['How do I buy a plan?', 'Three steps: (1) make sure your wallet has funds — get_receiving_address to deposit, get_balance to confirm; (2) see pricing with get_plans; (3) ask to buy, e.g. "Buy a DeepSeek V4 Flash plan for 30 minutes." opencode will ask you to approve the payment.'],
154
+ ['Can I pay with LIFT tokens?', 'Yes. Plans can be paid in BCH or LIFT tokens. To pay with tokens, add "pay with LIFT" to your buy request, e.g. "Buy a 15-minute GLM plan and pay with LIFT." Paying with LIFT applies a discount — see the current rate below (the backend sets it, so it can change anytime).'],
155
+ ['Why can\\'t I buy again while I still have credits?', 'Plans are time blocks, not balances that stack. While a model still has active time, buying another plan for it would charge nothing extra. Use up or wait out the remaining credits, then buy again. Check remaining time with get_credits.'],
156
+ ['What is Bitcoin Cash (BCH)?', 'Bitcoin Cash is peer-to-peer electronic cash. Transactions are confirmed in seconds to minutes with extremely low fees (fractions of a cent), which makes it practical for small, everyday payments like buying a plan. It is the currency Paytaca AI payments run on.'],
157
+ ['What are CashTokens and LIFT?', 'CashTokens are fungible and non-fungible tokens issued on the Bitcoin Cash blockchain. LIFT is a Paytaca CashToken you can use to pay for AI plans, and it can be bought/sold for BCH on the Cauldron DEX.'],
158
+ ['What is Paytaca?', 'Paytaca is a Bitcoin Cash wallet and payments ecosystem, and Paytaca AI is its AI inference service. The Paytaca wallet app holds your BCH and CashTokens, and Paytaca AI lets you spend them on AI usage.'],
159
+ ].map(([q, a]) => '- **' + q + '**\\n ' + a).join('\\n');
160
+
161
+ // Appended to Paytaca AI info tool outputs (plans, credits, models): asks the
162
+ // user what they want to do next and steers them toward buying a plan, with
163
+ // concrete example prompts they can reuse to place the order.
164
+ async function nextSteps() {
165
+ const percent = await getLiftDiscountPercent();
166
+ const liftLine = percent > 0
167
+ ? '- Pay with LIFT tokens instead of BCH: just add "pay with LIFT" to your request, e.g. "Buy a 30-minute GLM plan and pay with LIFT." — you get **' + percent + '% off**.'
168
+ : '- Pay with LIFT tokens instead of BCH: just add "pay with LIFT" to your request, e.g. "Buy a 30-minute GLM plan and pay with LIFT."';
169
+ return [
170
+ '',
171
+ 'What would you like to do next?',
172
+ '- Buy a plan: pick a model and duration from the list above, then say something like: "Buy a DeepSeek V4 Flash plan for 15 minutes."',
173
+ liftLine,
174
+ '- Check your credits: "How much time do I have left?"',
175
+ '- See prices for another model: "Show me the plans for <model>."',
176
+ ].join('\\n');
177
+ }
178
+
145
179
  // Remaining time credits per model session
146
180
  async function getCredits() {
147
181
  const data = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });
@@ -166,9 +200,19 @@ async function getCredits() {
166
200
  }
167
201
  }
168
202
  if (parts.length === 0) {
169
- return 'No active time credits.';
203
+ return 'No active time credits.' + await nextSteps();
204
+ }
205
+ return parts.join('\\n') + await nextSteps();
206
+ }
207
+
208
+ // Q&A guide about the wallet, buying plans, Paytaca AI, and Bitcoin Cash.
209
+ async function getHelp() {
210
+ let liftLine = '';
211
+ const percent = await getLiftDiscountPercent();
212
+ if (percent > 0) {
213
+ liftLine = '\\n\\n💡 **Current LIFT discount: ' + percent + '%** off plans paid with LIFT tokens.';
170
214
  }
171
- return parts.join('\\n');
215
+ return 'Paytaca AI and your wallet — frequently asked questions:\\n\\n' + FAQ + liftLine + await nextSteps();
172
216
  }
173
217
 
174
218
  // Wallet BCH balance via the paytaca CLI
@@ -192,10 +236,11 @@ async function getModels() {
192
236
  if (m.tier) line += ' [' + m.tier + ']';
193
237
  lines.push('- ' + line);
194
238
  }
195
- return lines.join('\\n');
239
+ return lines.join('\\n') + await nextSteps();
196
240
  }
197
241
 
198
- // Plan pricing as a markdown table per model, optionally filtered to one model
242
+ // Plan pricing as a compact matrix: models as rows, durations as columns,
243
+ // with USD and BCH price in each cell. Durations are always 15/30/60 min.
199
244
  async function getPlans(filterModel) {
200
245
  const data = await getJson(BACKEND_URL + '/v1/config', {});
201
246
  let models = Array.isArray(data.models) ? data.models : [];
@@ -208,33 +253,31 @@ async function getPlans(filterModel) {
208
253
  });
209
254
  }
210
255
  if (models.length === 0) {
211
- return 'No models available.';
256
+ return 'No models available.' + await nextSteps();
212
257
  }
213
- const lines = [];
258
+ const durationColumns = [15, 30, 60];
259
+ const lines = ['| Model | 15 min | 30 min | 60 min |', '|---|---|---|---|'];
214
260
  for (const m of models) {
215
261
  const name = m.display_name || m.id;
216
262
  const tier = m.tier ? String(m.tier).charAt(0).toUpperCase() + String(m.tier).slice(1) : '';
217
263
  const tierInName = tier && name.toLowerCase().indexOf(tier.toLowerCase()) !== -1;
218
- if (lines.length > 0) lines.push('');
219
- lines.push('**' + name + (tier && !tierInName ? ' (' + tier + ')' : '') + '**');
220
- lines.push('');
264
+ const label = name + (tier && !tierInName ? ' (' + tier + ')' : '');
221
265
  const tiers = Array.isArray(m.price_tiers) ? m.price_tiers : [];
222
- if (tiers.length === 0) {
223
- lines.push('No pricing configured.');
224
- continue;
266
+ const byMinutes = {};
267
+ for (const t of tiers) {
268
+ byMinutes[Number(t.minutes)] = t;
225
269
  }
226
- lines.push('| Duration | USD | BCH | Sats |');
227
- lines.push('|---|---|---|---|');
228
- const sorted = tiers.slice().sort((a, b) => (a.minutes || 0) - (b.minutes || 0));
229
- for (const t of sorted) {
270
+ const cells = durationColumns.map((mins) => {
271
+ const t = byMinutes[mins];
272
+ if (!t) return '-';
230
273
  const sats = typeof t.price_sats === 'number' ? t.price_sats : 0;
231
274
  const bch = (sats / 100000000).toFixed(8);
232
275
  const usd = typeof t.price_usd === 'number' ? '$' + t.price_usd.toFixed(2) : '?.??';
233
- const satsStr = Number(sats).toLocaleString('en-US');
234
- lines.push('| ' + (t.minutes || 0) + ' min | ' + usd + ' | ' + bch + ' | ' + satsStr + ' |');
235
- }
276
+ return usd + ' / ' + bch + ' BCH';
277
+ });
278
+ lines.push('| ' + label + ' | ' + cells.join(' | ') + ' |');
236
279
  }
237
- return lines.join('\\n');
280
+ return lines.join('\\n') + await nextSteps();
238
281
  }
239
282
 
240
283
  // Resolve a model (by id or display name) and its price tier by minutes.
@@ -280,6 +323,31 @@ async function getLiftBalanceUnits() {
280
323
  return BigInt(Math.round(parseFloat(match[1]) * 100));
281
324
  }
282
325
 
326
+ // LIFT payment discount percent advertised by the backend (/v1/config), cached
327
+ // for ~5 minutes. Returns 0 when unset/unavailable so callers can fall back to
328
+ // no-discount messaging.
329
+ let liftDiscountCache = { at: 0, percent: 0 };
330
+ async function getLiftDiscountPercent() {
331
+ const now = Date.now();
332
+ if (liftDiscountCache.at && now - liftDiscountCache.at < 300000) {
333
+ return liftDiscountCache.percent;
334
+ }
335
+ let percent = 0;
336
+ try {
337
+ const data = await getJson(BACKEND_URL + '/v1/config', {});
338
+ percent = Number(data.lift_payment_discount_percent) || 0;
339
+ } catch (e) {
340
+ log('Failed to fetch LIFT discount config: ' + e.message);
341
+ }
342
+ liftDiscountCache = { at: now, percent };
343
+ return percent;
344
+ }
345
+
346
+ // Format a satoshi amount as a BCH string with up to 8 decimals.
347
+ function formatBch(sats) {
348
+ return (Number(sats) / 100000000).toFixed(8);
349
+ }
350
+
283
351
  // Purchase time credits for a specific model and plan duration. Reuses the
284
352
  // same payment wrapper the proxy runs on a 402 (x402 payment + retry with the
285
353
  // PAYMENT-SIGNATURE header), but works for ANY model/tier the user picks —
@@ -298,6 +366,24 @@ async function buyPlan(args) {
298
366
  const { model, tier } = await resolvePlan(modelFilter, minutes);
299
367
  const priceSats = Number(tier.price_sats) || 0;
300
368
 
369
+ // Explicit restriction: a plan cannot be purchased while the model still has
370
+ // active credits. The backend serves requests without a 402 once credits are
371
+ // active, so buying again would silently charge nothing and the user would
372
+ // think they stacked time. Block the purchase and tell them to use up the
373
+ // remaining credits (or wait for them to expire) before buying again.
374
+ let status;
375
+ try {
376
+ status = await getJson(BACKEND_URL + '/v1/wallet/status', { 'X-Wallet-Hash': WALLET_HASH });
377
+ } catch (e) {
378
+ throw new Error('Could not check existing credits before purchase: ' + e.message + '. Try again, or check credits with get_credits.');
379
+ }
380
+ const sessions = Array.isArray(status.sessions) ? status.sessions : [];
381
+ const existing = sessions.find((s) => s.ai_model === model.id && s.time_remaining_seconds > 0);
382
+ if (existing) {
383
+ const remaining = formatDuration(existing.time_remaining_seconds);
384
+ throw new Error((model.display_name || model.id) + ' still has ' + remaining + ' of active credits. A new plan cannot be purchased until the remaining credits are used up or expire. Check credits with get_credits and buy again once they run out.');
385
+ }
386
+
301
387
  // Fail fast on insufficient balance instead of sending a txn that cannot
302
388
  // fund the plan (mirrors the proxy's 402 flow). Only applies to BCH — the
303
389
  // LIFT path funds the plan by selling tokens, so no BCH balance is required.
@@ -335,6 +421,9 @@ async function buyPlan(args) {
335
421
  'X-Model-Id': model.id,
336
422
  'X-Duration-Minutes': String(tier.minutes),
337
423
  };
424
+ if (paymentMethod === 'lift') {
425
+ extraHeaders['X-Payment-Method'] = 'lift';
426
+ }
338
427
 
339
428
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paytaca-buy-plan-'));
340
429
  const bodyFile = path.join(tmpDir, 'body.json');
@@ -381,7 +470,24 @@ async function buyPlan(args) {
381
470
  throw new Error(result.error || 'Payment failed (status ' + result.status + ').');
382
471
  }
383
472
 
473
+ const paid = result.payment && result.payment.required;
474
+ if (!paid) {
475
+ // No payment was required — the model already had active credits, so the
476
+ // server served the request for free. Report this instead of claiming a
477
+ // purchase so the user knows nothing was charged.
478
+ return 'No purchase made: ' + (model.display_name || model.id) + ' already has active credits, so the request was served without payment. Check your credits with get_credits.';
479
+ }
480
+
384
481
  const lines = ['Plan purchased for ' + (model.display_name || model.id) + ': ' + tier.minutes + ' minutes' + (paymentMethod === 'lift' ? ' (paid with LIFT).' : '.')];
482
+ if (paymentMethod === 'lift') {
483
+ const discountPercent = await getLiftDiscountPercent();
484
+ if (discountPercent > 0) {
485
+ const discountSats = Math.round(priceSats * (discountPercent / 100));
486
+ lines.push('**LIFT discount applied: ' + discountPercent + '% — you saved ' + formatBch(discountSats) + ' BCH.**');
487
+ } else {
488
+ lines.push('Paid with LIFT tokens.');
489
+ }
490
+ }
385
491
  if (result.payment && result.payment.txid) {
386
492
  lines.push('Transaction: ' + result.payment.txid);
387
493
  }
@@ -458,6 +564,11 @@ async function sendFunds(args) {
458
564
 
459
565
  // Tool schemas (concise descriptions so MCP tool context stays small)
460
566
  const TOOLS = [
567
+ {
568
+ name: 'get_help',
569
+ description: 'Get a Q&A guide about the Paytaca wallet, funding it, buying plans, paying with BCH or LIFT, what makes Paytaca AI unique, Bitcoin Cash, CashTokens/LIFT, and Paytaca itself. Use when the user asks how the wallet or buying works, how to fund or top up, what Paytaca AI is, what makes it different, or anything about Bitcoin Cash, CashTokens, or LIFT.',
570
+ inputSchema: { type: 'object', properties: {} },
571
+ },
461
572
  {
462
573
  name: 'get_credits',
463
574
  description: 'Get remaining Paytaca AI time credits (active model sessions, time left). Use when the user asks about credits, remaining time, session status, or how much usage they have left.',
@@ -485,7 +596,7 @@ const TOOLS = [
485
596
  },
486
597
  {
487
598
  name: 'buy_plan',
488
- description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT — LIFT phrasing: when the user says "pay with LIFT", "pay with LIFT tokens", "use LIFT", or mentions paying a plan with their LIFT token balance, set payment_method to "lift". LIFT is the Paytaca token users hold to pay for AI plans; "pay with LIFT" is NOT asking to buy a plan called LIFT. Default to "bch" unless the user explicitly mentions LIFT/tokens.',
599
+ description: 'Purchase Paytaca AI time credits for a specific model and plan duration. SPENDS FUNDS FROM THE WALLET (BCH, or LIFT tokens sold via Cauldron when payment_method=lift) — only call when the user explicitly asks to buy, purchase, or pay for a plan; opencode prompts the user for approval. IMPORTANT RESTRICTION: a plan CANNOT be purchased while the model still has active credits — the backend serves requests without payment once credits are active, so buying again charges nothing and does not stack time. Before calling, check the model\\'s credits with get_credits; if the model still has time remaining, do NOT buy — tell the user to use up or wait out the remaining credits first. Show pricing with get_plans first, then call with the model and minutes the user picked. Works for any model, even one not active in the current session. IMPORTANT — LIFT phrasing: when the user says "pay with LIFT", "pay with LIFT tokens", "use LIFT", or mentions paying a plan with their LIFT token balance, set payment_method to "lift". LIFT is the Paytaca token users hold to pay for AI plans; "pay with LIFT" is NOT asking to buy a plan called LIFT. Default to "bch" unless the user explicitly mentions LIFT/tokens. Paying with LIFT gets a discount (rate set server-side; see get_help or get_plans for the current percent).',
489
600
  inputSchema: {
490
601
  type: 'object',
491
602
  required: ['model', 'minutes'],
@@ -574,6 +685,7 @@ function handleMessage(msg) {
574
685
  let text;
575
686
  try {
576
687
  switch (name) {
688
+ case 'get_help': text = await getHelp(); break;
577
689
  case 'get_credits': text = await getCredits(); break;
578
690
  case 'get_balance': text = await getBalance(); break;
579
691
  case 'get_models': text = await getModels(); break;
@@ -1 +1 @@
1
- {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kEAAkE;;;AAErD,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgnBjC,CAAC"}
1
+ {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kEAAkE;;;AAErD,QAAA,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAguBjC,CAAC"}