@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.
- package/dist/bundled/mcp.d.ts +1 -1
- package/dist/bundled/mcp.d.ts.map +1 -1
- package/dist/bundled/mcp.js +133 -21
- package/dist/bundled/mcp.js.map +1 -1
- package/dist/bundled/proxy.d.ts +1 -1
- package/dist/bundled/proxy.d.ts.map +1 -1
- package/dist/bundled/proxy.js +151 -38
- package/dist/bundled/proxy.js.map +1 -1
- package/dist/bundled/wrapper.d.ts +1 -1
- package/dist/bundled/wrapper.d.ts.map +1 -1
- package/dist/bundled/wrapper.js +7 -0
- package/dist/bundled/wrapper.js.map +1 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +6 -4
- package/dist/proxy.js.map +1 -1
- package/package.json +1 -1
package/dist/bundled/mcp.d.ts
CHANGED
|
@@ -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,
|
|
1
|
+
{"version":3,"file":"mcp.d.ts","sourceRoot":"","sources":["../../src/bundled/mcp.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,kBAAkB,qhkCAguB9B,CAAC"}
|
package/dist/bundled/mcp.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
266
|
+
const byMinutes = {};
|
|
267
|
+
for (const t of tiers) {
|
|
268
|
+
byMinutes[Number(t.minutes)] = t;
|
|
225
269
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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;
|
package/dist/bundled/mcp.js.map
CHANGED
|
@@ -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
|
|
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"}
|