feat: add SkateSpots CVM server — MCP tools over Nostr
Adds cvm-server/ directory with complete ContextVM server implementation: - server.ts: Full MCP server with 5 tools (find_spots, find_events, find_coaches, find_gear, get_community_stats) using @contextvm/sdk - bootstrap.sh: One-command VPS setup script (Bun, PM2, firewall, keygen) - skatespots-cvm.service: systemd unit for production deployment - .env.example: Environment variable template - DEPLOY.md: Step-by-step deploy guide for LNVPS VPS - package.cvm.json: Dependencies manifest The server aggregates data from goskatespots.nostr1.com and other relays, deduplicates addressable events, and exposes structured JSON tools that AI agents can discover and call via the ContextVM/Nostr protocol (CEP-6). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
320032ea37
commit
2db0c6f7e2
6 changed files with 989 additions and 0 deletions
17
cvm-server/.env.example
Normal file
17
cvm-server/.env.example
Normal file
|
|
@ -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
|
||||
210
cvm-server/DEPLOY.md
Normal file
210
cvm-server/DEPLOY.md
Normal file
|
|
@ -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/)
|
||||
```
|
||||
119
cvm-server/bootstrap.sh
Normal file
119
cvm-server/bootstrap.sh
Normal file
|
|
@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
16
cvm-server/package.cvm.json
Normal file
16
cvm-server/package.cvm.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
597
cvm-server/server.ts
Normal file
597
cvm-server/server.ts
Normal file
|
|
@ -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<any[]> {
|
||||
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<string, any>();
|
||||
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<string, number[]> = {
|
||||
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);
|
||||
});
|
||||
30
cvm-server/skatespots-cvm.service
Normal file
30
cvm-server/skatespots-cvm.service
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue