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:
parent
cbd0287447
commit
b24c734a4d
1 changed files with 248 additions and 25 deletions
|
|
@ -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<string, number[]> = {
|
||||
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<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 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 }]);
|
||||
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(),
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue