import { WebSocket } from "ws"; // @ts-ignore globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket; /** * SkateSpots CVM Server * ───────────────────────────────────────────────────────────────────────────── * Exposes structured MCP tools over Nostr (ContextVM protocol). * AI agents discover this server on Nostr and call its tools to query * skateboarding spots, events, coaches, filmers, and gear listings. * * Tools: * • find_spots — filter by city, difficulty, features * • find_events — upcoming skate events by location/date * • find_coaches — coaches, filmers, editors by skill/location * • find_gear — marketplace listings by category/condition * • get_community_stats — live counts of all community content * * Deploy: bun run server.ts (or via PM2, see README) */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { NostrServerTransport, PrivateKeySigner, ApplesauceRelayPool, } from "@contextvm/sdk"; import { z } from "zod"; import { SimplePool, verifyEvent } from "nostr-tools"; import type { Filter } from "nostr-tools"; // ─── Config ────────────────────────────────────────────────────────────────── const NSEC_HEX = process.env.CVM_SERVER_NSEC_HEX; if (!NSEC_HEX) { console.error("❌ Missing CVM_SERVER_NSEC_HEX in environment. Check your .env file."); process.exit(1); } const NOSTR_RELAYS = (process.env.NOSTR_RELAYS || "wss://goskatespots.nostr1.com") .split(",") .map((r) => r.trim()) .filter(Boolean); const CVM_RELAY = process.env.CVM_RELAY || "wss://relay.contextvm.org"; // Transport log level. Previously documented but never consumed (always the // SDK default); now actually wired through to the transport. type SdkLogLevel = "debug" | "info" | "warn" | "error" | "silent"; const LOG_LEVEL = (process.env.LOG_LEVEL || "info") as SdkLogLevel; // How often to re-publish the server's discovery announcements. The SDK only // announces on start(), so without this a long-running server looks "stale" to // recency-based health checks. Set 0 to disable. const HEARTBEAT_MS = Number(process.env.CVM_HEARTBEAT_MS || 15 * 60 * 1000); // ─── Rate limiting & load protection ───────────────────────────────────────── // This is a public, read-only query endpoint, so this is load protection (keep // the community relays and this box healthy), not a security boundary. Inbound // middleware cannot return a JSON-RPC error on this transport, so a caller over // budget is simply dropped (logged) and will time out. const RATE_WINDOW_MS = Number(process.env.CVM_RATE_WINDOW_MS || 5 * 60 * 1000); const RATE_LIMIT = Number(process.env.CVM_RATE_LIMIT || 30); // requests / window / pubkey const MAX_INFLIGHT_PER_CLIENT = Number(process.env.CVM_MAX_INFLIGHT || 2); const MAX_UPSTREAM = Number(process.env.CVM_MAX_UPSTREAM || 8); const QUERY_CACHE_TTL_MS = Number(process.env.CVM_QUERY_CACHE_MS || 30_000); const requestLog = new Map(); const inFlightByClient = new Map(); const queryCache = new Map(); // Global semaphore capping concurrent upstream relay queries. let upstreamActive = 0; const upstreamQueue: Array<() => void> = []; async function withUpstreamSlot(fn: () => Promise): Promise { if (upstreamActive >= MAX_UPSTREAM) { await new Promise((resolve) => upstreamQueue.push(resolve)); } upstreamActive += 1; try { return await fn(); } finally { upstreamActive -= 1; upstreamQueue.shift()?.(); } } // Drop stale rate-limit entries and expired cache rows so the maps stay bounded. const janitor = setInterval(() => { const cutoff = Date.now() - RATE_WINDOW_MS; for (const [pk, times] of requestLog) { const kept = times.filter((t) => t > cutoff); if (kept.length) requestLog.set(pk, kept); else requestLog.delete(pk); } const cacheCutoff = Date.now() - QUERY_CACHE_TTL_MS; for (const [key, row] of queryCache) { if (row.at < cacheCutoff) queryCache.delete(key); } }, 60_000); (janitor as { unref?: () => void }).unref?.(); // ─── Nostr data pool ───────────────────────────────────────────────────────── // Used to query skate data from the community relays const dataPool = new SimplePool(); async function queryNostr(filters: Filter[], timeoutMs = 8000): Promise { // Short-TTL cache keyed by the filter set — identical queries (e.g. a burst of // get_community_stats) reuse the last result instead of hitting the relays. const cacheKey = JSON.stringify(filters); const cached = queryCache.get(cacheKey); if (cached && Date.now() - cached.at < QUERY_CACHE_TTL_MS) return cached.events; return withUpstreamSlot(async () => { // nostr-tools v2: use querySync which handles eose cleanly const allEvents: any[] = []; await Promise.all( filters.map(async (filter) => { try { const events = await dataPool.querySync(NOSTR_RELAYS, filter, { maxWait: timeoutMs, }); allEvents.push(...events); } catch (err) { console.error("[queryNostr] error:", err); } }) ); // Audit F2 (server-side): only serve signature-verified events to agents. // Unverified community-relay data must never become agent-trusted ground // truth (forged coach profiles / listings would flow straight into agents). const verified = allEvents.filter((e) => { try { return verifyEvent(e); } catch { return false; } }); queryCache.set(cacheKey, { at: Date.now(), events: verified }); return verified; }); } // ─── Helpers ───────────────────────────────────────────────────────────────── function getTag(event: any, name: string): string | undefined { return event.tags.find((t: string[]) => t[0] === name)?.[1]; } function getTags(event: any, name: string): string[] { return event.tags .filter((t: string[]) => t[0] === name) .map((t: string[]) => t[1]) .filter(Boolean); } /** Deduplicate addressable events (kind 30xxx) — keep newest per pubkey+kind+d */ function deduplicateAddressable(events: any[]): any[] { const seen = new Map(); for (const e of events) { const d = getTag(e, "d") ?? ""; const key = `${e.pubkey}:${e.kind}:${d}`; const existing = seen.get(key); if (!existing || e.created_at > existing.created_at) { seen.set(key, e); } } return Array.from(seen.values()); } function buildNaddr(event: any): string { return `${event.kind}:${event.pubkey}:${getTag(event, "d") ?? ""}`; } // ─── MCP Server ────────────────────────────────────────────────────────────── const mcpServer = new McpServer({ name: "SkateSpots", version: "1.0.0", }); // ── Tool: find_spots ───────────────────────────────────────────────────────── mcpServer.registerTool( "find_spots", { description: "Find skateboarding spots worldwide. Filter by city, country, difficulty level, or specific features like rails, ledges, stairs, manual pads, bowls, halfpipes. Returns spot names, locations, features, and deep-link identifiers.", inputSchema: { city: z.string().optional().describe("City name, e.g. 'Barcelona', 'Tokyo'"), country: z.string().optional().describe("Country name or code, e.g. 'Spain', 'ES'"), difficulty: z .enum(["beginner", "intermediate", "advanced", "all"]) .optional() .default("all") .describe("Skill level required for the spot"), features: z .array(z.string()) .optional() .describe( "Required features, e.g. ['rails', 'ledges', 'stairs', 'manual pad', 'bowl', 'halfpipe']" ), search: z .string() .optional() .describe("Free text search in spot name or description"), limit: z.number().min(1).max(50).optional().default(10), }, }, async ({ city, country, difficulty, features, search, limit }) => { // Query all kind 30818 events; also try with #t filter for relays that support it const raw = await queryNostr([{ kinds: [30818], limit: 500 }]); let spots = deduplicateAddressable(raw); // Only keep valid skate spots (must have 't: skatespot' tag — same rule as the app) spots = spots.filter((e) => e.tags.some((t: string[]) => t[0] === "t" && t[1] === "skatespot") ); if (difficulty && difficulty !== "all") { spots = spots.filter( (e) => getTag(e, "difficulty")?.toLowerCase() === difficulty ); } if (city) { const q = city.toLowerCase(); spots = spots.filter( (e) => // 'city' dedicated tag (if present) (getTag(e, "city") ?? "").toLowerCase().includes(q) || // 'location' free-text field (most spots have this) (getTag(e, "location") ?? "").toLowerCase().includes(q) || // 'title' tag (spot name may include city) (getTag(e, "title") ?? "").toLowerCase().includes(q) || // Event content / description e.content.toLowerCase().includes(q) ); } if (country) { const q = country.toLowerCase(); spots = spots.filter( (e) => (getTag(e, "country") ?? "").toLowerCase().includes(q) || (getTag(e, "location") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } if (features && features.length > 0) { spots = spots.filter((e) => { // Check 'feature' tags AND generic 't' tags (app uses 't' for features) const spotFeatures = [ ...getTags(e, "feature"), ...getTags(e, "t"), ].map((f) => f.toLowerCase()); const spotContent = e.content.toLowerCase(); return features.some( (f) => spotFeatures.some((sf) => sf.includes(f.toLowerCase())) || spotContent.includes(f.toLowerCase()) ); }); } if (search) { const q = search.toLowerCase(); spots = spots.filter( (e) => // App publishes 'title' tag (not 'name') — check both for compatibility (getTag(e, "title") ?? "").toLowerCase().includes(q) || (getTag(e, "name") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } spots.sort((a, b) => b.created_at - a.created_at); spots = spots.slice(0, limit!); const result = spots.map((e) => ({ // App uses 'title' tag; fall back to 'name' for legacy events name: getTag(e, "title") ?? getTag(e, "name") ?? "Unnamed Spot", description: e.content.slice(0, 300), city: getTag(e, "city") ?? getTag(e, "c"), country: getTag(e, "country"), location: getTag(e, "location"), difficulty: getTag(e, "difficulty"), category: getTag(e, "category"), // App uses 't' tags for features (street, park, rails, etc.) features: getTags(e, "t").filter((t) => t !== "skatespot"), geohash: getTag(e, "g"), // Support both 'image' (legacy) and 'imeta' (NIP-92, new) image: getTag(e, "image") ?? (() => { const imeta = e.tags.find((t: string[]) => t[0] === "imeta"); const urlPart = imeta?.find((p: string) => p.startsWith("url ")); return urlPart ? urlPart.replace("url ", "") : undefined; })(), author_pubkey: e.pubkey, naddr: buildNaddr(e), url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e, "d") ?? ""}`, created_at: new Date(e.created_at * 1000).toISOString(), })); return { content: [ { type: "text" as const, text: JSON.stringify({ count: result.length, spots: result }, null, 2), }, ], }; } ); // ── Tool: find_events ──────────────────────────────────────────────────────── mcpServer.registerTool( "find_events", { description: "Find upcoming skateboarding events — competitions, meetups, sessions, jams, contests. Filter by location or date range. Only returns future events by default.", inputSchema: { city: z.string().optional().describe("City or location keyword"), country: z.string().optional(), event_type: z .string() .optional() .describe("Type keyword: competition, meetup, session, contest, jam"), from_date: z .string() .optional() .describe("ISO date, e.g. '2025-06-01'. Defaults to today."), to_date: z.string().optional().describe("ISO date, e.g. '2025-12-31'"), limit: z.number().min(1).max(50).optional().default(10), }, }, async ({ city, country, event_type, from_date, to_date, limit }) => { const now = Math.floor(Date.now() / 1000); const filters: Filter[] = [{ kinds: [31923], limit: 500, since: now }]; const raw = await queryNostr(filters); let events = deduplicateAddressable(raw); if (city) { const q = city.toLowerCase(); events = events.filter( (e) => (getTag(e, "location") ?? "").toLowerCase().includes(q) || (getTag(e, "city") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } if (country) { const q = country.toLowerCase(); events = events.filter((e) => (getTag(e, "location") ?? "").toLowerCase().includes(q) ); } if (event_type) { const q = event_type.toLowerCase(); events = events.filter( (e) => getTags(e, "t").some((t) => t.toLowerCase().includes(q)) || (getTag(e, "title") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } if (from_date) { const fromTs = Math.floor(new Date(from_date).getTime() / 1000); if (Number.isFinite(fromTs)) { // kind 31923 stores its start as a unix timestamp in the `start` tag. events = events.filter((e) => { const start = parseInt(getTag(e, "start") ?? "0"); return !start || start >= fromTs; }); } } if (to_date) { const toTs = Math.floor(new Date(to_date).getTime() / 1000); events = events.filter((e) => { const start = parseInt(getTag(e, "start") ?? "0"); return !start || start <= toTs; }); } // Sort by start date ascending events.sort((a, b) => { const startA = parseInt(getTag(a, "start") ?? "0"); const startB = parseInt(getTag(b, "start") ?? "0"); return startA - startB; }); events = events.slice(0, limit!); const result = events.map((e) => ({ title: getTag(e, "title") ?? "Untitled Event", description: e.content.slice(0, 300), location: getTag(e, "location"), start: getTag(e, "start") ? new Date(parseInt(getTag(e, "start")!) * 1000).toISOString() : null, end: getTag(e, "end") ? new Date(parseInt(getTag(e, "end")!) * 1000).toISOString() : null, type: getTags(e, "t"), image: getTag(e, "image"), author_pubkey: e.pubkey, naddr: buildNaddr(e), url: `https://skate-spots.org/event/${e.pubkey}/${getTag(e, "d") ?? ""}`, })); return { content: [ { type: "text" as const, text: JSON.stringify({ count: result.length, events: result }, null, 2), }, ], }; } ); // ── Tool: find_coaches ─────────────────────────────────────────────────────── mcpServer.registerTool( "find_coaches", { description: "Find any Lab craftsperson — coach, filmer, editor, photographer, builder, artist, graffiti artist, tattoo artist, trainer/physio, musician, judge, event organizer or barber. Filter by role, skills, or location.", inputSchema: { role: z .enum([ "coach", "filmer", "editor", "photographer", "builder", "artist", "graffiti", "tattoo", "trainer", "musician", "judge", "organizer", "barber", "any", ]) .optional() .default("any") .describe("Lab role; 'any' searches all of them"), skills: z .array(z.string()) .optional() .describe( "Skill / `t`-tag keywords, e.g. ['kickflip', 'vert', 'film', 'edit', 'fade']" ), location: z.string().optional().describe("City or country"), limit: z.number().min(1).max(30).optional().default(10), }, }, async ({ role, skills, location, limit }) => { // Must match the app's Lab coverage (kinds 35001..35014, minus 35006 = crews). const ROLE_KIND: Record = { coach: 35001, filmer: 35002, editor: 35003, photographer: 35004, builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009, trainer: 35010, musician: 35011, judge: 35012, organizer: 35013, barber: 35014, }; const ALL_KINDS = Object.values(ROLE_KIND); const KIND_ROLE: Record = Object.fromEntries( Object.entries(ROLE_KIND).map(([r, k]) => [k, r]) ); const kinds = role && role !== "any" ? [ROLE_KIND[role]] : ALL_KINDS; const SELF_TAGS = new Set([ "skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber", ]); const raw = await queryNostr([{ kinds, limit: 500 }]); let profiles = deduplicateAddressable(raw); if (skills && skills.length > 0) { profiles = profiles.filter((e) => { // Crafts store skills as `t` tags (parseCraftBase); some older events // used `skill` tags. Check both, plus the bio. const profileSkills = [...getTags(e, "t"), ...getTags(e, "skill")].map((s) => s.toLowerCase() ); const bio = e.content.toLowerCase(); return skills.some( (s) => profileSkills.some((ps) => ps.includes(s.toLowerCase())) || bio.includes(s.toLowerCase()) ); }); } if (location) { const q = location.toLowerCase(); profiles = profiles.filter( (e) => (getTag(e, "location") ?? "").toLowerCase().includes(q) || (getTag(e, "city") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } profiles = profiles.slice(0, limit!); const result = profiles.map((e) => ({ role: KIND_ROLE[e.kind] ?? "lab", pubkey: e.pubkey, bio: e.content.slice(0, 300), location: getTag(e, "location"), skills: [...getTags(e, "t"), ...getTags(e, "skill")].filter((t) => !SELF_TAGS.has(t)), experience: getTag(e, "experience"), lightning_address: getTag(e, "lud16") ?? getTag(e, "contact"), naddr: buildNaddr(e), url: `https://skate-spots.org/coach/${e.pubkey}/${getTag(e, "d") ?? ""}`, })); return { content: [ { type: "text" as const, text: JSON.stringify({ count: result.length, profiles: result }, null, 2), }, ], }; } ); // ── Tool: find_gear ────────────────────────────────────────────────────────── mcpServer.registerTool( "find_gear", { description: "Find used skate gear for sale or trade on the SkateSpots marketplace. Filter by category, condition, price, or keyword.", inputSchema: { category: z .string() .optional() .describe( "Category keyword: deck, trucks, wheels, shoes, bearings, complete, helmet, pads, apparel" ), condition: z .enum(["new", "like_new", "good", "fair", "any"]) .optional() .default("any"), max_price_sats: z .number() .optional() .describe("Maximum price in satoshis (for sats-priced listings)"), search: z.string().optional().describe("Brand, model, or keyword search"), limit: z.number().min(1).max(50).optional().default(10), }, }, async ({ category, condition, max_price_sats, search, limit }) => { const filters: Filter[] = [ { kinds: [30402], limit: 500, "#t": ["skateboarding"] }, ]; const raw = await queryNostr(filters); let listings = deduplicateAddressable(raw); if (category) { const q = category.toLowerCase(); listings = listings.filter( (e) => getTags(e, "t").some((t) => t.toLowerCase().includes(q)) || (getTag(e, "title") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } if (condition && condition !== "any") { listings = listings.filter((e) => (getTag(e, "condition") ?? "") .toLowerCase() .replace(/\s+/g, "_") .includes(condition) ); } if (max_price_sats) { listings = listings.filter((e) => { const price = parseFloat(getTag(e, "price") ?? ""); const currency = (getTag(e, "currency") ?? "").toLowerCase(); if (!isNaN(price) && (currency === "sat" || currency === "sats")) { return price <= max_price_sats; } return true; // don't filter non-sats listings }); } if (search) { const q = search.toLowerCase(); listings = listings.filter( (e) => (getTag(e, "title") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); } listings.sort((a, b) => b.created_at - a.created_at); listings = listings.slice(0, limit!); const result = listings.map((e) => ({ title: getTag(e, "title") ?? "Untitled Listing", description: e.content.slice(0, 300), price: getTag(e, "price"), currency: getTag(e, "currency"), condition: getTag(e, "condition"), categories: getTags(e, "t"), image: getTag(e, "image") ?? getTag(e, "thumb"), seller_pubkey: e.pubkey, naddr: buildNaddr(e), created_at: new Date(e.created_at * 1000).toISOString(), })); return { content: [ { type: "text" as const, text: JSON.stringify( { count: result.length, listings: result }, null, 2 ), }, ], }; } ); // ── Tool: find_crews ───────────────────────────────────────────────────────── mcpServer.registerTool( "find_crews", { description: "Find skate crews / collectives (community identity, map-anchored) by name, city or scene focus.", inputSchema: { search: z.string().optional().describe("Name, city or focus keyword"), location: z.string().optional().describe("City or country"), limit: z.number().min(1).max(50).optional().default(10), }, }, async ({ search, location, limit }) => { const raw = await queryNostr([{ kinds: [35006], limit: 500 }]); let crews = deduplicateAddressable(raw).filter((e) => getTags(e, "t").some((t) => t === "skatecrew") ); if (search) { const q = search.toLowerCase(); crews = crews.filter( (e) => (getTag(e, "title") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) || (getTag(e, "location") ?? "").toLowerCase().includes(q) ); } if (location) { const q = location.toLowerCase(); crews = crews.filter((e) => (getTag(e, "location") ?? "").toLowerCase().includes(q)); } crews.sort((a, b) => b.created_at - a.created_at); crews = crews.slice(0, limit!); const result = crews.map((e) => ({ name: getTag(e, "title") ?? "Untitled Crew", about: e.content.slice(0, 300), location: getTag(e, "location"), focus: getTags(e, "t").find((t) => t.startsWith("crew-focus-"))?.replace("crew-focus-", ""), members: getTags(e, "p").length, website: getTag(e, "website"), founder_pubkey: e.pubkey, naddr: buildNaddr(e), url: `https://skate-spots.org/crew/${e.pubkey}/${getTag(e, "d") ?? ""}`, })); return { content: [ { type: "text" as const, text: JSON.stringify({ count: result.length, crews: result }, null, 2) }, ], }; } ); // ── Tool: find_shops ───────────────────────────────────────────────────────── mcpServer.registerTool( "find_shops", { description: "Find skate shops (NIP-15 stalls tagged as skateshops) by name, city or keyword.", inputSchema: { search: z.string().optional(), location: z.string().optional(), limit: z.number().min(1).max(50).optional().default(10), }, }, async ({ search, location, limit }) => { const raw = await queryNostr([{ kinds: [30017], limit: 500 }]); let shops = deduplicateAddressable(raw).filter((e) => getTags(e, "t").some((t) => t === "skateshop") ); if (search) { const q = search.toLowerCase(); shops = shops.filter( (e) => (getTag(e, "title") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) || (getTag(e, "location") ?? "").toLowerCase().includes(q) ); } if (location) { const q = location.toLowerCase(); shops = shops.filter((e) => (getTag(e, "location") ?? "").toLowerCase().includes(q)); } shops.sort((a, b) => b.created_at - a.created_at); shops = shops.slice(0, limit!); const result = shops.map((e) => ({ name: getTag(e, "title") ?? "Untitled Shop", about: e.content.slice(0, 300), location: getTag(e, "location"), website: getTag(e, "website"), nip05: getTag(e, "nip05"), owner_pubkey: e.pubkey, naddr: buildNaddr(e), url: `https://skate-spots.org/shop/${e.pubkey}/${getTag(e, "d") ?? ""}`, })); return { content: [ { type: "text" as const, text: JSON.stringify({ count: result.length, shops: result }, null, 2) }, ], }; } ); // ── Tool: find_tours ───────────────────────────────────────────────────────── mcpServer.registerTool( "find_tours", { description: "Find local skate tours — guides showing hidden spots. Filter by name or location.", inputSchema: { search: z.string().optional(), location: z.string().optional(), limit: z.number().min(1).max(50).optional().default(10), }, }, async ({ search, location, limit }) => { const raw = await queryNostr([{ kinds: [30823], limit: 500 }]); let tours = deduplicateAddressable(raw); if (search) { const q = search.toLowerCase(); tours = tours.filter( (e) => (getTag(e, "title") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) || (getTag(e, "location") ?? "").toLowerCase().includes(q) ); } if (location) { const q = location.toLowerCase(); tours = tours.filter((e) => (getTag(e, "location") ?? "").toLowerCase().includes(q)); } tours.sort((a, b) => b.created_at - a.created_at); tours = tours.slice(0, limit!); const result = tours.map((e) => ({ title: getTag(e, "title") ?? "Untitled Tour", about: e.content.slice(0, 300), location: getTag(e, "location"), price: getTag(e, "price"), currency: getTag(e, "currency"), guide_pubkey: e.pubkey, naddr: buildNaddr(e), url: `https://skate-spots.org/tour/${e.pubkey}/${getTag(e, "d") ?? ""}`, })); return { content: [ { type: "text" as const, text: JSON.stringify({ count: result.length, tours: result }, null, 2) }, ], }; } ); // ── Tool: get_community_stats ──────────────────────────────────────────────── mcpServer.registerTool( "get_community_stats", { description: "Get live SkateSpots community statistics: spots, upcoming events, every Lab craft, shops, crews, tours and gear listings across all connected relays.", inputSchema: {}, }, async () => { const [ spots, events, shops, crews, tours, gear, coaches, filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers, musicians, judges, organizers, barbers, ] = await Promise.all([ queryNostr([{ kinds: [30818], limit: 2000 }]), queryNostr([{ kinds: [31923], limit: 2000, since: Math.floor(Date.now() / 1000) }]), queryNostr([{ kinds: [30017], limit: 2000 }]), queryNostr([{ kinds: [35006], limit: 2000 }]), queryNostr([{ kinds: [30823], limit: 2000 }]), queryNostr([{ kinds: [30402], "#t": ["skateboarding"], limit: 2000 }]), queryNostr([{ kinds: [35001], limit: 2000 }]), queryNostr([{ kinds: [35002], limit: 2000 }]), queryNostr([{ kinds: [35003], limit: 2000 }]), queryNostr([{ kinds: [35004], limit: 2000 }]), queryNostr([{ kinds: [35005], limit: 2000 }]), queryNostr([{ kinds: [35007], limit: 2000 }]), queryNostr([{ kinds: [35008], limit: 2000 }]), queryNostr([{ kinds: [35009], limit: 2000 }]), queryNostr([{ kinds: [35010], limit: 2000 }]), queryNostr([{ kinds: [35011], limit: 2000 }]), queryNostr([{ kinds: [35012], limit: 2000 }]), queryNostr([{ kinds: [35013], limit: 2000 }]), queryNostr([{ kinds: [35014], limit: 2000 }]), ]); const n = (rows: any[]) => deduplicateAddressable(rows).length; const labTotal = n(coaches) + n(filmers) + n(editors) + n(photographers) + n(builders) + n(artists) + n(graffiti) + n(tattoo) + n(trainers) + n(musicians) + n(judges) + n(organizers) + n(barbers); return { content: [ { type: "text" as const, text: JSON.stringify( { spots: deduplicateAddressable(spots).filter((e) => e.tags.some((t) => t[0] === "t" && t[1] === "skatespot") ).length, upcoming_events: n(events), shops: deduplicateAddressable(shops).filter((e) => e.tags.some((t) => t[0] === "t" && t[1] === "skateshop") ).length, crews: deduplicateAddressable(crews).filter((e) => e.tags.some((t) => t[0] === "t" && t[1] === "skatecrew") ).length, tours: n(tours), gear_listings: n(gear), coaches: n(coaches), filmers: n(filmers), editors: n(editors), photographers: n(photographers), builders: n(builders), artists: n(artists), graffiti: n(graffiti), tattoo: n(tattoo), trainers: n(trainers), musicians: n(musicians), judges: n(judges), organizers: n(organizers), barbers: n(barbers), lab_total: labTotal, relays_queried: NOSTR_RELAYS, queried_at: new Date().toISOString(), }, null, 2 ), }, ], }; } ); // ─── Start ─────────────────────────────────────────────────────────────────── async function main() { console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(" 🛹 SkateSpots CVM Server"); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(" Data relays :", NOSTR_RELAYS.join(", ")); console.log(" CVM relay :", CVM_RELAY); const signer = new PrivateKeySigner(NSEC_HEX!); const pubkey = await signer.getPublicKey(); console.log(" Server npub :", pubkey); // All relays combined for the CVM transport (discovery + data) const allRelays = [CVM_RELAY, ...NOSTR_RELAYS]; const cvmRelayPool = new ApplesauceRelayPool(allRelays); const transport = new NostrServerTransport({ signer, relayHandler: cvmRelayPool, isAnnouncedServer: true, logLevel: LOG_LEVEL, serverInfo: { name: "SkateSpots Data Server", about: "Structured query access to the global SkateSpots database on Nostr. Find skate spots, events, coaches, filmers, video editors, and gear listings.", picture: "https://skate-spots.org/images/logo.png", website: "https://skate-spots.org", }, }); // Per-pubkey throttle: sliding window + in-flight cap. Over-budget callers are // dropped (not forwarded) and time out — the transport can't return an error. transport.addInboundMiddleware(async (message, ctx, forward) => { const pk = ctx.clientPubkey; const now = Date.now(); const recent = (requestLog.get(pk) ?? []).filter((t) => now - t < RATE_WINDOW_MS); const inflight = inFlightByClient.get(pk) ?? 0; if (recent.length >= RATE_LIMIT || inflight >= MAX_INFLIGHT_PER_CLIENT) { console.warn( `[ratelimit] dropped request from ${pk.slice(0, 12)}… ` + `(window ${recent.length}/${RATE_LIMIT}, in-flight ${inflight}/${MAX_INFLIGHT_PER_CLIENT})` ); return; } recent.push(now); requestLog.set(pk, recent); inFlightByClient.set(pk, inflight + 1); try { await forward(message); } finally { const left = (inFlightByClient.get(pk) ?? 1) - 1; if (left > 0) inFlightByClient.set(pk, left); else inFlightByClient.delete(pk); } }); await mcpServer.connect(transport); // ─── Heartbeat ───────────────────────────────────────────────────────────── // Re-publish the server announcements periodically so recency-based health // checks stay green for a long-running process. The announcement manager is a // typed-private SDK member, so we reach it defensively: if a future SDK // renames it we log and skip (health checks then fall back to restart-cadence // freshness) instead of crashing the server. const announcementManager = ( transport as unknown as { announcementManager?: { publishPublicAnnouncements?: () => Promise }; } ).announcementManager; if (HEARTBEAT_MS > 0 && typeof announcementManager?.publishPublicAnnouncements === "function") { const heartbeat = setInterval(() => { announcementManager.publishPublicAnnouncements!().catch((e) => console.error("[heartbeat] re-announce failed:", e) ); }, HEARTBEAT_MS); (heartbeat as { unref?: () => void }).unref?.(); console.log(` Heartbeat : every ${Math.round(HEARTBEAT_MS / 60000)} min`); } else if (HEARTBEAT_MS > 0) { console.warn(" ⚠️ Heartbeat unavailable — SDK internals changed; health checks may show stale."); } console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(" ✅ Server running — listening for agents on Nostr"); console.log(""); console.log(" Verify discovery:"); console.log(` nak req -k 11316 -a ${pubkey} ${CVM_RELAY}`); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); } main().catch((e) => { console.error("❌ Fatal:", e); process.exit(1); });