SkateSpots/cvm-server/server.ts
shakespeare.diy 84aa009b33 fix: CVM server tag mapping + add city/country geocoding to spots
CVM server fixes (cvm-server/server.ts):
- Use 'title' tag instead of 'name' to read spot names (app publishes 'title')
- Search now checks 'title' tag (not just 'name') for keyword matches
- City filter now also checks 'title' tag and event content
- Country filter also checks event content
- Features filter now checks 't' tags (app uses 't' not 'feature')
- Validate spots with 't: skatespot' tag (same rule as the app)
- Return 'location' field and proper features list in results
- Image parsing now supports both 'image' (legacy) and 'imeta' (NIP-92)

CreateSpotPage fix (src/pages/CreateSpotPage.tsx):
- Add reverse geocoding via Nominatim on spot submit
- Emit structured 'city' and 'country' tags so CVM can filter by them
- Auto-fill location field from geocoding if user left it blank

SasquatchGuide improvement (src/components/SasquatchGuide.tsx):
- Add data model notes to system prompt explaining tag structure
- Instruct AI to handle 0-result responses gracefully (encourage adding spots)
- Use limit:20+ for city/country searches

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-17 11:14:50 +07:00

623 lines
22 KiB
TypeScript

/**
* 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 }) => {
// 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);
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);
});