feat(meetup-mcp): serveur MCP Meetup (events et groupes publics)

API GraphQL de Meetup (gql-ext) en stdio, sans authentification pour ce périmètre
(vérifié : eventSearch, groupSearch, recommendedEvents répondent sans token). Les
requêtes nominatives exigent un OAuth consumer de compte Pro : le jour où le token
arrive, il se pose dans secrets/meetup.yml et le serveur le lit via MEETUP_TOKEN_FILE,
sans changement de code.
This commit is contained in:
Hermes Agent
2026-09-27 13:00:56 -04:00
parent 7175a1fe34
commit 0000584366
3 changed files with 493 additions and 0 deletions
+1
View File
@@ -46,6 +46,7 @@
./zitadel-mcp
./roku
./codeberg-mcp
./meetup-mcp
./gitea
# ./kanidm
# ./syncserver
+36
View File
@@ -0,0 +1,36 @@
# services/meetup-mcp/default.nix
# Serveur MCP Meetup pour hermes : recherche d'events et de groupes publics
# via l'API GraphQL de Meetup (https://api.meetup.com/gql-ext), transport
# stdio (hermes spawn le subprocess, pas d'unité systemd).
#
# Auth : AUCUNE requise pour ce périmètre. Vérifié le 26/09/2026 sans token :
# eventSearch, groupSearch, event, events, groupByUrlname, recommendedEvents,
# recommendedGroups, suggestTopics, topicCategories répondent tous. Les
# requêtes nominatives (self, RSVPs personnels, publication d'events) exigent
# un OAuth consumer, que seul un compte Meetup Pro peut créer. Si un token
# arrive un jour : le poser dans secrets/meetup.yml (clé meetup_oauth_token),
# le déclarer en sops.secrets ici (owner/group hermes, mode 0400,
# restartUnits hermes-agent) et le serveur le lira via MEETUP_TOKEN_FILE,
# sans changement de code (fichier absent = mode public).
#
# Le code du serveur vit dans meetup_mcp.py à côté (testable isolément) ;
# writers.writePython3Bin le passe au lint (pycodestyle/pyflakes) au build.
#
# Debug : { printf '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; sleep 5; } \
# | /nix/store/.../bin/meetup-mcp (garder stdin ouvert : un EOF tue la requête)
{pkgs, ...}: let
meetup-mcp =
pkgs.writers.writePython3Bin "meetup-mcp" {
libraries = [pkgs.python3Packages.mcp];
flakeIgnore = ["E501" "W503" "W504"];
} (builtins.readFile ./meetup_mcp.py);
in {
services.hermes-agent.mcpServers.meetup = {
command = "${meetup-mcp}/bin/meetup-mcp";
env = {
MEETUP_API_URL = "https://api.meetup.com/gql-ext";
# Optionnel : token OAuth (fichier absent = mode public).
MEETUP_TOKEN_FILE = "/run/secrets/meetup_oauth_token";
};
};
}
+456
View File
@@ -0,0 +1,456 @@
import json
import os
import urllib.error
import urllib.request
from datetime import datetime, timezone
try:
from mcp.server import MCPServer # mcp >= 2
except ImportError:
from mcp.server.fastmcp import FastMCP as MCPServer # mcp 1.x
API_URL = os.environ.get("MEETUP_API_URL", "https://api.meetup.com/gql-ext")
EVENT_TYPES = {"online": "ONLINE", "physical": "PHYSICAL", "hybrid": "HYBRID"}
# Sélection standard d'un Event dans toutes les listes (recherches, listes
# de groupe, recommandations). _event_node dépend de ces champs.
EVENT_FIELDS = (
" id title dateTime eventUrl"
" group { name urlname }"
" venue { name city state }"
" rsvps { totalCount }"
)
mcp = MCPServer("meetup")
def _token():
"""Token OAuth optionnel (la lecture publique n'en a pas besoin).
Lu à chaque appel : MEETUP_TOKEN_FILE (fichier sops) puis MEETUP_TOKEN.
"""
path = os.environ.get("MEETUP_TOKEN_FILE", "")
if path and os.path.exists(path):
with open(path) as fh:
return fh.read().strip()
return os.environ.get("MEETUP_TOKEN", "").strip()
def gql(query, variables=None):
"""POST GraphQL sur l'API Meetup ; RuntimeError avec l'erreur brute.
L'API renvoie HTTP 200 même pour une erreur GraphQL (token expiré,
champ inconnu) : le tableau `errors` est donc vérifié explicitement.
"""
payload = {"query": query}
if variables:
payload["variables"] = variables
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
token = _token()
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.loads(resp.read().decode())
except urllib.error.HTTPError as err:
raise RuntimeError(f"HTTP {err.code} sur {API_URL} : {err.read().decode()[:500]}") from None
except urllib.error.URLError as err:
raise RuntimeError(f"Réseau injoignable ({err.reason}) sur {API_URL}") from None
errors = body.get("errors") or []
if errors:
first = errors[0]
kind = (first.get("extensions") or {}).get("classification", "?")
raise RuntimeError(f"Erreur GraphQL Meetup [{kind}] : {first.get('message')}")
return body.get("data") or {}
def _where(query="", lat=0.0, lon=0.0, radius=0.0):
"""Filtre commun eventSearch/groupSearch. lat/lon sont NonNull côté API."""
where = {}
if query:
where["query"] = query
where["lat"] = lat
where["lon"] = lon
if radius:
where["radius"] = radius
return where
def _zoned(value, end=False):
"""'YYYY-MM-DD' -> ZonedDateTime UTC ; une valeur avec 'T' passe telle quelle."""
if not value:
return ""
if "T" in value:
return value
return value + ("T23:59:59Z" if end else "T00:00:00Z")
def _edges(conn):
return (conn or {}).get("edges") or []
def _venue_str(venue):
if not venue:
return None
parts = [venue.get("name"), venue.get("city"), venue.get("state")]
return ", ".join([p for p in parts if p]) or None
def _event_node(node):
group = node.get("group") or {}
return {
"id": node.get("id"),
"titre": node.get("title"),
"date": node.get("dateTime"),
"url": node.get("eventUrl"),
"groupe": group.get("name"),
"urlname": group.get("urlname"),
"lieu": _venue_str(node.get("venue")),
"rsvps": (node.get("rsvps") or {}).get("totalCount"),
}
def _group_node(node):
return {
"id": node.get("id"),
"nom": node.get("name"),
"urlname": node.get("urlname"),
"lien": node.get("link"),
"membres": (node.get("memberships") or {}).get("totalCount"),
"ville": node.get("city"),
"pays": node.get("country"),
}
def _connection(conn, mapper, key):
conn = conn or {}
out = {
"total": conn.get("totalCount"),
key: [mapper(e.get("node") or {}) for e in _edges(conn)],
}
page = conn.get("pageInfo") or {}
if page.get("endCursor"):
out["curseur_suivant"] = page["endCursor"]
return out
def _date_key(event):
try:
dt = datetime.fromisoformat(event.get("date") or "")
except ValueError:
return datetime.max.replace(tzinfo=timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
@mcp.tool()
def status() -> dict:
"""État du serveur Meetup : API joignable, mode d'auth, compte lié.
Tourne sans credentials en mode public (recherche et consultation des
données publiques uniquement). Si un token OAuth est configuré, vérifie
aussi le compte membre associé. Sert de test de vie du serveur.
"""
token = _token()
data = gql("query { topicCategories { edges { node { id } } } }")
out = {
"endpoint": API_URL,
"mode": "public (sans token)" if not token else "token configuré",
"categories_topics": len(_edges(data.get("topicCategories"))),
}
if token:
me = gql("query { self { id name username } }").get("self")
out["compte"] = me if me else "token refusé (self = null)"
return out
@mcp.tool()
def search_events(
lat: float,
lon: float,
query: str = "",
radius: float = 25.0,
event_type: str = "",
start_date: str = "",
end_date: str = "",
sort: str = "relevance",
limit: int = 10,
after: str = "",
) -> dict:
"""Cherche des events Meetup publics par mots-clés autour d'un point.
La recherche Meetup est sémantique : query approxime un thème plutôt
qu'un match littéral. lat/lon obligatoires (géocoder la ville avant si
besoin) ; radius en miles. event_type : online, physical ou hybrid.
start_date/end_date bornent les dates ("YYYY-MM-DD", interprété en UTC,
ou ZonedDateTime complet). sort : "relevance" (défaut) ou "datetime"
(chronologique, tri refait côté serveur). limit 1-50. after : reprendre
à « curseur_suivant » d'un appel précédent.
"""
where = _where(query, lat, lon, radius)
if event_type:
key = event_type.strip().lower()
if key not in EVENT_TYPES:
return {"erreur": "event_type attendu : online, physical ou hybrid"}
where["eventType"] = EVENT_TYPES[key]
start = _zoned(start_date)
end = _zoned(end_date, end=True)
if start:
where["startDateRange"] = start
if end:
where["endDateRange"] = end
if sort not in ("relevance", "datetime"):
return {"erreur": "sort attendu : relevance ou datetime"}
order = ", sort: {sortField: DATETIME, sortOrder: ASC}" if sort == "datetime" else ""
variables = {"filter": where, "first": max(1, min(limit, 50))}
if after:
variables["after"] = after
data = gql(
"query($filter: EventSearchFilter!, $first: Int, $after: String) {"
" eventSearch(filter: $filter, first: $first, after: $after" + order + ") {"
" totalCount pageInfo { endCursor }"
" edges { node {" + EVENT_FIELDS + "} } } }",
variables,
)
out = _connection(data.get("eventSearch"), _event_node, "events")
if sort == "datetime":
out["events"].sort(key=_date_key)
return out
@mcp.tool()
def get_event(event_id: str) -> dict:
"""Détail complet d'un event public par son id (ex. "316297665").
Inclut dates, lieu, groupe, hôtes, topics, frais, photo, nombre de
RSVP. La liste nominative des participants n'est pas publique.
"""
data = gql(
"query($id: ID!) { event(id: $id) {"
" id title description dateTime endTime duration eventUrl"
" eventType status"
" venue { name address city state lat lon }"
" group { id name urlname link }"
" eventHosts { name member { id name } }"
" topics { edges { node { name id } } }"
" feeSettings { amount currency }"
" featuredEventPhoto { highResUrl }"
" rsvps { totalCount }"
" } }",
{"id": event_id},
)
event = data.get("event")
if not event:
return {"trouve": False, "event_id": event_id}
venue = event.get("venue") or {}
group = event.get("group") or {}
return {
"id": event.get("id"),
"titre": event.get("title"),
"description": (event.get("description") or "")[:3000],
"date": event.get("dateTime"),
"fin": event.get("endTime"),
"duree": event.get("duration"),
"url": event.get("eventUrl"),
"type": event.get("eventType"),
"statut": event.get("status"),
"lieu": {
"nom": venue.get("name"),
"adresse": venue.get("address"),
"ville": venue.get("city"),
"region": venue.get("state"),
"lat": venue.get("lat"),
"lon": venue.get("lon"),
},
"groupe": {
"id": group.get("id"),
"nom": group.get("name"),
"urlname": group.get("urlname"),
"lien": group.get("link"),
},
"hotes": [h.get("name") for h in (event.get("eventHosts") or [])],
"topics": [(t.get("node") or {}).get("name") for t in _edges(event.get("topics"))],
"frais": event.get("feeSettings"),
"photo": (event.get("featuredEventPhoto") or {}).get("highResUrl"),
"rsvps": (event.get("rsvps") or {}).get("totalCount"),
}
@mcp.tool()
def get_events(event_ids: list[str]) -> list:
"""Résumé de plusieurs events à partir de leurs ids (max 20).
Pour revérifier d'un coup des events déjà repérés (titre, date, url,
groupe, lieu, rsvps) sans un appel par id.
"""
ids = [str(i) for i in event_ids][:20]
data = gql(
"query($where: EventSearch!) { events(where: $where) {" + EVENT_FIELDS + "} }",
{"where": {"ids": ids}},
)
return [_event_node(n) for n in (data.get("events") or [])]
@mcp.tool()
def search_groups(
lat: float,
lon: float,
query: str = "",
radius: float = 25.0,
limit: int = 10,
after: str = "",
) -> dict:
"""Cherche des groupes Meetup autour d'un point (mots-clés optionnels).
lat/lon obligatoires, radius en miles. after : curseur d'une page
précédente (« curseur_suivant »).
"""
where = _where(query, lat, lon, radius)
variables = {"filter": where, "first": max(1, min(limit, 50))}
if after:
variables["after"] = after
data = gql(
"query($filter: GroupSearchFilter!, $first: Int, $after: String) {"
" groupSearch(filter: $filter, first: $first, after: $after) {"
" totalCount pageInfo { endCursor }"
" edges { node { id name urlname link memberships { totalCount } city country } } } }",
variables,
)
return _connection(data.get("groupSearch"), _group_node, "groupes")
@mcp.tool()
def get_group(urlname: str) -> dict:
"""Fiche d'un groupe par son urlname (ex. "nyhackr") + 5 prochains events.
urlname : le segment d'URL meetup.com/<urlname> ; aussi renvoyé par
search_groups et par les events (champ urlname).
"""
data = gql(
"query($urlname: String!) { groupByUrlname(urlname: $urlname) {"
" id name urlname description link"
" city state country lat lon timezone foundedDate"
" memberships { totalCount }"
" keyGroupPhoto { highResUrl }"
" organizer { id name }"
" events(first: 5) { edges { node {" + EVENT_FIELDS + "} } }"
" } }",
{"urlname": urlname},
)
group = data.get("groupByUrlname")
if not group:
return {"trouve": False, "urlname": urlname}
return {
"id": group.get("id"),
"nom": group.get("name"),
"urlname": group.get("urlname"),
"description": (group.get("description") or "")[:2000],
"lien": group.get("link"),
"ville": group.get("city"),
"region": group.get("state"),
"pays": group.get("country"),
"lat": group.get("lat"),
"lon": group.get("lon"),
"timezone": group.get("timezone"),
"cree_le": group.get("foundedDate"),
"membres": (group.get("memberships") or {}).get("totalCount"),
"photo": (group.get("keyGroupPhoto") or {}).get("highResUrl"),
"organisateur": (group.get("organizer") or {}).get("name"),
"prochains_events": [_event_node(e.get("node") or {}) for e in _edges(group.get("events"))],
}
@mcp.tool()
def list_group_events(urlname: str, status: str = "upcoming", limit: int = 10) -> dict:
"""Events d'un groupe : status "upcoming" (défaut) ou "past".
"past" liste les plus anciens d'abord (ordre Meetup, non triable).
"""
if status not in ("upcoming", "past"):
return {"erreur": "status attendu : upcoming ou past"}
filt = ", filter: {status: PAST}" if status == "past" else ""
data = gql(
"query($urlname: String!, $first: Int) { groupByUrlname(urlname: $urlname) {"
" name urlname link"
" events(first: $first" + filt + ") { edges { node {" + EVENT_FIELDS + "} } }"
" } }",
{"urlname": urlname, "first": max(1, min(limit, 50))},
)
group = data.get("groupByUrlname")
if not group:
return {"trouve": False, "urlname": urlname}
return {
"groupe": group.get("name"),
"urlname": group.get("urlname"),
"lien": group.get("link"),
"statut": status,
"events": [_event_node(e.get("node") or {}) for e in _edges(group.get("events"))],
}
@mcp.tool()
def recommended_events(lat: float, lon: float, radius: float = 25.0, limit: int = 10) -> dict:
"""Events recommandés par Meetup autour d'un point, sans mots-clés.
lat/lon obligatoires ; radius en miles. Pour une recherche par thème,
passer par search_events.
"""
where = {"lat": lat, "lon": lon}
if radius:
where["radius"] = radius
data = gql(
"query($filter: RecommendedEventsFilter!, $first: Int) {"
" recommendedEvents(filter: $filter, first: $first) {"
" totalCount pageInfo { endCursor }"
" edges { node {" + EVENT_FIELDS + "} } } }",
{"filter": where, "first": max(1, min(limit, 50))},
)
return _connection(data.get("recommendedEvents"), _event_node, "events")
@mcp.tool()
def recommended_groups(lat: float, lon: float, radius: float = 25.0, limit: int = 10) -> dict:
"""Groupes recommandés par Meetup autour d'un point, sans mots-clés."""
where = {"lat": lat, "lon": lon}
if radius:
where["radius"] = radius
data = gql(
"query($filter: RecommendedGroupsFilter!, $first: Int) {"
" recommendedGroups(filter: $filter, first: $first) {"
" totalCount edges { node { id name urlname link memberships { totalCount } city country } } } }",
{"filter": where, "first": max(1, min(limit, 50))},
)
return _connection(data.get("recommendedGroups"), _group_node, "groupes")
@mcp.tool()
def suggest_topics(query: str, limit: int = 10) -> list:
"""Suggère des topics Meetup réels pour un mot-clé (id, nom, urlkey).
Utile pour préciser un thème de recherche ou qualifier un groupe.
"""
data = gql(
"query($query: String!, $first: Int) {"
" suggestTopics(query: $query, first: $first) {"
" edges { node { id name urlkey } } } }",
{"query": query, "first": max(1, min(limit, 25))},
)
return [(e.get("node") or {}) for e in _edges(data.get("suggestTopics"))]
@mcp.tool()
def list_topic_categories() -> list:
"""Catégories de topics Meetup (id + nom), pour situer les grands thèmes."""
data = gql("query { topicCategories { edges { node { id name } } } }")
return [(e.get("node") or {}) for e in _edges(data.get("topicCategories"))]
if __name__ == "__main__":
mcp.run()