feat(cvm-server): parity with the app — all Lab roles + crews/shops/tours

- find_coaches now covers every Lab kind (35001-35014, minus 35006 crews), with
  a real role enum and label map, and matches skills against t/skill tags.
- New tools: find_crews (35006), find_shops (30017/t=skateshop), find_tours
  (30823).
- get_community_stats widened to every craft + shops/crews/tours, with lab_total.
This commit is contained in:
SkateSpots 2026-09-27 11:12:14 +07:00
parent cbd0287447
commit b24c734a4d

View file

@ -372,38 +372,57 @@ mcpServer.registerTool(
"find_coaches", "find_coaches",
{ {
description: 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: { inputSchema: {
role: z role: z
.enum(["coach", "filmer", "editor", "any"]) .enum([
"coach", "filmer", "editor", "photographer", "builder", "artist",
"graffiti", "tattoo", "trainer", "musician", "judge", "organizer",
"barber", "any",
])
.optional() .optional()
.default("any") .default("any")
.describe("coach = skateboarding instructor, filmer = videographer, editor = video editor"), .describe("Lab role; 'any' searches all of them"),
skills: z skills: z
.array(z.string()) .array(z.string())
.optional() .optional()
.describe( .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"), location: z.string().optional().describe("City or country"),
limit: z.number().min(1).max(30).optional().default(10), limit: z.number().min(1).max(30).optional().default(10),
}, },
}, },
async ({ role, skills, location, limit }) => { async ({ role, skills, location, limit }) => {
const kindMap: Record<string, number[]> = { // Must match the app's Lab coverage (kinds 35001..35014, minus 35006 = crews).
coach: [35001], const ROLE_KIND: Record<string, number> = {
filmer: [35002], coach: 35001, filmer: 35002, editor: 35003, photographer: 35004,
editor: [35003], builder: 35005, artist: 35007, graffiti: 35008, tattoo: 35009,
any: [35001, 35002, 35003], 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<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 }]); const raw = await queryNostr([{ kinds, limit: 500 }]);
let profiles = deduplicateAddressable(raw); let profiles = deduplicateAddressable(raw);
if (skills && skills.length > 0) { if (skills && skills.length > 0) {
profiles = profiles.filter((e) => { 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(); const bio = e.content.toLowerCase();
return skills.some( return skills.some(
(s) => (s) =>
@ -425,15 +444,12 @@ mcpServer.registerTool(
profiles = profiles.slice(0, limit!); profiles = profiles.slice(0, limit!);
const roleLabel = (kind: number) =>
kind === 35001 ? "coach" : kind === 35002 ? "filmer" : "editor";
const result = profiles.map((e) => ({ const result = profiles.map((e) => ({
role: roleLabel(e.kind), role: KIND_ROLE[e.kind] ?? "lab",
pubkey: e.pubkey, pubkey: e.pubkey,
bio: e.content.slice(0, 300), bio: e.content.slice(0, 300),
location: getTag(e, "location"), 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"), experience: getTag(e, "experience"),
lightning_address: getTag(e, "lud16") ?? getTag(e, "contact"), lightning_address: getTag(e, "lud16") ?? getTag(e, "contact"),
naddr: buildNaddr(e), 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 ──────────────────────────────────────────────── // ── Tool: get_community_stats ────────────────────────────────────────────────
mcpServer.registerTool( mcpServer.registerTool(
"get_community_stats", "get_community_stats",
{ {
description: 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: {}, inputSchema: {},
}, },
async () => { 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: [30818], limit: 2000 }]),
queryNostr([{ kinds: [31923], limit: 2000, since: Math.floor(Date.now() / 1000) }]), 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: [35001], limit: 2000 }]),
queryNostr([{ kinds: [35002], limit: 2000 }]), queryNostr([{ kinds: [35002], limit: 2000 }]),
queryNostr([{ kinds: [35003], 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 { return {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
text: JSON.stringify( text: JSON.stringify(
{ {
spots: deduplicateAddressable(spots).length, spots: deduplicateAddressable(spots).filter((e) =>
upcoming_events: deduplicateAddressable(events).length, e.tags.some((t) => t[0] === "t" && t[1] === "skatespot")
coaches: deduplicateAddressable(coaches).length, ).length,
filmers: deduplicateAddressable(filmers).length, upcoming_events: n(events),
editors: deduplicateAddressable(editors).length, shops: deduplicateAddressable(shops).filter((e) =>
gear_listings: deduplicateAddressable(gear).length, 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, relays_queried: NOSTR_RELAYS,
queried_at: new Date().toISOString(), queried_at: new Date().toISOString(),
}, },