v2 - STT via Whisper for more precision and if nor available, twist to Vox
This commit is contained in:
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serveur MCP stdio autonome pour Hermes, sans installation pip.
|
||||
|
||||
Protocole JSON-RPC 2.0 (MCP legacy 2025-03/2025-06), messages JSONL.
|
||||
Le serveur ne gère pas l'horloge ; il délègue à Alfred sur 127.0.0.1:54321.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib import request, error
|
||||
|
||||
URL = os.getenv('ALFRED_TIMERS_API', 'http://127.0.0.1:54321').rstrip('/')
|
||||
|
||||
|
||||
def tool(name, desc, props=None, required=None):
|
||||
return {'name': name, 'description': desc,
|
||||
'inputSchema': {'type': 'object', 'properties': props or {}, 'required': required or [], 'additionalProperties': False}}
|
||||
|
||||
|
||||
def seconds_prop(desc):
|
||||
return {'type': 'number', 'exclusiveMinimum': 0, 'maximum': 2592000, 'description': desc}
|
||||
|
||||
|
||||
def label_prop():
|
||||
return {'type': 'string', 'maxLength': 80, 'description': 'Nom facultatif pour reconnaître le minuteur (ex. pâtes, cuisson).'}
|
||||
|
||||
|
||||
def id_prop():
|
||||
return {'type': 'string', 'description': "Identifiant ou nom unique renvoyé par list_timers ou create_timer."}
|
||||
|
||||
|
||||
TOOLS = [
|
||||
tool('create_timer', 'Crée un minuteur qui compte à rebours. Convertis les minutes/heures en secondes. Ex : 5 minutes = 300 secondes. Une alerte vocale est déclenchée à zéro.', {'seconds': seconds_prop('Durée demandée en secondes'), 'label': label_prop()}, ['seconds']),
|
||||
tool('create_stopwatch', 'Démarre un chronomètre qui compte à partir de zéro ; si l’utilisateur dit « chrono de 10 minutes », fournir 600 secondes pour l’arrêter avec une alerte après 10 minutes. Sinon omettre seconds pour un chrono libre.', {'seconds': seconds_prop('Durée limite facultative, en secondes'), 'label': label_prop()}),
|
||||
tool('list_timers', 'Liste minuteurs/chronomètres en cours et le temps restant ou écoulé.'),
|
||||
tool('cancel_all_timers', 'Annule TOUS les minuteurs et chronomètres en cours sur demande explicite de l’utilisateur.'),
|
||||
tool('pause_timer', 'Met en pause le minuteur ou le chronomètre sélectionné.', {'id': id_prop()}, ['id']),
|
||||
tool('resume_timer', 'Reprend un minuteur ou chronomètre en pause.', {'id': id_prop()}, ['id']),
|
||||
tool('cancel_timer', 'Annule le minuteur ou le chronomètre sélectionné.', {'id': id_prop()}, ['id']),
|
||||
tool('add_time', 'Ajoute du temps à un minuteur ou un chrono avec durée limite. Ex : ajoute 2 minutes = 120 secondes.', {'id': id_prop(), 'seconds': seconds_prop('Temps supplémentaire en secondes')}, ['id','seconds']),
|
||||
]
|
||||
|
||||
|
||||
def api_call(action=None, args=None):
|
||||
if action is None:
|
||||
req = request.Request(URL + '/api/timers', headers={'Accept': 'application/json'})
|
||||
else:
|
||||
data = json.dumps({'action': action, **(args or {})}).encode('utf-8')
|
||||
req = request.Request(URL + '/api/timers/action', data=data, method='POST', headers={'Content-Type': 'application/json', 'X-Alfred-MCP-Local': 'timers'})
|
||||
try:
|
||||
with request.urlopen(req, timeout=7) as response:
|
||||
return json.loads(response.read())
|
||||
except error.HTTPError as exc:
|
||||
try:
|
||||
detail = json.loads(exc.read()).get('error', '')
|
||||
except Exception:
|
||||
detail = ''
|
||||
raise ValueError(detail or f'HTTP {exc.code}') from exc
|
||||
except (error.URLError, TimeoutError) as exc:
|
||||
raise ValueError(f'Alfred indisponible sur {URL} : {exc}') from exc
|
||||
|
||||
|
||||
def invoke(name, arguments):
|
||||
if not isinstance(arguments, dict):
|
||||
raise ValueError('Arguments JSON attendus.')
|
||||
if name == 'list_timers':
|
||||
return api_call()
|
||||
if name == 'cancel_all_timers':
|
||||
return api_call('cancel_all')
|
||||
if name in ('create_timer', 'create_stopwatch'):
|
||||
return api_call(name, {k: arguments[k] for k in ('seconds','label') if k in arguments})
|
||||
action = {'pause_timer':'pause','resume_timer':'resume','cancel_timer':'cancel','add_time':'add_time'}.get(name)
|
||||
if action:
|
||||
return api_call(action, {k: arguments[k] for k in ('id','seconds') if k in arguments})
|
||||
raise ValueError('Outil inconnu : '+str(name))
|
||||
|
||||
|
||||
def respond(msg):
|
||||
sys.stdout.write(json.dumps(msg, ensure_ascii=False, separators=(',', ':'))+'\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def handle(obj):
|
||||
method = obj.get('method')
|
||||
if not isinstance(method, str) or 'id' not in obj:
|
||||
return
|
||||
req_id = obj['id']
|
||||
try:
|
||||
if method == 'initialize':
|
||||
requested = str((obj.get('params') or {}).get('protocolVersion', '2025-03-26'))
|
||||
version = requested if requested in ('2025-03-26', '2025-06-18', '2024-11-05') else '2025-06-18'
|
||||
result = {'protocolVersion': version, 'capabilities': {'tools': {'listChanged': False}},
|
||||
'serverInfo': {'name': 'alfred-timers', 'version': '1.0.0'},
|
||||
'instructions': 'Use these tools for all timers, chronometers and time adjustments on Alfred. Never claim a timer was set without tool success.'}
|
||||
elif method == 'tools/list':
|
||||
result = {'tools': TOOLS}
|
||||
elif method == 'tools/call':
|
||||
params = obj.get('params') or {}
|
||||
try:
|
||||
answer = invoke(str(params.get('name','')), params.get('arguments') or {})
|
||||
result = {'content': [{'type': 'text', 'text': json.dumps(answer, ensure_ascii=False)}], 'isError': False}
|
||||
except Exception as exc:
|
||||
result = {'content': [{'type': 'text', 'text': str(exc)}], 'isError': True}
|
||||
elif method == 'ping':
|
||||
result = {}
|
||||
else:
|
||||
respond({'jsonrpc':'2.0','id':req_id,'error':{'code':-32601,'message':'Method not found'}})
|
||||
return
|
||||
respond({'jsonrpc': '2.0', 'id': req_id, 'result': result})
|
||||
except Exception as exc:
|
||||
respond({'jsonrpc':'2.0','id':req_id,'error':{'code':-32603,'message':str(exc)}})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
if isinstance(obj, dict):
|
||||
handle(obj)
|
||||
except json.JSONDecodeError as exc:
|
||||
print('Erreur JSON MCP :', exc, file=sys.stderr)
|
||||
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
"""Serveur MCP stdio de l'assistant Alfred : heure et date locales.
|
||||
|
||||
N'écrit rien sur stdout : ce flux est réservé au protocole MCP.
|
||||
"""
|
||||
|
||||
from mcp.server import MCPServer
|
||||
from time_logic import get_current_datetime as read_clock
|
||||
|
||||
mcp = MCPServer("Alfred Autres")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_current_datetime(timezone: str = "") -> dict:
|
||||
"""Donne la date, le jour et l'heure ACTUELS exacts de l'horloge système.
|
||||
|
||||
À utiliser pour : « Quelle heure est-il ? », « On est quel jour ? »,
|
||||
« Quelle est la date d'aujourd'hui ? » et les questions similaires.
|
||||
Laisse timezone vide pour l'heure locale d'Alfred (Europe/Paris par défaut).
|
||||
Pour un autre endroit, utilise un fuseau IANA : Europe/London, Asia/Tokyo...
|
||||
Ne devine jamais la date ou l'heure sans appeler cet outil.
|
||||
"""
|
||||
return read_clock(timezone)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run() # stdio : Hermes lance ce processus à la demande.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Calculs de temps purs pour le MCP Alfred Autres (Python 3.11+)."""
|
||||
|
||||
from datetime import datetime
|
||||
from os import environ
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
WEEKDAYS_FR = (
|
||||
"lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"
|
||||
)
|
||||
MONTHS_FR = (
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
|
||||
)
|
||||
|
||||
|
||||
def get_current_datetime(timezone: str = "") -> dict[str, str | int]:
|
||||
"""Obtient l'heure réelle de l'horloge système dans un fuseau IANA."""
|
||||
selected = timezone.strip() if timezone else environ.get("ALFRED_TIMEZONE", "Europe/Paris")
|
||||
try:
|
||||
zone = ZoneInfo(selected)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise ValueError(f"Fuseau horaire non reconnu : {selected!r}") from exc
|
||||
|
||||
now = datetime.now(zone)
|
||||
offset = now.strftime("%z")
|
||||
return {
|
||||
"iso_datetime": now.isoformat(timespec="seconds"),
|
||||
"time_24h": now.strftime("%H:%M:%S"),
|
||||
"hour": now.hour,
|
||||
"minute": now.minute,
|
||||
"date_iso": now.date().isoformat(),
|
||||
"weekday_fr": WEEKDAYS_FR[now.weekday()],
|
||||
"date_fr": f"{WEEKDAYS_FR[now.weekday()]} {now.day} {MONTHS_FR[now.month - 1]} {now.year}",
|
||||
"timezone": selected,
|
||||
"utc_offset": (offset[:3] + ":" + offset[3:]) if offset else "",
|
||||
"timestamp_unix": int(now.timestamp()),
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
VIKUNJA_URL = os.getenv("VIKUNJA_URL", "").rstrip("/")
|
||||
VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "")
|
||||
VIKUNJA_VERIFY_SSL = (
|
||||
os.getenv("VIKUNJA_VERIFY_SSL", "true").lower()
|
||||
in ("true", "1", "yes")
|
||||
)
|
||||
|
||||
if not VIKUNJA_URL:
|
||||
raise RuntimeError("VIKUNJA_URL n'est pas défini dans .env")
|
||||
|
||||
if not VIKUNJA_TOKEN:
|
||||
raise RuntimeError("VIKUNJA_TOKEN n'est pas défini dans .env")
|
||||
|
||||
|
||||
API_URL = f"{VIKUNJA_URL}/api/v2"
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {VIKUNJA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MCP
|
||||
# =============================================================================
|
||||
|
||||
mcp = FastMCP("vikunja")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Client Vikunja
|
||||
# =============================================================================
|
||||
|
||||
async def vikunja_request(
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
json: dict | None = None,
|
||||
):
|
||||
"""
|
||||
Effectue une requête vers l'API v2 de Vikunja.
|
||||
"""
|
||||
|
||||
url = f"{API_URL}{endpoint}"
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
headers=HEADERS,
|
||||
verify=VIKUNJA_VERIFY_SSL,
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
if not response.content:
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
async def vikunja_get(
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
):
|
||||
return await vikunja_request(
|
||||
"GET",
|
||||
endpoint,
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
async def vikunja_post(
|
||||
endpoint: str,
|
||||
data: dict,
|
||||
):
|
||||
return await vikunja_request(
|
||||
"POST",
|
||||
endpoint,
|
||||
json=data,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MCP Tools
|
||||
# =============================================================================
|
||||
|
||||
@mcp.tool()
|
||||
async def vikunja_get_projects() -> dict:
|
||||
"""
|
||||
Retourne les projets Vikunja disponibles pour l'utilisateur.
|
||||
|
||||
À utiliser notamment avant de créer une tâche lorsque l'utilisateur
|
||||
ne donne pas directement l'identifiant du projet.
|
||||
"""
|
||||
|
||||
projects = await vikunja_get(
|
||||
"/projects",
|
||||
params={
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for project in projects or []:
|
||||
result.append(
|
||||
{
|
||||
"id": project.get("id"),
|
||||
"title": project.get("title"),
|
||||
"description": project.get("description"),
|
||||
"parent_project_id": project.get("parent_project_id"),
|
||||
"is_archived": project.get("is_archived"),
|
||||
"created": project.get("created"),
|
||||
"updated": project.get("updated"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"count": len(result),
|
||||
"projects": result,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def vikunja_get_project(
|
||||
project_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Retourne les informations détaillées d'un projet Vikunja.
|
||||
|
||||
Args:
|
||||
project_id:
|
||||
ID du projet.
|
||||
"""
|
||||
|
||||
project = await vikunja_get(
|
||||
f"/projects/{project_id}",
|
||||
params={
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": project.get("id"),
|
||||
"title": project.get("title"),
|
||||
"description": project.get("description"),
|
||||
"parent_project_id": project.get("parent_project_id"),
|
||||
"is_archived": project.get("is_archived"),
|
||||
"created": project.get("created"),
|
||||
"updated": project.get("updated"),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def vikunja_get_tasks(
|
||||
project_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Retourne les tâches d'un projet Vikunja.
|
||||
|
||||
Args:
|
||||
project_id:
|
||||
ID du projet Vikunja.
|
||||
"""
|
||||
|
||||
tasks = await vikunja_get(
|
||||
f"/projects/{project_id}/tasks",
|
||||
params={
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for task in tasks or []:
|
||||
result.append(
|
||||
{
|
||||
"id": task.get("id"),
|
||||
"title": task.get("title"),
|
||||
"description": task.get("description"),
|
||||
"done": task.get("done"),
|
||||
"priority": task.get("priority"),
|
||||
"due_date": task.get("due_date"),
|
||||
"start_date": task.get("start_date"),
|
||||
"end_date": task.get("end_date"),
|
||||
"project_id": task.get("project_id"),
|
||||
"labels": task.get("labels", []),
|
||||
"created": task.get("created"),
|
||||
"updated": task.get("updated"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"count": len(result),
|
||||
"tasks": result,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def vikunja_get_task(
|
||||
task_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Retourne le détail d'une tâche Vikunja.
|
||||
|
||||
Args:
|
||||
task_id:
|
||||
ID de la tâche.
|
||||
"""
|
||||
|
||||
task = await vikunja_get(
|
||||
f"/tasks/{task_id}",
|
||||
params={
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": task.get("id"),
|
||||
"title": task.get("title"),
|
||||
"description": task.get("description"),
|
||||
"done": task.get("done"),
|
||||
"priority": task.get("priority"),
|
||||
"due_date": task.get("due_date"),
|
||||
"start_date": task.get("start_date"),
|
||||
"end_date": task.get("end_date"),
|
||||
"project_id": task.get("project_id"),
|
||||
"labels": task.get("labels", []),
|
||||
"assignees": task.get("assignees", []),
|
||||
"created": task.get("created"),
|
||||
"updated": task.get("updated"),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def vikunja_create_task(
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
due_date: str | None = None,
|
||||
priority: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Crée une nouvelle tâche dans Vikunja.
|
||||
|
||||
Args:
|
||||
project_id:
|
||||
ID du projet dans lequel créer la tâche.
|
||||
|
||||
title:
|
||||
Titre de la tâche.
|
||||
|
||||
description:
|
||||
Description facultative.
|
||||
|
||||
due_date:
|
||||
Date d'échéance facultative au format ISO 8601.
|
||||
Exemple : 2026-09-15T18:00:00+02:00
|
||||
|
||||
priority:
|
||||
Priorité Vikunja.
|
||||
0 signifie aucune priorité.
|
||||
"""
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
if due_date:
|
||||
data["due_date"] = due_date
|
||||
|
||||
task = await vikunja_request(
|
||||
"POST",
|
||||
f"/projects/{project_id}/tasks",
|
||||
params={
|
||||
"format": "markdown",
|
||||
},
|
||||
json=data,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task": {
|
||||
"id": task.get("id"),
|
||||
"title": task.get("title"),
|
||||
"description": task.get("description"),
|
||||
"project_id": task.get("project_id"),
|
||||
"priority": task.get("priority"),
|
||||
"due_date": task.get("due_date"),
|
||||
"done": task.get("done"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user