diff --git a/cvm-server/server.ts b/cvm-server/server.ts index 38a1f97..c5c44bc 100644 --- a/cvm-server/server.ts +++ b/cvm-server/server.ts @@ -372,38 +372,57 @@ mcpServer.registerTool( "find_coaches", { description: - "Find skateboarding coaches, filmers, and video editors in The Lab. Filter by role, skills, or location.", + "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", "any"]) + .enum([ + "coach", "filmer", "editor", "photographer", "builder", "artist", + "graffiti", "tattoo", "trainer", "musician", "judge", "organizer", + "barber", "any", + ]) .optional() .default("any") - .describe("coach = skateboarding instructor, filmer = videographer, editor = video editor"), + .describe("Lab role; 'any' searches all of them"), skills: z .array(z.string()) .optional() .describe( - "Skill specializations, e.g. ['kickflip', 'grind', 'vert', 'bowl', 'street', 'film', 'edit']" + "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 }) => { - const kindMap: Record = { - coach: [35001], - filmer: [35002], - editor: [35003], - any: [35001, 35002, 35003], + // Must match the app's Lab coverage (kinds 35001..35014, minus 35006 = crews). + const ROLE_KIND: Record = { + coach: 35001, filmer: 35002, editor: 35003, photographer: 35004, + builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009, + trainer: 35010, musician: 35011, judge: 35012, organizer: 35013, + barber: 35014, }; - const kinds = kindMap[role ?? "any"]; + const ALL_KINDS = Object.values(ROLE_KIND); + const KIND_ROLE: Record = Object.fromEntries( + Object.entries(ROLE_KIND).map(([r, k]) => [k, r]) + ); + const kinds = role && role !== "any" ? [ROLE_KIND[role]] : ALL_KINDS; + + const SELF_TAGS = new Set([ + "skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", + "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", + "skatejudge", "skateorganizer", "skatebarber", + ]); const raw = await queryNostr([{ kinds, limit: 500 }]); let profiles = deduplicateAddressable(raw); if (skills && skills.length > 0) { profiles = profiles.filter((e) => { - const profileSkills = getTags(e, "skill").map((s) => s.toLowerCase()); + // 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) => @@ -425,15 +444,12 @@ mcpServer.registerTool( 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), + role: KIND_ROLE[e.kind] ?? "lab", pubkey: e.pubkey, bio: e.content.slice(0, 300), location: getTag(e, "location"), - skills: getTags(e, "skill"), + 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), @@ -554,37 +570,244 @@ mcpServer.registerTool( } ); +// ── 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: total spots, upcoming events, coaches, filmers, editors, and gear listings across all connected relays.", + "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, coaches, filmers, editors, gear] = await Promise.all([ + 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: [30402], "#t": ["skateboarding"], 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).length, - upcoming_events: deduplicateAddressable(events).length, - coaches: deduplicateAddressable(coaches).length, - filmers: deduplicateAddressable(filmers).length, - editors: deduplicateAddressable(editors).length, - gear_listings: deduplicateAddressable(gear).length, + 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(), },