#!/usr/bin/env node /** * SpendOS MCP Tool Server * * Exposes x402-gated AI compute as MCP tools. * Any MCP-compatible agent (Claude Code, Cursor, GPT) can call these tools. * Each invocation is paid via x402 from the agent's OWS wallet. * * Track 3 #5: "Build MCP tool servers that charge per invocation via x402." * * Usage: * npx tsx src/mcp-server.ts # stdio transport (for Claude Code) * Add to openclaw.json or claude settings as MCP server */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; const SPENDOS_URL = process.env.SPENDOS_URL ?? 'http://localhost:3030'; const server = new Server( { name: 'spendos', version: '0.1.0' }, { capabilities: { tools: {} } }, ); // ── Tool Definitions ─────────────────────────────────── server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'summarize_url', description: 'Summarize a URL into a concise brief. Costs $0.01 via x402. Powered by Venice AI (wallet-authenticated, decentralized inference).', inputSchema: { type: 'object' as const, properties: { url: { type: 'string', description: 'The URL to summarize' }, }, required: ['url'], }, }, { name: 'generate_image', description: 'Generate an image from a text prompt. Costs $0.05 via x402. Powered by Venice AI.', inputSchema: { type: 'object' as const, properties: { prompt: { type: 'string', description: 'Text description of the image to generate' }, }, required: ['prompt'], }, }, { name: 'check_pnl', description: 'Check the SpendOS agent P&L (earnings, spending, profit margin).', inputSchema: { type: 'object' as const, properties: {} }, }, { name: 'request_delegation', description: 'Request a spending delegation from SpendOS governance. Must be approved by the wallet owner before the agent can sign transactions.', inputSchema: { type: 'object' as const, properties: { reason: { type: 'string', description: 'Why the agent needs spending permission' }, chains: { type: 'array', items: { type: 'string' }, description: 'CAIP-2 chain IDs (e.g. eip155:8453)' }, totalBudget: { type: 'string', description: 'Maximum total spend in USD' }, expiresInMinutes: { type: 'number', description: 'How many minutes until the delegation expires' }, }, required: ['reason'], }, }, ], })); // ── Tool Execution ───────────────────────────────────── server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case 'summarize_url': { const res = await fetch(`${SPENDOS_URL}/api/summarize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: args?.url }), }); if (res.status === 402) { return { content: [{ type: 'text', text: 'Payment required: $0.01 via x402. This endpoint is gated by the x402 payment protocol.' }] }; } const data = await res.json() as any; return { content: [{ type: 'text', text: `Summary: ${data.summary}\n\nCost: earned $${data.cost?.earned}, inference $${data.cost?.inference}, profit $${data.cost?.profit}`, }], }; } case 'generate_image': { const res = await fetch(`${SPENDOS_URL}/api/generate-image`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: args?.prompt }), }); if (res.status === 402) { return { content: [{ type: 'text', text: 'Payment required: $0.05 via x402.' }] }; } const data = await res.json() as any; const imageUrl = data.images?.[0]?.url ?? data.images?.[0]?.b64_json ? '[base64 image]' : 'no image'; return { content: [{ type: 'text', text: `Image generated: ${imageUrl}\nCost: earned $0.05, inference $0.01, profit $0.04` }], }; } case 'check_pnl': { const res = await fetch(`${SPENDOS_URL}/api/pnl`); const pnl = await res.json() as any; const margin = pnl.totalEarned > 0 ? ((pnl.profit / pnl.totalEarned) * 100).toFixed(0) : 'n/a'; return { content: [{ type: 'text', text: `Agent P&L:\n Earned: $${pnl.totalEarned.toFixed(3)}\n Spent: $${pnl.totalSpent.toFixed(4)}\n Profit: $${pnl.profit.toFixed(4)}\n Queries: ${pnl.queryCount}\n Margin: ${margin}%`, }], }; } case 'request_delegation': { const expiresAt = new Date(Date.now() + (Number(args?.expiresInMinutes) || 30) * 60000).toISOString(); const res = await fetch(`${SPENDOS_URL}/api/delegate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agentAddress: '0x0000000000000000000000000000000000000000', // MCP agent reason: args?.reason ?? 'MCP tool request', chains: args?.chains ?? ['eip155:8453'], operations: ['sign_tx'], maxAmountPerAction: args?.totalBudget ?? '1.00', totalBudget: args?.totalBudget ?? '1.00', expiresAt, }), }); const d = await res.json() as any; return { content: [{ type: 'text', text: `Delegation requested:\n ID: ${d.id}\n Status: ${d.status}\n Risk: ${d.aiInterpretation?.riskLevel ?? 'unknown'}\n Warnings: ${d.aiInterpretation?.warnings?.join('; ') ?? 'none'}\n\nAwaiting wallet owner approval in SpendOS dashboard.`, }], }; } default: return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }; } }); // ── Start ────────────────────────────────────────────── async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('[SpendOS MCP] Server started on stdio'); } main().catch(console.error);