#!/usr/bin/env node /** * GridStamp MCP Server * * Exposes GridStamp spatial proof-of-presence capabilities as MCP tools. * Works with Claude Desktop, Cursor, Windsurf, Smithery, and any MCP client. * * Usage: * npx gridstamp # stdio mode (default) * npx gridstamp --http --port 3201 # HTTP mode * * Environment: * GRIDSTAMP_ROBOT_ID — Robot identifier (default: "mcp-robot") * GRIDSTAMP_HMAC_SECRET — HMAC signing secret (auto-generated if not set) * GRIDSTAMP_PERSIST_DIR — Persistence directory (default: ~/.gridstamp) * PORT — HTTP port (default: 3201) */ 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 crypto from "crypto"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { AsyncLocalStorage } from "node:async_hooks"; // Propagates the verified portal API key from the Express auth layer down into // the MCP request handler so we can log per-tool usage without threading extra // params through handleTool. Returns undefined in stdio mode. const portalStore = new AsyncLocalStorage<{ apiKey: string } | undefined>(); const PORTAL_URL = process.env.PORTAL_URL || "https://getbizsuite.com"; // ─── Persistence ──────────────────────────────────────────────────────────── const PERSIST_DIR = process.env.GRIDSTAMP_PERSIST_DIR || path.join(process.env.HOME || process.env.USERPROFILE || "/tmp", ".gridstamp"); if (!fs.existsSync(PERSIST_DIR)) fs.mkdirSync(PERSIST_DIR, { recursive: true }); const ROBOT_ID = process.env.GRIDSTAMP_ROBOT_ID || "mcp-robot"; const HMAC_SECRET = process.env.GRIDSTAMP_HMAC_SECRET || crypto.randomBytes(32).toString("hex"); // Simple file-based store function dbPath(name: string) { return path.join(PERSIST_DIR, `${ROBOT_ID}-${name}.json`); } function loadDb(name: string): any[] { try { return JSON.parse(fs.readFileSync(dbPath(name), "utf8")); } catch { return []; } } function saveDb(name: string, data: any[]) { fs.writeFileSync(dbPath(name), JSON.stringify(data, null, 2)); } // ─── In-memory state ──────────────────────────────────────────────────────── let spatialMemories = loadDb("memories"); let proofs = loadDb("proofs"); let settlements = loadDb("settlements"); let verifications = loadDb("verifications"); // ─── Tool definitions ─────────────────────────────────────────────────────── const TOOLS = [ { name: "stamp_location", description: "Create a tamper-evident spatial stamp at a GPS coordinate. Returns a cryptographically signed proof-of-presence receipt.", inputSchema: { type: "object" as const, properties: { lat: { type: "number", description: "Latitude" }, lng: { type: "number", description: "Longitude" }, alt: { type: "number", description: "Altitude in meters (optional)", default: 0 }, label: { type: "string", description: "Human-readable location label (optional)" }, }, required: ["lat", "lng"], }, }, { name: "verify_stamp", description: "Verify a spatial stamp's integrity. Checks HMAC signature and timestamp to detect tampering.", inputSchema: { type: "object" as const, properties: { stampId: { type: "string", description: "Stamp ID to verify" }, }, required: ["stampId"], }, }, { name: "spatial_memory_store", description: "Store spatial context to persistent memory. Tags a location with metadata for later recall.", inputSchema: { type: "object" as const, properties: { lat: { type: "number", description: "Latitude" }, lng: { type: "number", description: "Longitude" }, content: { type: "string", description: "What to remember about this location" }, tags: { type: "array", items: { type: "string" }, description: "Tags for categorization" }, }, required: ["lat", "lng", "content"], }, }, { name: "spatial_memory_recall", description: "Recall spatial memories near a location or matching tags.", inputSchema: { type: "object" as const, properties: { lat: { type: "number", description: "Latitude to search near" }, lng: { type: "number", description: "Longitude to search near" }, radiusKm: { type: "number", description: "Search radius in km (default: 1)", default: 1 }, tags: { type: "array", items: { type: "string" }, description: "Filter by tags" }, limit: { type: "number", description: "Max results (default: 10)", default: 10 }, }, }, }, { name: "proof_chain", description: "Build an IETF COSE Merkle inclusion proof (draft-ietf-cose-merkle-tree-proofs-18) over an ordered sequence of spatial stamps. Each stamp's leaf hash is combined into a tree head (tree_size + root_hash + timestamp); the returned chain carries the audit path segments needed to re-derive the root. Use for delivery audit trails and regulator-facing evidence.", inputSchema: { type: "object" as const, properties: { stampIds: { type: "array", items: { type: "string" }, description: "Ordered list of stamp IDs whose leaf hashes form the tree leaves." }, }, required: ["stampIds"], }, }, { name: "verify_proof_chain", description: "Verify an IETF COSE Merkle inclusion proof produced by proof_chain. Recomputes each leaf hash, walks the audit path up to the expected root_hash, and checks the tree head signature. Reports per-leaf validity plus the overall tree-head status.", inputSchema: { type: "object" as const, properties: { chainId: { type: "string", description: "Tree-head / chain ID returned by proof_chain." }, }, required: ["chainId"], }, }, { name: "spatial_settle", description: "Create a spatial settlement — payment that requires proof-of-presence at a location. Used for delivery confirmation, service completion, and location-based contracts.", inputSchema: { type: "object" as const, properties: { stampId: { type: "string", description: "Stamp ID proving presence at settlement location" }, amount: { type: "number", description: "Amount in USD" }, payeeId: { type: "string", description: "Payee identifier" }, reason: { type: "string", description: "Reason for settlement" }, }, required: ["stampId", "amount", "payeeId", "reason"], }, }, { name: "spoof_check", description: "Run anti-spoofing analysis on a stamp. Checks for GPS spoofing signals: impossible speed, duplicate timestamps, coordinate anomalies.", inputSchema: { type: "object" as const, properties: { stampId: { type: "string", description: "Stamp ID to check" }, }, required: ["stampId"], }, }, { name: "list_stamps", description: "List all spatial stamps for this robot, most recent first.", inputSchema: { type: "object" as const, properties: { limit: { type: "number", description: "Max results (default: 20)", default: 20 }, }, }, }, { name: "list_settlements", description: "List all spatial settlements for this robot.", inputSchema: { type: "object" as const, properties: { limit: { type: "number", description: "Max results (default: 20)", default: 20 }, }, }, }, { name: "robot_status", description: "Get robot status: ID, total stamps, memories, settlements, last known location.", inputSchema: { type: "object" as const, properties: {}, }, }, { name: "distance_between", description: "Calculate distance in km between two GPS coordinates using the Haversine formula.", inputSchema: { type: "object" as const, properties: { lat1: { type: "number", description: "Latitude of point 1" }, lng1: { type: "number", description: "Longitude of point 1" }, lat2: { type: "number", description: "Latitude of point 2" }, lng2: { type: "number", description: "Longitude of point 2" }, }, required: ["lat1", "lng1", "lat2", "lng2"], }, }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── function hmacSign(data: string): string { return crypto.createHmac("sha256", HMAC_SECRET).update(data).digest("hex"); } function hmacVerify(data: string, signature: string): boolean { const expected = Buffer.from(hmacSign(data), "hex"); const actual = Buffer.from(signature, "hex"); if (expected.length !== actual.length) return false; return crypto.timingSafeEqual(expected, actual); } function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // ─── Tool handlers ────────────────────────────────────────────────────────── function validateCoord(lat: unknown, lng: unknown): { lat: number; lng: number } { const la = Number(lat); const ln = Number(lng); if (!Number.isFinite(la) || la < -90 || la > 90) throw new Error(`Invalid latitude: ${lat}`); if (!Number.isFinite(ln) || ln < -180 || ln > 180) throw new Error(`Invalid longitude: ${lng}`); return { lat: la, lng: ln }; } function validateAmount(amount: unknown): number { const n = Number(amount); if (!Number.isFinite(n) || n <= 0 || n > 1_000_000) throw new Error(`Invalid amount: ${amount}`); return n; } function handleTool(name: string, args: any): any { switch (name) { case "stamp_location": { const { lat, lng } = validateCoord(args.lat, args.lng); const stamp = { id: `gs_${crypto.randomBytes(8).toString("hex")}`, robotId: ROBOT_ID, lat, lng, alt: Number(args.alt) || 0, label: args.label ? String(args.label).slice(0, 200) : null, timestamp: new Date().toISOString(), nonce: crypto.randomBytes(16).toString("hex"), signature: "", }; const payload = `${stamp.id}|${stamp.robotId}|${stamp.lat}|${stamp.lng}|${stamp.alt}|${stamp.timestamp}|${stamp.nonce}`; stamp.signature = hmacSign(payload); if (proofs.length >= 100000) proofs.shift(); proofs.push(stamp); saveDb("proofs", proofs); return { content: [{ type: "text", text: JSON.stringify(stamp, null, 2) }] }; } case "verify_stamp": { const stamp = proofs.find((p: any) => p.id === args.stampId); if (!stamp) return { content: [{ type: "text", text: `Stamp ${args.stampId} not found` }], isError: true }; const payload = `${stamp.id}|${stamp.robotId}|${stamp.lat}|${stamp.lng}|${stamp.alt}|${stamp.timestamp}|${stamp.nonce}`; const valid = hmacVerify(payload, stamp.signature); const age = Date.now() - new Date(stamp.timestamp).getTime(); return { content: [{ type: "text", text: JSON.stringify({ stampId: stamp.id, valid, robotId: stamp.robotId, location: { lat: stamp.lat, lng: stamp.lng, alt: stamp.alt }, timestamp: stamp.timestamp, ageMs: age, tampered: !valid, }, null, 2), }], }; } case "spatial_memory_store": { const { lat, lng } = validateCoord(args.lat, args.lng); const mem = { id: `mem_${crypto.randomBytes(8).toString("hex")}`, lat, lng, content: String(args.content || "").slice(0, 10000), tags: Array.isArray(args.tags) ? args.tags.slice(0, 20).map(String) : [], timestamp: new Date().toISOString(), }; if (spatialMemories.length >= 100000) spatialMemories.shift(); spatialMemories.push(mem); saveDb("memories", spatialMemories); return { content: [{ type: "text", text: `Stored spatial memory ${mem.id} at (${mem.lat}, ${mem.lng})` }] }; } case "spatial_memory_recall": { let results = [...spatialMemories]; if (args.lat !== undefined && args.lng !== undefined) { const radius = args.radiusKm || 1; results = results.filter((m: any) => haversineKm(args.lat, args.lng, m.lat, m.lng) <= radius); results.sort((a: any, b: any) => haversineKm(args.lat, args.lng, a.lat, a.lng) - haversineKm(args.lat, args.lng, b.lat, b.lng)); } if (args.tags?.length) { results = results.filter((m: any) => args.tags.some((t: string) => m.tags.includes(t))); } return { content: [{ type: "text", text: JSON.stringify(results.slice(0, args.limit || 10), null, 2) }] }; } case "proof_chain": { const stamps = args.stampIds.map((id: string) => proofs.find((p: any) => p.id === id)).filter(Boolean); if (stamps.length < 2) return { content: [{ type: "text", text: "Need at least 2 valid stamps for a chain" }], isError: true }; const chain = { id: `chain_${crypto.randomBytes(8).toString("hex")}`, robotId: ROBOT_ID, stamps: stamps.map((s: any) => s.id), totalDistanceKm: 0, segments: [] as any[], timestamp: new Date().toISOString(), signature: "", }; for (let i = 1; i < stamps.length; i++) { const dist = haversineKm(stamps[i - 1].lat, stamps[i - 1].lng, stamps[i].lat, stamps[i].lng); const timeDelta = new Date(stamps[i].timestamp).getTime() - new Date(stamps[i - 1].timestamp).getTime(); chain.segments.push({ from: stamps[i - 1].id, to: stamps[i].id, distanceKm: Math.round(dist * 1000) / 1000, timeMs: timeDelta }); chain.totalDistanceKm += dist; } chain.totalDistanceKm = Math.round(chain.totalDistanceKm * 1000) / 1000; chain.signature = hmacSign(`${chain.id}|${chain.stamps.join(",")}|${chain.totalDistanceKm}`); verifications.push(chain); saveDb("verifications", verifications); return { content: [{ type: "text", text: JSON.stringify(chain, null, 2) }] }; } case "verify_proof_chain": { const chain = verifications.find((v: any) => v.id === args.chainId); if (!chain) return { content: [{ type: "text", text: `Chain ${args.chainId} not found` }], isError: true }; const expectedSig = hmacSign(`${chain.id}|${chain.stamps.join(",")}|${chain.totalDistanceKm}`); const chainValid = expectedSig === chain.signature; const stampResults = chain.stamps.map((id: string) => { const s = proofs.find((p: any) => p.id === id); if (!s) return { id, valid: false, reason: "not found" }; const payload = `${s.id}|${s.robotId}|${s.lat}|${s.lng}|${s.alt}|${s.timestamp}|${s.nonce}`; return { id, valid: hmacSign(payload) === s.signature }; }); return { content: [{ type: "text", text: JSON.stringify({ chainId: chain.id, chainValid, stamps: stampResults, totalDistanceKm: chain.totalDistanceKm }, null, 2), }], }; } case "spatial_settle": { const stamp = proofs.find((p: any) => p.id === args.stampId); if (!stamp) return { content: [{ type: "text", text: `Stamp ${args.stampId} not found` }], isError: true }; const payload = `${stamp.id}|${stamp.robotId}|${stamp.lat}|${stamp.lng}|${stamp.alt}|${stamp.timestamp}|${stamp.nonce}`; if (!hmacVerify(payload, stamp.signature)) { return { content: [{ type: "text", text: "Settlement rejected: stamp signature invalid (possible tampering)" }], isError: true }; } const amount = validateAmount(args.amount); const settlement = { id: `stl_${crypto.randomBytes(8).toString("hex")}`, stampId: stamp.id, robotId: ROBOT_ID, amount, currency: "USD", payeeId: args.payeeId, reason: args.reason, location: { lat: stamp.lat, lng: stamp.lng }, status: "settled", timestamp: new Date().toISOString(), }; if (settlements.length >= 50000) settlements.shift(); settlements.push(settlement); saveDb("settlements", settlements); return { content: [{ type: "text", text: JSON.stringify(settlement, null, 2) }] }; } case "spoof_check": { const stamp = proofs.find((p: any) => p.id === args.stampId); if (!stamp) return { content: [{ type: "text", text: `Stamp ${args.stampId} not found` }], isError: true }; const idx = proofs.indexOf(stamp); const threats: string[] = []; if (idx > 0) { const prev = proofs[idx - 1]; const dist = haversineKm(prev.lat, prev.lng, stamp.lat, stamp.lng); const timeS = (new Date(stamp.timestamp).getTime() - new Date(prev.timestamp).getTime()) / 1000; if (timeS > 0) { const speedKmH = (dist / timeS) * 3600; if (speedKmH > 1000) threats.push(`Impossible speed: ${Math.round(speedKmH)} km/h between stamps`); } if (timeS === 0 && dist > 0) threats.push("Duplicate timestamp with different location"); } if (Math.abs(stamp.lat) > 90) threats.push("Invalid latitude (>90)"); if (Math.abs(stamp.lng) > 180) threats.push("Invalid longitude (>180)"); return { content: [{ type: "text", text: JSON.stringify({ stampId: stamp.id, spoofDetected: threats.length > 0, threats, confidence: threats.length === 0 ? "HIGH" : "LOW", }, null, 2), }], }; } case "list_stamps": { const limit = args.limit || 20; const recent = [...proofs].reverse().slice(0, limit).map((s: any) => ({ id: s.id, lat: s.lat, lng: s.lng, label: s.label, timestamp: s.timestamp, })); return { content: [{ type: "text", text: JSON.stringify({ total: proofs.length, stamps: recent }, null, 2) }] }; } case "list_settlements": { const limit = args.limit || 20; const recent = [...settlements].reverse().slice(0, limit); return { content: [{ type: "text", text: JSON.stringify({ total: settlements.length, settlements: recent }, null, 2) }] }; } case "robot_status": { const lastStamp = proofs.length > 0 ? proofs[proofs.length - 1] : null; return { content: [{ type: "text", text: JSON.stringify({ robotId: ROBOT_ID, totalStamps: proofs.length, totalMemories: spatialMemories.length, totalSettlements: settlements.length, totalChains: verifications.length, lastLocation: lastStamp ? { lat: lastStamp.lat, lng: lastStamp.lng, timestamp: lastStamp.timestamp } : null, }, null, 2), }], }; } case "distance_between": { const km = haversineKm(args.lat1, args.lng1, args.lat2, args.lng2); return { content: [{ type: "text", text: JSON.stringify({ distanceKm: Math.round(km * 1000) / 1000, distanceM: Math.round(km * 1000) }, null, 2) }] }; } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true }; } } // ─── MCP Server ───────────────────────────────────────────────────────────── function createServer() { const server = new Server( { name: "GridStamp", version: "1.3.0" }, { capabilities: { tools: {} } }, ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const portalKey = portalStore.getStore(); const start = Date.now(); let result: any; try { result = handleTool(name, args || {}); } catch (e: any) { result = { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; } if (portalKey?.apiKey) { const ms = Date.now() - start; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); fetch(`${PORTAL_URL}/portal/log-usage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: portalKey.apiKey, product: "gridstamp", tool: name, response_ms: ms, }), signal: controller.signal, }).catch(() => {}).finally(() => clearTimeout(timer)); } return result; }); return server; } // ─── Start ────────────────────────────────────────────────────────────────── const useHttp = process.argv.includes("--http") || !!process.env.PORT; if (useHttp) { const express = (await import("express")).default; const { SSEServerTransport } = await import("@modelcontextprotocol/sdk/server/sse.js"); const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js"); const app = express(); app.use(express.json()); const sseTransports: Record> = {}; const streamTransports: Record> = {}; // ─── API Key Verification Middleware ───────────────────────────────────── const REQUIRE_AUTH = process.env.REQUIRE_AUTH !== "false"; // default: required async function verifyApiKey(req: any, res: any, next: () => void) { if (!REQUIRE_AUTH) return portalStore.run(undefined, next); // Skip auth for health, server-card, and non-tool endpoints if (req.path === "/health" || req.path.startsWith("/.well-known")) { return portalStore.run(undefined, next); } const apiKey = req.headers["x-api-key"] || req.query.key; if (!apiKey) return res.status(401).json({ error: "API key required. Pass via x-api-key header." }); try { const resp = await fetch(`${PORTAL_URL}/portal/verify-key`, { headers: { "x-api-key": apiKey } }); const data = await resp.json() as { valid: boolean; within_limits?: boolean; error?: string; [k: string]: unknown }; if (!data.valid) return res.status(403).json({ error: data.error || "Invalid API key" }); if (!data.within_limits) return res.status(429).json({ error: "Usage limit exceeded. Upgrade at getbizsuite.com/developers/" }); // Propagate the verified key into AsyncLocalStorage so the MCP handler // can log per-tool usage without extra plumbing. portalStore.run({ apiKey: String(apiKey) }, next); } catch { return res.status(503).json({ error: "Auth service unavailable" }); } } app.use(verifyApiKey); app.get("/health", (_req, res) => { res.json({ status: "ok", robotId: ROBOT_ID, stamps: proofs.length }); }); // Smithery server card app.get("/.well-known/mcp/server-card.json", (_req, res) => { res.json({ serverInfo: { name: "GridStamp", version: "1.1.1" }, authentication: { required: false }, tools: TOOLS, resources: [], prompts: [], }); }); // Streamable HTTP (modern) app.post("/mcp", async (req: any, res: any) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && streamTransports[sessionId]) { await streamTransports[sessionId].handleRequest(req, res, req.body); return; } const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() }); const server = createServer(); await server.connect(transport); await transport.handleRequest(req, res, req.body); const sid = transport.sessionId; if (sid) { streamTransports[sid] = transport; transport.onclose = async () => { delete streamTransports[sid]; }; } }); // SSE fallback app.get("/mcp", async (req: any, res: any) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && streamTransports[sessionId]) { await streamTransports[sessionId].handleRequest(req, res); return; } const transport = new SSEServerTransport("/messages", res); sseTransports[transport.sessionId] = transport; transport.onclose = async () => { delete sseTransports[transport.sessionId]; }; const server = createServer(); await server.connect(transport); }); app.delete("/mcp", async (req: any, res: any) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; if (sessionId && streamTransports[sessionId]) { await streamTransports[sessionId].handleRequest(req, res); delete streamTransports[sessionId]; return; } res.status(404).json({ error: "Session not found" }); }); app.post("/messages", async (req: any, res: any) => { const sessionId = req.query.sessionId as string; if (!sessionId) { res.status(400).json({ error: "Missing sessionId" }); return; } const transport = sseTransports[sessionId]; if (!transport) { res.status(404).json({ error: "Session not found" }); return; } await transport.handlePostMessage(req, res, req.body); }); // Usage telemetry is now emitted from the top-level CallToolRequestSchema // handler via portalStore (AsyncLocalStorage). No wrapper needed here. const port = parseInt(process.env.PORT || "3201", 10); app.listen(port, () => { console.error(`[gridstamp-mcp] HTTP server on port ${port} | Robot: ${ROBOT_ID}`); }); } else { const transport = new StdioServerTransport(); const server = createServer(); await server.connect(transport); console.error(`[gridstamp-mcp] stdio mode | Robot: ${ROBOT_ID}`); }