diff --git a/cvm-server/.env.example b/cvm-server/.env.example new file mode 100644 index 0000000..39886cc --- /dev/null +++ b/cvm-server/.env.example @@ -0,0 +1,17 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SkateSpots CVM Server — Environment Variables +# Copy this to .env and fill in your values +# ───────────────────────────────────────────────────────────────────────────── + +# Your CVM server's Nostr private key (hex format, generated by bootstrap.sh) +# NEVER commit this to git! +CVM_SERVER_NSEC_HEX=your_64_char_hex_private_key_here + +# Nostr relays to query for skate data +NOSTR_RELAYS=wss://goskatespots.nostr1.com,wss://relay.ditto.pub,wss://wotr.relatr.xyz + +# CVM discovery relay — where agents find your server +CVM_RELAY=wss://relay.contextvm.net + +# Log level: info | debug | warn | error +LOG_LEVEL=info diff --git a/cvm-server/DEPLOY.md b/cvm-server/DEPLOY.md new file mode 100644 index 0000000..fb97822 --- /dev/null +++ b/cvm-server/DEPLOY.md @@ -0,0 +1,210 @@ +# SkateSpots CVM Server — VPS Deploy Guide + +Everything below runs on your LNVPS server over SSH. +Shakespeare prepared all the files. You just copy-paste. + +--- + +## 1. SSH into your VPS + +```bash +ssh root@YOUR_VPS_IP +``` + +LNVPS will email/show you the IP and root password after purchase. + +--- + +## 2. Run the bootstrap script (one command) + +This installs Bun, Node, PM2, firewall, fail2ban, creates the `skate` user, +and generates your CVM server Nostr keypair. + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/YOUR_REPO/main/cvm-server/bootstrap.sh) +``` + +**Or** if you haven't pushed to git yet, paste the contents of `bootstrap.sh` +directly into your SSH terminal. + +--- + +## 3. Note down your keys + +The bootstrap script prints something like: + +``` + PRIVATE KEY (hex) — goes in .env as CVM_SERVER_NSEC_HEX + a1b2c3d4e5f6... + + NPUB (bech32) — share this so agents can find you + npub1abc123... +``` + +**Copy both somewhere safe** (password manager, notes). You need the hex key in step 4. + +--- + +## 4. Switch to the skate user and set up the project + +```bash +su - skate +cd ~/skatespots-cvm +``` + +Copy the project files (one of these options): + +**Option A — clone from git (after you push):** +```bash +git clone YOUR_REPO_URL . +``` + +**Option B — create files manually:** +```bash +# Copy server.ts, package.cvm.json contents from your local machine +# Easiest: use scp from your local terminal +# scp -r ./cvm-server/* root@YOUR_VPS_IP:/home/skate/skatespots-cvm/ +``` + +--- + +## 5. Rename package file and install dependencies + +```bash +cd ~/skatespots-cvm +cp package.cvm.json package.json +bun install +``` + +--- + +## 6. Create your .env file + +```bash +cp .env.example .env +nano .env +``` + +Fill in: +```env +CVM_SERVER_NSEC_HEX=PASTE_YOUR_HEX_KEY_FROM_STEP_3_HERE +NOSTR_RELAYS=wss://goskatespots.nostr1.com,wss://relay.ditto.pub,wss://wotr.relatr.xyz +CVM_RELAY=wss://relay.contextvm.net +LOG_LEVEL=info +``` + +Save: `Ctrl+X` → `Y` → `Enter` + +--- + +## 7. Test it runs + +```bash +bun run server.ts +``` + +You should see: +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 🛹 SkateSpots CVM Server +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Data relays : wss://goskatespots.nostr1.com, ... + Server npub : abc123... + ✅ Server running — listening for agents on Nostr + Verify discovery: + nak req -k 11316 -a abc123... wss://relay.contextvm.net +``` + +Press `Ctrl+C` to stop, then move to step 8. + +--- + +## 8. Install as a systemd service (runs forever, survives reboots) + +```bash +# Back to root for this +exit # leaves skate user, back to root + +sudo cp /home/skate/skatespots-cvm/skatespots-cvm.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable skatespots-cvm +sudo systemctl start skatespots-cvm + +# Check it started +sudo systemctl status skatespots-cvm +``` + +You should see `● skatespots-cvm.service — active (running)` ✅ + +--- + +## 9. Watch the live logs + +```bash +sudo journalctl -u skatespots-cvm -f +``` + +--- + +## 10. Verify it's discoverable on Nostr + +Install `nak` (Nostr Army Knife): + +```bash +# Option A — go binary (fastest) +apt install -y golang-go +go install github.com/fiatjaf/nak@latest +export PATH=$PATH:~/go/bin + +# Option B — npx (no install needed) +npx @nostr-army-knife/nak req -k 11316 -a YOUR_SERVER_PUBKEY wss://relay.contextvm.net +``` + +Run the discovery query: +```bash +nak req -k 11316 -a YOUR_SERVER_PUBKEY wss://relay.contextvm.net +``` + +If you get back a JSON event listing your server's tools → **it works!** 🎉 + +Any AI agent anywhere in the world can now call: +- `find_spots` — query skate spots +- `find_events` — query upcoming events +- `find_coaches` — find coaches/filmers/editors +- `find_gear` — browse the marketplace +- `get_community_stats` — live counts + +--- + +## Useful commands after deploy + +```bash +# Check service status +sudo systemctl status skatespots-cvm + +# Restart after code changes +sudo systemctl restart skatespots-cvm + +# Live logs +sudo journalctl -u skatespots-cvm -f + +# Stop +sudo systemctl stop skatespots-cvm + +# Update server.ts and restart +scp server.ts root@YOUR_VPS_IP:/home/skate/skatespots-cvm/ +ssh root@YOUR_VPS_IP "systemctl restart skatespots-cvm" +``` + +--- + +## File layout on the VPS + +``` +/home/skate/skatespots-cvm/ +├── server.ts ← the CVM server (main file) +├── package.json ← dependencies +├── .env ← your secret key + relay config (never commit!) +├── node_modules/ ← installed by bun install +└── skatespots-cvm.service ← systemd unit (copied to /etc/systemd/system/) +``` diff --git a/cvm-server/bootstrap.sh b/cvm-server/bootstrap.sh new file mode 100644 index 0000000..317cfb3 --- /dev/null +++ b/cvm-server/bootstrap.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# SkateSpots CVM Server — Bootstrap Script +# Run this ONCE on a fresh LNVPS Ubuntu 22.04 VPS as root: +# curl -fsSL https://raw.githubusercontent.com/YOUR_REPO/bootstrap.sh | bash +# OR copy-paste the whole file into your SSH session. +# ───────────────────────────────────────────────────────────────────────────── + +set -e # Exit on any error + +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " 🛹 SkateSpots CVM Server Bootstrap" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# ── 1. System update ───────────────────────────────────────────────────────── +echo "" +echo "▶ [1/8] Updating system packages..." +apt-get update -qq +apt-get upgrade -y -qq +apt-get install -y -qq curl git ufw fail2ban htop nano + +# ── 2. Firewall ────────────────────────────────────────────────────────────── +echo "" +echo "▶ [2/8] Configuring firewall..." +ufw --force reset +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp comment 'SSH' +# NOTE: CVM server needs NO open inbound ports — it connects OUT to Nostr relays +ufw --force enable +echo " ✅ Firewall: only SSH allowed inbound" + +# ── 3. fail2ban ────────────────────────────────────────────────────────────── +echo "" +echo "▶ [3/8] Enabling fail2ban..." +systemctl enable fail2ban --quiet +systemctl start fail2ban +echo " ✅ fail2ban active" + +# ── 4. Create skate user ───────────────────────────────────────────────────── +echo "" +echo "▶ [4/8] Creating 'skate' system user..." +if id "skate" &>/dev/null; then + echo " ℹ User 'skate' already exists, skipping" +else + useradd -m -s /bin/bash skate + echo " ✅ User 'skate' created" +fi + +# ── 5. Install Bun ─────────────────────────────────────────────────────────── +echo "" +echo "▶ [5/8] Installing Bun runtime..." +export BUN_INSTALL="/home/skate/.bun" +su - skate -c 'curl -fsSL https://bun.sh/install | bash' 2>/dev/null +# Make bun available system-wide +ln -sf /home/skate/.bun/bin/bun /usr/local/bin/bun 2>/dev/null || true +echo " ✅ Bun installed: $(bun --version 2>/dev/null || echo 'check /home/skate/.bun/bin/bun')" + +# ── 6. Create project directory ────────────────────────────────────────────── +echo "" +echo "▶ [6/8] Setting up project directory..." +mkdir -p /home/skate/skatespots-cvm +chown -R skate:skate /home/skate/skatespots-cvm + +# ── 7. Install PM2 ─────────────────────────────────────────────────────────── +echo "" +echo "▶ [7/8] Installing PM2 process manager..." +# Install Node.js LTS first (needed for PM2) +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - 2>/dev/null +apt-get install -y -qq nodejs +npm install -g pm2 --silent +echo " ✅ PM2 installed: $(pm2 --version)" + +# ── 8. Generate Nostr keypair for the CVM server ───────────────────────────── +echo "" +echo "▶ [8/8] Generating Nostr keypair for CVM server..." +cat > /tmp/gen-keys.ts << 'GENKEYS' +import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; +import { bytesToHex } from "@noble/hashes/utils"; + +const sk = generateSecretKey(); +const pkHex = getPublicKey(sk); +const nsec = nip19.nsecEncode(sk); +const npub = nip19.npubEncode(pkHex); + +console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +console.log(" 🔑 CVM Server Nostr Keys — SAVE THESE NOW"); +console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +console.log(""); +console.log(" PRIVATE KEY (hex) — goes in .env as CVM_SERVER_NSEC_HEX"); +console.log(" ", bytesToHex(sk)); +console.log(""); +console.log(" NSEC (bech32) — backup this too"); +console.log(" ", nsec); +console.log(""); +console.log(" PUBLIC KEY (hex) — your server's identity on Nostr"); +console.log(" ", pkHex); +console.log(""); +console.log(" NPUB (bech32) — share this so agents can find you"); +console.log(" ", npub); +console.log(""); +console.log(" ⚠️ NEVER share the private key / nsec with anyone!"); +console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); +GENKEYS + +# Run key generation using bun with a temp nostr-tools install +cd /tmp +su - skate -c "cd /tmp && /home/skate/.bun/bin/bun add nostr-tools @noble/hashes 2>/dev/null && /home/skate/.bun/bin/bun run /tmp/gen-keys.ts" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " ✅ Bootstrap complete!" +echo "" +echo " Next steps:" +echo " 1. Copy the PRIVATE KEY (hex) printed above" +echo " 2. cd /home/skate/skatespots-cvm" +echo " 3. Run: nano .env (paste your key there)" +echo " 4. Run: bun run setup.sh (installs the server)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/cvm-server/package.cvm.json b/cvm-server/package.cvm.json new file mode 100644 index 0000000..d04042a --- /dev/null +++ b/cvm-server/package.cvm.json @@ -0,0 +1,16 @@ +{ + "name": "skatespots-cvm-server", + "version": "1.0.0", + "description": "SkateSpots CVM (ContextVM) server — structured MCP tools over Nostr", + "scripts": { + "start": "bun run server.ts", + "dev": "bun --watch run server.ts" + }, + "dependencies": { + "@contextvm/sdk": "latest", + "@modelcontextprotocol/sdk": "latest", + "@noble/hashes": "latest", + "nostr-tools": "latest", + "zod": "latest" + } +} diff --git a/cvm-server/server.ts b/cvm-server/server.ts new file mode 100644 index 0000000..cdb9300 --- /dev/null +++ b/cvm-server/server.ts @@ -0,0 +1,597 @@ +/** + * 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 } 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.net"; + +// ─── Nostr data pool ───────────────────────────────────────────────────────── +// Used to query skate data from the community relays + +const dataPool = new SimplePool(); + +async function queryNostr(filters: Filter[], timeoutMs = 6000): Promise { + return new Promise((resolve) => { + const events: any[] = []; + const sub = dataPool.subscribeMany(NOSTR_RELAYS, filters, { + onevent(e: any) { + events.push(e); + }, + oneose() { + sub.close(); + resolve(events); + }, + }); + setTimeout(() => { + sub.close(); + resolve(events); + }, timeoutMs); + }); +} + +// ─── 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 }) => { + const filters: Filter[] = [{ kinds: [30818], limit: 500 }]; + + const raw = await queryNostr(filters); + let spots = deduplicateAddressable(raw); + + if (difficulty && difficulty !== "all") { + spots = spots.filter( + (e) => getTag(e, "difficulty")?.toLowerCase() === difficulty + ); + } + + if (city) { + const q = city.toLowerCase(); + spots = spots.filter( + (e) => + (getTag(e, "city") ?? "").toLowerCase().includes(q) || + (getTag(e, "location") ?? "").toLowerCase().includes(q) || + 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) + ); + } + + if (features && features.length > 0) { + spots = spots.filter((e) => { + const spotFeatures = getTags(e, "feature").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) => + (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) => ({ + name: getTag(e, "name") ?? "Unnamed Spot", + description: e.content.slice(0, 300), + city: getTag(e, "city") ?? getTag(e, "c"), + country: getTag(e, "country"), + difficulty: getTag(e, "difficulty"), + features: getTags(e, "feature"), + geohash: getTag(e, "g"), + image: getTag(e, "image") ?? getTag(e, "thumb"), + 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); + events = events.filter(() => { + // start tag is unix timestamp for kind 31923 + return true; // already filtered by since: now above + }); + void 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 skateboarding coaches, filmers, and video editors in The Lab. Filter by role, skills, or location.", + inputSchema: { + role: z + .enum(["coach", "filmer", "editor", "any"]) + .optional() + .default("any") + .describe("coach = skateboarding instructor, filmer = videographer, editor = video editor"), + skills: z + .array(z.string()) + .optional() + .describe( + "Skill specializations, e.g. ['kickflip', 'grind', 'vert', 'bowl', 'street', 'film', 'edit']" + ), + location: z.string().optional().describe("City or country"), + limit: z.number().min(1).max(30).optional().default(10), + }, + }, + async ({ role, skills, location, limit }) => { + const kindMap: Record = { + coach: [35001], + filmer: [35002], + editor: [35003], + any: [35001, 35002, 35003], + }; + const kinds = kindMap[role ?? "any"]; + + const raw = await queryNostr([{ kinds, limit: 500 }]); + let profiles = deduplicateAddressable(raw); + + if (skills && skills.length > 0) { + profiles = profiles.filter((e) => { + const profileSkills = 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 roleLabel = (kind: number) => + kind === 35001 ? "coach" : kind === 35002 ? "filmer" : "editor"; + + const result = profiles.map((e) => ({ + role: roleLabel(e.kind), + pubkey: e.pubkey, + bio: e.content.slice(0, 300), + location: getTag(e, "location"), + skills: getTags(e, "skill"), + 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: get_community_stats ──────────────────────────────────────────────── + +mcpServer.registerTool( + "get_community_stats", + { + description: + "Get live SkateSpots community statistics: total spots, upcoming events, coaches, filmers, editors, and gear listings across all connected relays.", + inputSchema: {}, + }, + async () => { + const [spots, events, coaches, filmers, editors, gear] = await Promise.all([ + queryNostr([{ kinds: [30818], limit: 2000 }]), + queryNostr([{ kinds: [31923], limit: 2000, since: Math.floor(Date.now() / 1000) }]), + queryNostr([{ kinds: [35001], limit: 2000 }]), + queryNostr([{ kinds: [35002], limit: 2000 }]), + queryNostr([{ kinds: [35003], limit: 2000 }]), + queryNostr([{ kinds: [30402], "#t": ["skateboarding"], limit: 2000 }]), + ]); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + spots: deduplicateAddressable(spots).length, + upcoming_events: deduplicateAddressable(events).length, + coaches: deduplicateAddressable(coaches).length, + filmers: deduplicateAddressable(filmers).length, + editors: deduplicateAddressable(editors).length, + gear_listings: deduplicateAddressable(gear).length, + 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, + 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", + }, + }); + + await mcpServer.connect(transport); + + 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); +}); diff --git a/cvm-server/skatespots-cvm.service b/cvm-server/skatespots-cvm.service new file mode 100644 index 0000000..0831de4 --- /dev/null +++ b/cvm-server/skatespots-cvm.service @@ -0,0 +1,30 @@ +# ───────────────────────────────────────────────────────────────────────────── +# systemd unit file for SkateSpots CVM Server +# Install: sudo cp skatespots-cvm.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now skatespots-cvm +# ───────────────────────────────────────────────────────────────────────────── + +[Unit] +Description=SkateSpots CVM Server (ContextVM MCP over Nostr) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=skate +WorkingDirectory=/home/skate/skatespots-cvm +EnvironmentFile=/home/skate/skatespots-cvm/.env +ExecStart=/home/skate/.bun/bin/bun run server.ts +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=skatespots-cvm + +# Basic hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target