From 74c6ef8ed2d9042e599bc1998d9f38446fab4b6c Mon Sep 17 00:00:00 2001 From: Evandro Ribeiro Date: Sat, 31 Oct 2026 12:06:34 +0000 Subject: [PATCH] feat(mcp): add HackProof MCP server for Claude, Cursor and Raven (fixes #19) --- apps/mcp/README.md | 111 ++++++++++ apps/mcp/package.json | 37 ++++ apps/mcp/src/client.ts | 180 +++++++++++++++++ apps/mcp/src/index.ts | 27 +++ apps/mcp/src/server.test.ts | 310 ++++++++++++++++++++++++++++ apps/mcp/src/server.ts | 389 ++++++++++++++++++++++++++++++++++++ apps/mcp/src/types.ts | 136 +++++++++++++ apps/mcp/tsconfig.json | 17 ++ package.json | 6 +- pnpm-lock.yaml | 13 ++ 10 files changed, 1224 insertions(+), 2 deletions(-) create mode 100644 apps/mcp/README.md create mode 100644 apps/mcp/package.json create mode 100644 apps/mcp/src/client.ts create mode 100644 apps/mcp/src/index.ts create mode 100644 apps/mcp/src/server.test.ts create mode 100644 apps/mcp/src/server.ts create mode 100644 apps/mcp/src/types.ts create mode 100644 apps/mcp/tsconfig.json diff --git a/apps/mcp/README.md b/apps/mcp/README.md new file mode 100644 index 0000000..e41ad61 --- /dev/null +++ b/apps/mcp/README.md @@ -0,0 +1,111 @@ +# hackproof-mcp + +Model Context Protocol (MCP) server for [HackProof](https://github.com/devodii/hackproof) — inspect, query, and verify HackMeridian 2026 hackathon projects, contracts, and deployments rebuilt from Stellar testnet ledger activity. + +## Features & Tools + +- `list_hackathons`: List indexed hackathons and verification windows. +- `get_summary`: High-level aggregate metrics (projects, contracts, builders, freshness timestamp). +- `query_projects`: Search projects by keywords, evidence tier, track, or integration partner. +- `get_project`: Full project breakdown including contracts, wasm uploads, and evidence links. +- `get_contract`: Detailed contract deployment record on Stellar testnet. +- `hourly`: 27-hour window contract deployment timeline. +- `distributions`: Geographic countries and tier distributions. +- `top_errors`: Most frequent transaction failure codes and error kinds. +- `compare_idea(text)`: Semantic keyword similarity matcher against all submissions. +- `methodology()`: Official evidence tier definitions and attribution rules. + +Every tool response includes a `generated_at` timestamp indicating data freshness. Responses are cached with an in-memory TTL and support ETag revalidation. + +--- + +## Installation & Running + +You can run the MCP server directly using `npx`: + +```sh +npx hackproof-mcp +``` + +Or run locally within the HackProof repository: + +```sh +cd apps/mcp +pnpm build +pnpm start +``` + +--- + +## Configuration + +### Claude Desktop + +Add this to your `claude_desktop_config.json`: + +- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` +- **Linux:** `~/.config/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "hackproof": { + "command": "npx", + "args": ["-y", "hackproof-mcp"], + "env": { + "HACKPROOF_API_URL": "https://hackproof.vercel.app/api" + } + } + } +} +``` + +### Cursor + +Add to your project's `.cursor/mcp.json` or global Cursor settings: + +```json +{ + "mcpServers": { + "hackproof": { + "command": "npx", + "args": ["-y", "hackproof-mcp"], + "env": { + "HACKPROOF_API_URL": "https://hackproof.vercel.app/api" + } + } + } +} +``` + +### Raven + +Add to your `raven.json` MCP configuration: + +```json +{ + "servers": [ + { + "name": "hackproof", + "transport": "stdio", + "command": "npx", + "args": ["-y", "hackproof-mcp"], + "env": { + "HACKPROOF_API_URL": "https://hackproof.vercel.app/api" + } + } + ] +} +``` + +--- + +## Development & Tests + +```sh +cd apps/mcp +pnpm install +pnpm build +pnpm test +``` diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..dcfa0e1 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,37 @@ +{ + "name": "hackproof-mcp", + "version": "0.1.0", + "description": "MCP server for HackProof - inspect and query hackathon projects and contracts rebuilt from Stellar testnet", + "type": "module", + "bin": { + "hackproof-mcp": "./dist/index.js" + }, + "main": "./dist/index.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node ./dist/index.js", + "test": "node --test dist/**/*.test.js" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "stellar", + "soroban", + "hackproof", + "hackathon" + ], + "author": "HackProof Contributors", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.1" + }, + "devDependencies": { + "@types/node": "^24.19.2", + "typescript": "~6.0.3" + } +} diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts new file mode 100644 index 0000000..e8bc2e7 --- /dev/null +++ b/apps/mcp/src/client.ts @@ -0,0 +1,180 @@ +import type { + Contract, + CountryCount, + FailedTx, + Integration, + Page, + Project, + ProjectDetail, + Stats, + TimelinePoint, + WasmUpload, +} from './types.js' + +interface CacheEntry { + data: T + etag?: string + expiresAt: number + generatedAt: string +} + +export interface ApiResponseWithFreshness { + data: T + generated_at: string +} + +export class HackProofClient { + private apiBase: string + private cache: Map> = new Map() + private defaultTtlMs: number = 60_000 // 60s in-memory cache + + constructor(apiBase?: string) { + this.apiBase = ( + apiBase || + process.env.HACKPROOF_API_URL || + 'https://hackproof.vercel.app/api' + ).replace(/\/$/, '') + } + + private async fetchWithCache(path: string): Promise> { + const url = `${this.apiBase}${path.startsWith('/') ? path : `/${path}`}` + const now = Date.now() + const cached = this.cache.get(url) + + const headers: Record = { + Accept: 'application/json', + 'User-Agent': 'HackProof-MCP-Server/0.1.0', + } + + if (cached) { + if (now < cached.expiresAt) { + return { data: cached.data, generated_at: cached.generatedAt } + } + if (cached.etag) { + headers['If-None-Match'] = cached.etag + } + } + + try { + const resp = await fetch(url, { headers }) + + if (resp.status === 304 && cached) { + cached.expiresAt = now + this.defaultTtlMs + return { data: cached.data, generated_at: cached.generatedAt } + } + + if (!resp.ok) { + throw new Error(`API error from ${url}: ${resp.status} ${resp.statusText}`) + } + + const etag = resp.headers.get('ETag') || undefined + const dateHeader = resp.headers.get('Date') + const generatedAt = dateHeader + ? new Date(dateHeader).toISOString() + : new Date(now).toISOString() + + const data = (await resp.json()) as T + + this.cache.set(url, { + data, + etag, + expiresAt: now + this.defaultTtlMs, + generatedAt, + }) + + return { data, generated_at: generatedAt } + } catch (err) { + // If network fails but we have stale cache, serve stale + if (cached) { + return { data: cached.data, generated_at: cached.generatedAt } + } + throw err + } + } + + async getStats(): Promise> { + const res = await this.fetchWithCache('/stats') + const stats = res.data + const generatedAt = stats.generated_at || res.generated_at + return { data: { ...stats, generated_at: generatedAt }, generated_at: generatedAt } + } + + async getTimeline(): Promise> { + return this.fetchWithCache('/timeline') + } + + async getCountries(): Promise> { + return this.fetchWithCache('/countries') + } + + async getIntegrations(): Promise> { + return this.fetchWithCache('/integrations') + } + + async getProjects(params?: { + q?: string + tier?: string + track?: string + integration?: string + limit?: number + offset?: number + }): Promise>> { + const query = new URLSearchParams() + if (params?.q) query.set('q', params.q) + if (params?.tier) query.set('tier', params.tier) + if (params?.track) query.set('track', params.track) + if (params?.integration) query.set('integration', params.integration) + if (params?.limit) query.set('limit', params.limit.toString()) + if (params?.offset) query.set('offset', params.offset.toString()) + + const qs = query.toString() + const path = `/projects${qs ? `?${qs}` : ''}` + return this.fetchWithCache>(path) + } + + async getProject(projectId: string): Promise> { + return this.fetchWithCache(`/projects/${encodeURIComponent(projectId)}`) + } + + async getContracts(params?: { + q?: string + limit?: number + offset?: number + }): Promise>> { + const query = new URLSearchParams() + if (params?.q) query.set('q', params.q) + if (params?.limit) query.set('limit', params.limit.toString()) + if (params?.offset) query.set('offset', params.offset.toString()) + + const qs = query.toString() + return this.fetchWithCache>(`/contracts${qs ? `?${qs}` : ''}`) + } + + async getWasm(params?: { + q?: string + limit?: number + offset?: number + }): Promise>> { + const query = new URLSearchParams() + if (params?.q) query.set('q', params.q) + if (params?.limit) query.set('limit', params.limit.toString()) + if (params?.offset) query.set('offset', params.offset.toString()) + + const qs = query.toString() + return this.fetchWithCache>(`/wasm${qs ? `?${qs}` : ''}`) + } + + async getFailed(params?: { + q?: string + limit?: number + offset?: number + }): Promise>> { + const query = new URLSearchParams() + if (params?.q) query.set('q', params.q) + if (params?.limit) query.set('limit', params.limit.toString()) + if (params?.offset) query.set('offset', params.offset.toString()) + + const qs = query.toString() + return this.fetchWithCache>(`/failed${qs ? `?${qs}` : ''}`) + } +} diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts new file mode 100644 index 0000000..eeaf894 --- /dev/null +++ b/apps/mcp/src/index.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { createMcpServer } from './server.js' + +async function main() { + const server = createMcpServer() + const transport = new StdioServerTransport() + + process.on('SIGINT', async () => { + await server.close() + process.exit(0) + }) + + process.on('SIGTERM', async () => { + await server.close() + process.exit(0) + }) + + await server.connect(transport) + console.error('HackProof MCP server running on stdio') +} + +main().catch((error) => { + console.error('Fatal error in HackProof MCP server:', error) + process.exit(1) +}) diff --git a/apps/mcp/src/server.test.ts b/apps/mcp/src/server.test.ts new file mode 100644 index 0000000..ec72156 --- /dev/null +++ b/apps/mcp/src/server.test.ts @@ -0,0 +1,310 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { HackProofClient } from './client.js' +import { createMcpServer } from './server.js' + +// Mock fixture client that returns recorded data without network calls +class FixtureHackProofClient extends HackProofClient { + constructor() { + super('http://fixture.local/api') + } + + override async getStats() { + return { + data: { + projects: 42, + verified_projects: 20, + likely_projects: 15, + possible_projects: 7, + unattributed_accounts: 10, + contracts: 55, + builders: 30, + contributors: 65, + countries: 18, + wasm_uploads: 40, + failed_txs: 5, + generated_at: '2026-10-31T12:00:00Z', + }, + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getTimeline() { + return { + data: [ + { hour: '2026-10-17T10:00:00Z', contracts: 3 }, + { hour: '2026-10-17T11:00:00Z', contracts: 12 }, + ], + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getCountries() { + return { + data: [ + { country: 'PT', contributors: 25 }, + { country: 'BR', contributors: 14 }, + ], + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getIntegrations() { + return { + data: [ + { + slug: 'privy', + name: 'Privy', + category: 'Auth', + site: 'https://privy.io', + projects: 6, + }, + ], + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getProjects() { + return { + data: { + total: 1, + items: [ + { + project_id: 'repo:stellar/soroban-vault', + full_name: 'stellar/soroban-vault', + url: 'https://github.com/stellar/soroban-vault', + footprint: 'contracts' as const, + tier: 'verified' as const, + contracts: 4, + classic_ops: 8, + assets: 1, + builders: 2, + wasm_uploads: 3, + failed_txs: 0, + first_activity: '2026-10-17T10:00:00Z', + last_activity: '2026-10-17T18:00:00Z', + description: 'Automated yield vault and lending protocol on Soroban', + homepage: 'https://vault.stellar.org', + stars: 55, + forks: 12, + language: 'Rust', + license: 'Apache-2.0', + owner: 'stellar', + owner_avatar: null, + country: 'PT', + window_commits: 18, + total_commits: 22, + stellar_added_in_window: true, + created_at: '2026-10-17T09:30:00Z', + topics: ['defi', 'vault', 'lending'], + track: 'genesis' as const, + integrations: ['privy'], + award: 'first' as const, + }, + ], + }, + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getProject(projectId: string) { + return { + data: { + project: (await this.getProjects()).data.items[0], + links: [ + { + subject_kind: 'contract', + subject: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + method: 'contract_in_repo', + path: 'src/lib.rs', + }, + ], + integration_uses: [ + { + slug: 'privy', + name: 'Privy', + method: 'package' as const, + evidence: '@privy-io/node in package.json', + path: 'package.json', + }, + ], + contributors: [ + { + login: 'alice', + url: 'https://github.com/alice', + avatar_url: null, + contributions: 14, + country: 'PT', + }, + ], + contracts: [ + { + contract_id: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + deployer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + wasm_hash: '1111111111111111111111111111111111111111111111111111111111111111', + tx_hash: '2222222222222222222222222222222222222222222222222222222222222222', + ledger: 100, + deployed_at: '2026-10-17T11:00:00Z', + project_id: projectId, + }, + ], + wasm_uploads: [], + failed_txs: [], + }, + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getContracts() { + return { + data: { + total: 1, + items: [ + { + contract_id: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + deployer: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + wasm_hash: '1111111111111111111111111111111111111111111111111111111111111111', + tx_hash: '2222222222222222222222222222222222222222222222222222222222222222', + ledger: 100, + deployed_at: '2026-10-17T11:00:00Z', + project_id: 'repo:stellar/soroban-vault', + }, + ], + }, + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getWasm() { + return { + data: { + total: 1, + items: [ + { + wasm_hash: '1111111111111111111111111111111111111111111111111111111111111111', + uploader: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + tx_hash: '3333333333333333333333333333333333333333333333333333333333333333', + size: 24500, + source_repo: 'stellar/soroban-vault', + full_name: 'stellar/soroban-vault', + contracts: 1, + uploaded_at: '2026-10-17T10:45:00Z', + }, + ], + }, + generated_at: '2026-10-31T12:00:00Z', + } + } + + override async getFailed() { + return { + data: { + total: 1, + items: [ + { + tx_hash: '4444444444444444444444444444444444444444444444444444444444444444', + source_account: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + kind: 'invokeHostFunction', + result_code: 'txFAILED', + ledger: 101, + created_at: '2026-10-17T11:05:00Z', + }, + ], + }, + generated_at: '2026-10-31T12:00:00Z', + } + } +} + +test('MCP Server lists all 10 tools', async () => { + const client = new FixtureHackProofClient() + const server = createMcpServer(client) + + const handlers = (server as any)._requestHandlers + assert.ok(handlers.has('tools/list')) + assert.ok(handlers.has('tools/call')) +}) + +test('Every MCP tool returns valid data with fixture API', async () => { + const client = new FixtureHackProofClient() + const server = createMcpServer(client) + const callHandler = (server as any)._requestHandlers.get('tools/call') + + // Helper to call tool through Zod-compliant request shape + const callTool = async (name: string, args: Record = {}) => { + return callHandler({ + method: 'tools/call', + params: { name, arguments: args }, + }) + } + + // 1. list_hackathons + const res1 = await callTool('list_hackathons') + assert.strictEqual(res1.isError, undefined) + const data1 = JSON.parse(res1.content[0].text) + assert.strictEqual(data1.hackathons[0].slug, 'hackmeridian-2026') + assert.ok(data1.generated_at) + + // 2. get_summary + const res2 = await callTool('get_summary') + assert.strictEqual(res2.isError, undefined) + const data2 = JSON.parse(res2.content[0].text) + assert.strictEqual(data2.data.projects, 42) + assert.ok(data2.generated_at) + + // 3. query_projects + const res3 = await callTool('query_projects', { q: 'vault' }) + assert.strictEqual(res3.isError, undefined) + const data3 = JSON.parse(res3.content[0].text) + assert.strictEqual(data3.count, 1) + assert.ok(data3.generated_at) + + // 4. get_project + const res4 = await callTool('get_project', { project_id: 'stellar/soroban-vault' }) + assert.strictEqual(res4.isError, undefined) + const data4 = JSON.parse(res4.content[0].text) + assert.strictEqual(data4.data.project.project_id, 'repo:stellar/soroban-vault') + assert.strictEqual(data4.data.links.length, 1) + + // 5. get_contract + const res5 = await callTool('get_contract', { contract_id: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' }) + assert.strictEqual(res5.isError, undefined) + const data5 = JSON.parse(res5.content[0].text) + assert.strictEqual(data5.matched_contracts.length, 1) + + // 6. hourly + const res6 = await callTool('hourly') + assert.strictEqual(res6.isError, undefined) + const data6 = JSON.parse(res6.content[0].text) + assert.strictEqual(data6.timeline.length, 2) + assert.strictEqual(data6.peak_hour.contracts, 12) + + // 7. distributions + const res7 = await callTool('distributions') + assert.strictEqual(res7.isError, undefined) + const data7 = JSON.parse(res7.content[0].text) + assert.strictEqual(data7.countries.length, 2) + assert.strictEqual(data7.tiers.verified, 20) + + // 8. top_errors + const res8 = await callTool('top_errors', { limit: 10 }) + assert.strictEqual(res8.isError, undefined) + const data8 = JSON.parse(res8.content[0].text) + assert.strictEqual(data8.total_failed_txs_sampled, 1) + assert.strictEqual(data8.top_result_codes[0].code, 'txFAILED') + + // 9. compare_idea + const res9 = await callTool('compare_idea', { text: 'A lending vault protocol for soroban' }) + assert.strictEqual(res9.isError, undefined) + const data9 = JSON.parse(res9.content[0].text) + assert.strictEqual(data9.matched_count, 1) + assert.ok(data9.closest_projects[0].matched_keywords.includes('vault')) + + // 10. methodology + const res10 = await callTool('methodology') + assert.strictEqual(res10.isError, undefined) + const data10 = JSON.parse(res10.content[0].text) + assert.strictEqual(data10.hack_window.duration, '27 hours') + assert.ok(data10.evidence_tiers.verified) +}) diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts new file mode 100644 index 0000000..219c6ec --- /dev/null +++ b/apps/mcp/src/server.ts @@ -0,0 +1,389 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type Tool, +} from '@modelcontextprotocol/sdk/types.js' + +import { HackProofClient } from './client.js' + +const TOOLS: Tool[] = [ + { + name: 'list_hackathons', + description: 'List all hackathons indexed and verified by HackProof, including HackMeridian 2026.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'get_summary', + description: 'Get high-level summary metrics for HackMeridian (projects, verified projects, total contracts, builders, contributors, countries, wasm uploads, and freshness timestamp).', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'query_projects', + description: 'Search and filter HackProof projects by keyword, evidence tier (verified, likely, possible), track (genesis, scale), or integration partner.', + inputSchema: { + type: 'object', + properties: { + q: { type: 'string', description: 'Search term across project name, description or repository' }, + tier: { type: 'string', enum: ['verified', 'likely', 'possible', 'unattributed'], description: 'Filter by evidence tier' }, + track: { type: 'string', enum: ['genesis', 'scale'], description: 'Filter by track' }, + integration: { type: 'string', description: 'Filter by integration partner slug (e.g. privy, trustless-work)' }, + limit: { type: 'number', description: 'Number of results to return (default 20, max 100)' }, + offset: { type: 'number', description: 'Offset for pagination' }, + }, + additionalProperties: false, + }, + }, + { + name: 'get_project', + description: 'Get in-depth breakdown for a specific HackProof project by project ID or repo full name, including evidence matches, contributors, contracts deployed, wasm uploads, and failed transactions.', + inputSchema: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'Project ID (e.g. "repo:org/name") or repository full name' }, + }, + required: ['project_id'], + additionalProperties: false, + }, + }, + { + name: 'get_contract', + description: 'Lookup a deployed Soroban contract on Stellar testnet by contract ID or search query to view its deployer, wasm hash, transaction hash, and timestamp.', + inputSchema: { + type: 'object', + properties: { + contract_id: { type: 'string', description: 'Stellar contract ID (C...) or search term' }, + }, + required: ['contract_id'], + additionalProperties: false, + }, + }, + { + name: 'hourly', + description: 'Get the hourly timeline of Soroban contract deployments across the entire 27-hour hackathon window.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'distributions', + description: 'Get comprehensive distribution metrics: builder geographic origin by country, evidence tier breakdown, and track distributions.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'top_errors', + description: 'Get the most common Soroban and Stellar transaction failure kinds and error codes recorded during the hack window.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max error transactions to inspect (default 50)' }, + }, + additionalProperties: false, + }, + }, + { + name: 'compare_idea', + description: 'Compare an idea or proposal against all HackProof submissions using keyword matching over project names, descriptions and topics, returning the closest projects with matched terms.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'The idea, thesis or description to compare against existing projects' }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + { + name: 'methodology', + description: 'Return the official HackProof methodology explaining evidence tiers (verified vs likely vs possible), repo-to-chain matching, and testnet reconstruction rules.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, +] + +export function createMcpServer(client?: HackProofClient) { + const apiClient = client || new HackProofClient() + + const server = new Server( + { + name: 'hackproof-mcp', + version: '0.1.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS } + }) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params + + try { + switch (name) { + case 'list_hackathons': { + const statsRes = await apiClient.getStats() + const payload = { + hackathons: [ + { + slug: 'hackmeridian-2026', + name: 'HackMeridian 2026', + network: 'stellar-testnet', + window: { + start: '2026-10-17T09:00:00Z', + end: '2026-10-18T12:00:00Z', + duration_hours: 27, + }, + location: 'Lisbon, Portugal', + tracks: ['genesis', 'scale'], + total_projects: statsRes.data.projects, + }, + ], + generated_at: statsRes.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'get_summary': { + const res = await apiClient.getStats() + return { + content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], + } + } + + case 'query_projects': { + const limit = Math.min(Number(args.limit) || 20, 100) + const offset = Number(args.offset) || 0 + const res = await apiClient.getProjects({ + q: typeof args.q === 'string' ? args.q : undefined, + tier: typeof args.tier === 'string' ? args.tier : undefined, + track: typeof args.track === 'string' ? args.track : undefined, + integration: typeof args.integration === 'string' ? args.integration : undefined, + limit, + offset, + }) + const payload = { + total: res.data.total, + count: res.data.items.length, + projects: res.data.items, + generated_at: res.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'get_project': { + const projectId = String(args.project_id || '') + if (!projectId) { + return { + isError: true, + content: [{ type: 'text', text: 'Missing required argument: project_id' }], + } + } + const formattedId = projectId.startsWith('repo:') || projectId.startsWith('acct:') + ? projectId + : `repo:${projectId}` + const res = await apiClient.getProject(formattedId) + return { + content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], + } + } + + case 'get_contract': { + const contractId = String(args.contract_id || '') + const res = await apiClient.getContracts({ q: contractId, limit: 10 }) + const payload = { + query: contractId, + matched_contracts: res.data.items, + generated_at: res.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'hourly': { + const res = await apiClient.getTimeline() + const payload = { + timeline: res.data, + peak_hour: res.data.reduce( + (best, point) => (point.contracts > best.contracts ? point : best), + res.data[0] || { hour: '', contracts: 0 }, + ), + generated_at: res.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'distributions': { + const [countriesRes, statsRes] = await Promise.all([ + apiClient.getCountries(), + apiClient.getStats(), + ]) + const payload = { + countries: countriesRes.data, + tiers: { + verified: statsRes.data.verified_projects, + likely: statsRes.data.likely_projects, + possible: statsRes.data.possible_projects, + unattributed_accounts: statsRes.data.unattributed_accounts, + }, + total_projects: statsRes.data.projects, + generated_at: statsRes.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'top_errors': { + const limit = Math.min(Number(args.limit) || 50, 200) + const res = await apiClient.getFailed({ limit }) + + const errorFrequency: Record = {} + const kindFrequency: Record = {} + for (const tx of res.data.items) { + errorFrequency[tx.result_code] = (errorFrequency[tx.result_code] || 0) + 1 + kindFrequency[tx.kind] = (kindFrequency[tx.kind] || 0) + 1 + } + + const sortedErrors = Object.entries(errorFrequency) + .sort((a, b) => b[1] - a[1]) + .map(([code, count]) => ({ code, count })) + + const payload = { + total_failed_txs_sampled: res.data.items.length, + top_result_codes: sortedErrors, + operations_distribution: kindFrequency, + sample_recent_failures: res.data.items.slice(0, 10), + generated_at: res.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'compare_idea': { + const text = String(args.text || '').toLowerCase() + if (!text) { + return { + isError: true, + content: [{ type: 'text', text: 'Missing required argument: text' }], + } + } + + // Fetch projects + const res = await apiClient.getProjects({ limit: 100 }) + const stopWords = new Set([ + 'a', 'an', 'and', 'the', 'in', 'on', 'of', 'for', 'to', 'with', 'by', + 'is', 'are', 'was', 'were', 'at', 'from', 'as', 'that', 'this', 'it', + ]) + const tokens = text + .split(/[\s,.;:!?/()\[\]{}]+/) + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 2 && !stopWords.has(t)) + + const scored = res.data.items.map((project) => { + const searchable = [ + project.full_name || '', + project.description || '', + ...(project.topics || []), + ...(project.integrations || []), + ] + .join(' ') + .toLowerCase() + + const matchedTokens = tokens.filter((tok) => searchable.includes(tok)) + return { + project_id: project.project_id, + full_name: project.full_name, + description: project.description, + tier: project.tier, + track: project.track, + matched_keywords: matchedTokens, + score: matchedTokens.length, + } + }) + + const topMatches = scored + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 5) + + const payload = { + query_idea: text, + extracted_keywords: tokens, + matched_count: topMatches.length, + closest_projects: topMatches, + generated_at: res.generated_at, + } + return { + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + } + } + + case 'methodology': { + const methodologyDoc = { + title: 'HackProof Evidence Methodology', + purpose: 'Reconstruct HackMeridian 2026 hackathon submissions directly from Stellar testnet ledger data and GitHub repository matches.', + hack_window: { + start: '2026-10-17T09:00:00Z', + end: '2026-10-18T12:00:00Z', + duration: '27 hours', + }, + evidence_tiers: { + verified: 'Exact match between on-chain contract deploy/invocation and git repository activity inside the hack window.', + likely: 'Repository created or modified during the hack with Stellar SDK dependencies and at least 5 commits inside the window.', + possible: 'Activity detected with weak attribution or single matching commit.', + unattributed: 'On-chain deployer accounts with activity during the hack window but no matching public repository discovered.', + }, + reproducibility: 'All data is rebuilt deterministically from public Horizon & Soroban RPC and public GitHub repositories.', + generated_at: new Date().toISOString(), + } + return { + content: [{ type: 'text', text: JSON.stringify(methodologyDoc, null, 2) }], + } + } + + default: + return { + isError: true, + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + } + } + } catch (error: any) { + return { + isError: true, + content: [{ type: 'text', text: `Error executing ${name}: ${error.message || String(error)}` }], + } + } + }) + + return server +} diff --git a/apps/mcp/src/types.ts b/apps/mcp/src/types.ts new file mode 100644 index 0000000..4409f12 --- /dev/null +++ b/apps/mcp/src/types.ts @@ -0,0 +1,136 @@ +export type Tier = 'verified' | 'likely' | 'possible' | 'unattributed' +export type Track = 'genesis' | 'scale' + +export interface Stats { + projects: number + verified_projects: number + likely_projects: number + possible_projects: number + unattributed_accounts: number + contracts: number + builders: number + contributors: number + countries: number + wasm_uploads: number + failed_txs: number + generated_at?: string | null +} + +export interface Project { + project_id: string + full_name: string | null + url: string | null + footprint: 'contracts' | 'classic' | 'github' + tier: Tier + contracts: number + classic_ops: number + assets: number + builders: number + wasm_uploads: number + failed_txs: number + first_activity: string | null + last_activity: string | null + description: string | null + homepage: string | null + stars: number | null + forks: number | null + language: string | null + license: string | null + owner: string | null + owner_avatar: string | null + country: string | null + window_commits: number | null + total_commits: number | null + stellar_added_in_window: boolean | null + created_at: string | null + topics: string[] | null + track: Track | null + integrations: string[] + award: 'first' | 'runner-up' | null +} + +export interface Contributor { + login: string + url: string + avatar_url: string | null + contributions: number + country: string | null +} + +export interface Link { + subject_kind: string + subject: string + method: string + path: string | null +} + +export interface Contract { + contract_id: string + deployer: string + wasm_hash: string + tx_hash: string + ledger: number + deployed_at: string + project_id: string +} + +export interface WasmUpload { + wasm_hash: string + uploader: string + tx_hash: string + size: number + source_repo: string | null + full_name: string | null + contracts: number + uploaded_at: string +} + +export interface FailedTx { + tx_hash: string + source_account: string + kind: string + result_code: string + ledger: number + created_at: string +} + +export interface IntegrationUse { + slug: string + name: string + method: 'package' | 'endpoint' + evidence: string + path: string +} + +export interface ProjectDetail { + project: Project + links: Link[] + integration_uses: IntegrationUse[] + contributors: Contributor[] + contracts: Contract[] + wasm_uploads: WasmUpload[] + failed_txs: FailedTx[] +} + +export interface TimelinePoint { + hour: string + contracts: number +} + +export interface Integration { + slug: string + name: string + category: string + site: string + projects: number +} + +export interface CountryCount { + country: string + contributors: number +} + +export interface Page { + items: T[] + total: number +} diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..56a3c3a --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/package.json b/package.json index c6d293d..84d6429 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "pnpm --filter web build", "lint": "pnpm --filter web lint", "format": "pnpm --filter web format", - "format:check": "pnpm --filter web format:check" + "format:check": "pnpm --filter web format:check", + "build:mcp": "pnpm --filter hackproof-mcp build", + "test:mcp": "pnpm --filter hackproof-mcp test" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de60ba8..fde797f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,19 @@ importers: .: {} + apps/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.6.1 + version: 1.32.2(zod@4.6.7) + devDependencies: + '@types/node': + specifier: ^24.19.2 + version: 24.19.2 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + apps/web: dependencies: '@ai-sdk/react':