UNPKG

viberank-mcp-server

Version:

MCP server for submitting Claude usage stats to Viberank

358 lines 13.7 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; import fetch from 'node-fetch'; class ViberankMCPServer { server; cachedUsageData = null; lastFetchTime = 0; cacheDurationMs = 5 * 60 * 1000; // 5 minutes cache constructor() { this.server = new Server({ name: 'viberank-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, }); this.setupHandlers(); } setupHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'get_usage', description: 'Get current Claude Code usage statistics from ccusage', inputSchema: { type: 'object', properties: { force_refresh: { type: 'boolean', description: 'Force refresh the usage data (bypass cache)', default: false, }, }, }, }, { name: 'submit_to_viberank', description: 'Submit Claude Code usage statistics to Viberank leaderboard', inputSchema: { type: 'object', properties: { github_username: { type: 'string', description: 'GitHub username for the submission', }, auto_detect_username: { type: 'boolean', description: 'Automatically detect GitHub username from git config', default: true, }, }, required: [], }, }, { name: 'get_leaderboard', description: 'Get current Viberank leaderboard rankings', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of top users to return', default: 10, }, }, }, }, { name: 'get_profile', description: 'Get Viberank profile for a specific user', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'GitHub username to get profile for', }, }, required: ['username'], }, }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case 'get_usage': return await this.getUsage(args?.force_refresh); case 'submit_to_viberank': return await this.submitToViberank(args?.github_username, args?.auto_detect_username); case 'get_leaderboard': return await this.getLeaderboard(args?.limit); case 'get_profile': return await this.getProfile(args?.username); default: throw new Error(`Unknown tool: ${name}`); } }); } async getUsage(forceRefresh = false) { try { const now = Date.now(); // Check cache if (!forceRefresh && this.cachedUsageData && (now - this.lastFetchTime) < this.cacheDurationMs) { return { content: [ { type: 'text', text: JSON.stringify({ success: true, data: this.cachedUsageData, cached: true, summary: { totalCost: `$${Math.round(this.cachedUsageData.totals.totalCost)}`, totalTokens: this.cachedUsageData.totals.totalTokens.toLocaleString(), daysTracked: this.cachedUsageData.daily.length, }, }, null, 2), }, ], }; } // Generate fresh usage data const tempFile = path.join(os.tmpdir(), `cc-usage-${Date.now()}.json`); try { execSync(`npx ccusage@latest --json > ${tempFile}`, { encoding: 'utf8', stdio: 'pipe', }); const data = JSON.parse(fs.readFileSync(tempFile, 'utf8')); // Update cache this.cachedUsageData = data; this.lastFetchTime = now; // Clean up temp file fs.unlinkSync(tempFile); return { content: [ { type: 'text', text: JSON.stringify({ success: true, data, cached: false, summary: { totalCost: `$${Math.round(data.totals.totalCost)}`, totalTokens: data.totals.totalTokens.toLocaleString(), daysTracked: data.daily.length, }, }, null, 2), }, ], }; } catch (error) { // Clean up temp file if it exists if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } throw error; } } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: `Failed to get usage data: ${error.message}`, hint: 'Make sure you have run Claude Code at least once.', }, null, 2), }, ], }; } } async submitToViberank(githubUsername, autoDetectUsername = true) { try { // Determine GitHub username let username = githubUsername; if (!username && autoDetectUsername) { try { username = execSync('git config user.name', { encoding: 'utf8' }).trim(); } catch { // Ignore git config errors } } if (!username) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: 'GitHub username is required. Please provide it or ensure git config is set.', }, null, 2), }, ], }; } // Get fresh usage data const usageResult = await this.getUsage(true); const usageResponse = JSON.parse(usageResult.content[0].text); if (!usageResponse.success) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: 'Failed to get usage data before submission.', }, null, 2), }, ], }; } // Submit to Viberank API const response = await fetch('https://viberank.app/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-GitHub-User': username, }, body: JSON.stringify(usageResponse.data), }); const result = await response.json(); if (result.success) { return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully submitted to Viberank for ${username}!`, profileUrl: result.profileUrl, submissionId: result.submissionId, summary: usageResponse.summary, }, null, 2), }, ], }; } else { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: result.error || 'Failed to submit to Viberank', }, null, 2), }, ], }; } } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: `Submission failed: ${error.message}`, }, null, 2), }, ], }; } } async getLeaderboard(limit = 10) { try { // For now, return a message about visiting the website // In a future version, we could add an API endpoint for this return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: 'Leaderboard data is available at https://viberank.app', note: 'Direct API access to leaderboard coming soon!', limit, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: `Failed to get leaderboard: ${error.message}`, }, null, 2), }, ], }; } } async getProfile(username) { try { const profileUrl = `https://viberank.app/profile/${encodeURIComponent(username)}`; return { content: [ { type: 'text', text: JSON.stringify({ success: true, username, profileUrl, message: `View profile at: ${profileUrl}`, note: 'Direct API access to profile data coming soon!', }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: `Failed to get profile: ${error.message}`, }, null, 2), }, ], }; } } async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Viberank MCP Server started'); } } // Start the server const server = new ViberankMCPServer(); server.start().catch((error) => { console.error('Failed to start server:', error); process.exit(1); }); //# sourceMappingURL=index.js.map