Point SKATE_RELAY, the default relay pools, the DM/CVM/zap relay arrays, the cvm-server + push-service defaults, and the archive default at wss://relay.skate-spots.org, keeping the legacy hosted relay as a fallback during the client-update window. Restored 115/115 archived events; the relay runs behind Caddy with TLS. Docs + AGENTS.md updated.
975 lines
36 KiB
TypeScript
975 lines
36 KiB
TypeScript
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://relay.skate-spots.org,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<string, number[]>();
|
|
const inFlightByClient = new Map<string, number>();
|
|
const queryCache = new Map<string, { at: number; events: any[] }>();
|
|
|
|
// Global semaphore capping concurrent upstream relay queries.
|
|
let upstreamActive = 0;
|
|
const upstreamQueue: Array<() => void> = [];
|
|
async function withUpstreamSlot<T>(fn: () => Promise<T>): Promise<T> {
|
|
if (upstreamActive >= MAX_UPSTREAM) {
|
|
await new Promise<void>((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<any[]> {
|
|
// 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<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);
|
|
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<string, number> = {
|
|
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<number, string> = 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<void> };
|
|
}
|
|
).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);
|
|
});
|