dosync 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dosync/mcp_server.py ADDED
@@ -0,0 +1,746 @@
1
+ """
2
+ DoSync — MCP Server
3
+ ====================
4
+ Expone el hub DoSync como un servidor MCP (Model Context Protocol).
5
+
6
+ Con esto, cualquier LLM que soporte MCP (Claude, ChatGPT, Cursor, etc.)
7
+ puede controlar el hogar via DoSync sin configuración adicional.
8
+
9
+ Uso:
10
+ # Arrancar como servidor MCP standalone (stdio — para Claude Desktop)
11
+ PYTHONPATH=. python3 dosync/mcp_server.py
12
+
13
+ # Con autenticación
14
+ DOSYNC_TOKEN=<tu-token> PYTHONPATH=. python3 dosync/mcp_server.py
15
+
16
+ Configuración en Claude Desktop (~/.config/claude/claude_desktop_config.json):
17
+ {
18
+ "mcpServers": {
19
+ "dosync": {
20
+ "command": "python3",
21
+ "args": ["/ruta/a/dosync/dosync/mcp_server.py"],
22
+ "env": {
23
+ "DOSYNC_TOKEN": "<tu-token>",
24
+ "DOSYNC_HUB_URL": "http://localhost:47200"
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ Herramientas expuestas al LLM:
31
+ dosync_fire_intent — ejecutar una intención semántica
32
+ dosync_list_devices — listar dispositivos registrados
33
+ dosync_get_status — estado del hub + ocupación
34
+ dosync_send_event — enviar un evento de dispositivo
35
+ dosync_get_audit_log — últimas entradas del audit log
36
+ dosync_get_scenarios — listar escenarios disponibles
37
+ """
38
+
39
+ from __future__ import annotations
40
+ import asyncio
41
+ import json
42
+ import logging
43
+ import os
44
+ import sys
45
+ from typing import Any
46
+
47
+ log = logging.getLogger("dosync.mcp")
48
+
49
+ try:
50
+ import mcp.server.stdio
51
+ import mcp.types as types
52
+ from mcp.server import Server
53
+ from mcp.server.models import InitializationOptions
54
+ MCP_AVAILABLE = True
55
+ except ImportError:
56
+ MCP_AVAILABLE = False
57
+ print("mcp not installed. Run: pip install mcp", file=sys.stderr)
58
+ sys.exit(1)
59
+
60
+ try:
61
+ import httpx
62
+ HTTP_AVAILABLE = True
63
+ except ImportError:
64
+ HTTP_AVAILABLE = False
65
+
66
+ # ── Configuración ─────────────────────────────────────────────────────────────
67
+
68
+ HUB_URL = os.environ.get("DOSYNC_HUB_URL", "http://localhost:47200")
69
+ HUB_TOKEN = os.environ.get("DOSYNC_TOKEN", "")
70
+ CA_CERT = os.environ.get("DOSYNC_CA_CERT", None)
71
+
72
+ # Polling timeout for async intents.
73
+ # Derived from DOSYNC_INTENT_TIMEOUT + 3s network margin.
74
+ # emergency default: 5 + 3 = 8s, info/alert default: 10 + 3 = 13s
75
+ _EMERGENCY_HUB_TIMEOUT = float(os.environ.get("DOSYNC_INTENT_TIMEOUT", "5"))
76
+ _DEFAULT_HUB_TIMEOUT = float(os.environ.get("DOSYNC_INTENT_TIMEOUT", "10"))
77
+ MCP_EMERGENCY_TIMEOUT = _EMERGENCY_HUB_TIMEOUT + 3
78
+ MCP_DEFAULT_TIMEOUT = _DEFAULT_HUB_TIMEOUT + 3
79
+ POLL_INTERVAL = 1.0 # seconds between polls
80
+
81
+ # ── HTTP helper ───────────────────────────────────────────────────────────────
82
+
83
+ async def hub_request(method: str, path: str, body: dict = None) -> dict:
84
+ """Llama a la API REST del hub DoSync."""
85
+ headers = {"Content-Type": "application/json"}
86
+ if HUB_TOKEN:
87
+ headers["Authorization"] = f"Bearer {HUB_TOKEN}"
88
+
89
+ if not HTTP_AVAILABLE:
90
+ # Fallback sin httpx — usar urllib
91
+ import urllib.request
92
+ import urllib.error
93
+ data = json.dumps(body).encode() if body else None
94
+ req = urllib.request.Request(
95
+ HUB_URL + path, data=data, headers=headers, method=method
96
+ )
97
+ try:
98
+ with urllib.request.urlopen(req, timeout=10) as resp:
99
+ return json.loads(resp.read())
100
+ except urllib.error.HTTPError as e:
101
+ return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
102
+ except Exception as e:
103
+ return {"error": str(e)}
104
+
105
+ async with httpx.AsyncClient(verify=CA_CERT if CA_CERT else True) as client:
106
+ try:
107
+ if method == "GET":
108
+ r = await client.get(HUB_URL + path, headers=headers, timeout=10)
109
+ else:
110
+ r = await client.post(HUB_URL + path, headers=headers,
111
+ json=body, timeout=10)
112
+ r.raise_for_status()
113
+ return r.json()
114
+ except httpx.HTTPStatusError as e:
115
+ return {"error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
116
+ except Exception as e:
117
+ return {"error": str(e)}
118
+
119
+
120
+ def fmt(data: dict) -> str:
121
+ """Formatea la respuesta del hub para el LLM."""
122
+ if "error" in data:
123
+ return f"Error: {data['error']}"
124
+ return json.dumps(data, indent=2, ensure_ascii=False)
125
+
126
+
127
+ # ── MCP Server ────────────────────────────────────────────────────────────────
128
+
129
+ server = Server("dosync-hub")
130
+
131
+
132
+ async def _intent_property_schema() -> dict:
133
+ """Build the JSON-schema fragment for the `intent` argument by reading the
134
+ intent classes the hub declares. Returns a property dict either with a live
135
+ `enum` (hub reachable) or a plain string (hub unreachable — degrade gracefully;
136
+ the hub validates anyway). Also surfaces, in the description, which intents are
137
+ compositions so the AI knows they need geographic context."""
138
+ base_desc = "Clase de intención semántica (declarada en el hub)"
139
+ try:
140
+ listing = await hub_request("GET", "/v1/intent-classes")
141
+ classes = listing.get("intent_classes") if isinstance(listing, dict) else None
142
+ if classes:
143
+ names = [c.get("name") for c in classes if c.get("name")]
144
+ # Note which ones are compositions — they need structured context.
145
+ composites = [c.get("name") for c in classes
146
+ if c.get("name") and c.get("composition_kind")]
147
+ desc = base_desc
148
+ if composites:
149
+ desc += (". Composition intents " + ", ".join(sorted(composites))
150
+ + " require geographic context (e.g. center=[lat,lon], "
151
+ "radius_m, altitude_m) passed in the 'context' object.")
152
+ if names:
153
+ return {"type": "string", "description": desc, "enum": sorted(names)}
154
+ except Exception:
155
+ pass
156
+ # Fallback: hub unreachable. Free-form string — the hub validates on fire.
157
+ return {"type": "string",
158
+ "description": base_desc + " (hub no consultado — el hub validará)"}
159
+
160
+
161
+ @server.list_tools()
162
+ async def list_tools() -> list[types.Tool]:
163
+ """Define las herramientas disponibles para el LLM."""
164
+ # Read the available intent classes from the hub — the single source of truth.
165
+ # The hub declares them in /v1/intent-classes; the MCP reflects that rather than
166
+ # carrying its own hardcoded copy (which inevitably diverges — that is how
167
+ # inspect_area was invisible to the AI). This is the protocol's "everything is
168
+ # declared" principle applied to the AI layer: a new intent declared on the hub
169
+ # appears here with no code change. The enum is only a guide for the AI; the hub
170
+ # is the real validator (it returns 422 for an unregistered intent), so if the
171
+ # hub is unreachable when describing tools we degrade gracefully to a free-form
172
+ # string and the AI can still fire a known intent by name.
173
+ intent_schema = await _intent_property_schema()
174
+ return [
175
+
176
+ types.Tool(
177
+ name="dosync_fire_intent",
178
+ description=(
179
+ "Ejecuta una intención semántica en el hub DoSync. "
180
+ "El hub resuelve automáticamente qué dispositivos actúan y cómo, "
181
+ "basándose en sus capacidades declaradas. "
182
+ "Usar para: emergencias, rutinas, control del ambiente, notificaciones."
183
+ ),
184
+ inputSchema={
185
+ "type": "object",
186
+ "properties": {
187
+ "intent": intent_schema,
188
+ "urgency": {
189
+ "type": "string",
190
+ "description": "Urgency level: info=routine, alert=high-priority (action likely), emergency=bypass all policies (immediate execution)",
191
+ "enum": ["info", "warning", "alert", "emergency"],
192
+ "default": "info",
193
+ },
194
+ "context": {
195
+ "type": "object",
196
+ "description": (
197
+ "Structured context passed to the intent's resolver. "
198
+ "Free-form by design — each intent reads what it needs. "
199
+ "Composition intents (e.g. inspect_area) need geographic "
200
+ "fields: device_id (the vehicle), center=[lat,lon], "
201
+ "radius_m, altitude_m. Home intents may use location, "
202
+ "message, etc. The hub and resolver interpret it."
203
+ ),
204
+ },
205
+ "subject": {
206
+ "type": "string",
207
+ "description": "Sujeto de la intención (ej: 'abuela', 'niños')",
208
+ },
209
+ "message": {
210
+ "type": "string",
211
+ "description": "Mensaje a incluir en notificaciones",
212
+ },
213
+ "location": {
214
+ "type": "string",
215
+ "description": "Ubicación relevante (ej: 'dormitorio', 'sala')",
216
+ },
217
+ },
218
+ "required": ["intent"],
219
+ },
220
+ ),
221
+
222
+ types.Tool(
223
+ name="dosync_list_devices",
224
+ description=(
225
+ "Lista todos los dispositivos registrados en el hub DoSync "
226
+ "con sus capacidades, tags, y estado del adapter."
227
+ ),
228
+ inputSchema={
229
+ "type": "object",
230
+ "properties": {
231
+ "filter_tag": {
232
+ "type": "string",
233
+ "description": "Filtrar por tag (ej: 'emergency', 'light', 'sensor')",
234
+ },
235
+ "emergency_only": {
236
+ "type": "boolean",
237
+ "description": "Mostrar solo dispositivos con emergency_capable=true",
238
+ "default": False,
239
+ },
240
+ },
241
+ },
242
+ ),
243
+
244
+ types.Tool(
245
+ name="dosync_get_status",
246
+ description=(
247
+ "Obtiene el estado actual del hub DoSync: "
248
+ "cantidad de dispositivos, ocupación del hogar, "
249
+ "integridad del audit log, y estadísticas de la base de datos."
250
+ ),
251
+ inputSchema={
252
+ "type": "object",
253
+ "properties": {},
254
+ },
255
+ ),
256
+
257
+ types.Tool(
258
+ name="dosync_send_event",
259
+ description=(
260
+ "Envía un evento desde un dispositivo al hub. "
261
+ "Usar para simular eventos de sensores (caída detectada, "
262
+ "movimiento, avería, etc.) o para integrar dispositivos "
263
+ "que no tienen adapter nativo."
264
+ ),
265
+ inputSchema={
266
+ "type": "object",
267
+ "properties": {
268
+ "device_id": {
269
+ "type": "string",
270
+ "description": "ID del dispositivo que emite el evento",
271
+ },
272
+ "event_id": {
273
+ "type": "string",
274
+ "description": "Tipo de evento (ej: 'fall_detected', 'malfunction', 'motion')",
275
+ },
276
+ "severity": {
277
+ "type": "string",
278
+ "description": "Event severity: info=normal, warning=notable, alert=requires attention, emergency=critical",
279
+ "enum": ["info", "warning", "alert", "emergency"],
280
+ "default": "info",
281
+ },
282
+ "data": {
283
+ "type": "object",
284
+ "description": "Datos adicionales del evento",
285
+ },
286
+ },
287
+ "required": ["device_id", "event_id"],
288
+ },
289
+ ),
290
+
291
+ types.Tool(
292
+ name="dosync_get_audit_log",
293
+ description=(
294
+ "Obtiene las últimas entradas del audit log del hub. "
295
+ "El log es tamper-evident: cada entrada está encadenada "
296
+ "con SHA-256. Incluye verificación de integridad."
297
+ ),
298
+ inputSchema={
299
+ "type": "object",
300
+ "properties": {
301
+ "last_n": {
302
+ "type": "integer",
303
+ "description": "Cantidad de entradas a mostrar (default: 10)",
304
+ "default": 10,
305
+ },
306
+ },
307
+ },
308
+ ),
309
+
310
+ types.Tool(
311
+ name="dosync_get_scenarios",
312
+ description=(
313
+ "Devuelve la lista de escenarios disponibles en DoSync "
314
+ "con descripción de cuándo usar cada uno."
315
+ ),
316
+ inputSchema={
317
+ "type": "object",
318
+ "properties": {},
319
+ },
320
+ ),
321
+
322
+ types.Tool(
323
+ name="dosync_control_device",
324
+ description=(
325
+ "Control directo de un dispositivo específico. "
326
+ "Usar cuando el usuario pide controlar un dispositivo concreto: "
327
+ "'apagá las luces', 'poné las luces en rojo', 'subí el brillo', etc. "
328
+ "Para luces WiZ soporta: turn_on, turn_off, set_brightness (0-100), "
329
+ "set_color (r/g/b 0-255), set_color_temp (kelvin 2200-6500). "
330
+ "Para apagar TODAS las luces, llamar con device_id='all_lights'."
331
+ ),
332
+ inputSchema={
333
+ "type": "object",
334
+ "properties": {
335
+ "device_id": {
336
+ "type": "string",
337
+ "description": "ID del dispositivo o 'all_lights' para todas las luces",
338
+ },
339
+ "action": {
340
+ "type": "string",
341
+ "description": "Acción a ejecutar",
342
+ "enum": ["turn_on", "turn_off", "set_brightness",
343
+ "set_color", "set_color_temp", "set_effect"],
344
+ },
345
+ "brightness": {
346
+ "type": "integer",
347
+ "description": "Brillo 0-100 (para set_brightness o turn_on)",
348
+ },
349
+ "r": {"type": "integer", "description": "Rojo 0-255"},
350
+ "g": {"type": "integer", "description": "Verde 0-255"},
351
+ "b": {"type": "integer", "description": "Azul 0-255"},
352
+ "kelvin": {
353
+ "type": "integer",
354
+ "description": "Temperatura de color en Kelvin (2200-6500)",
355
+ },
356
+ "effect": {
357
+ "type": "string",
358
+ "description": "Efecto Ambilight. Valores: FOLLOW_COLOR: HOT_LAVA, FOLLOW_COLOR: DEEP_WATER, FOLLOW_COLOR: FRESH_NATURE, FOLLOW_VIDEO: STANDARD, FOLLOW_VIDEO: VIVID, FOLLOW_AUDIO: ENERGY_ADAPTIVE_BRIGHTNESS, Mode: lounge",
359
+ },
360
+ },
361
+ "required": ["device_id", "action"],
362
+ },
363
+ ),
364
+
365
+ ]
366
+
367
+
368
+ @server.call_tool()
369
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
370
+ """Ejecuta una herramienta y retorna el resultado al LLM."""
371
+
372
+ # ── dosync_fire_intent ────────────────────────────────────────────────────
373
+ if name == "dosync_fire_intent":
374
+ intent = arguments.get("intent")
375
+ urgency = arguments.get("urgency", "info")
376
+ subject = arguments.get("subject")
377
+ message = arguments.get("message", "")
378
+ location = arguments.get("location", "")
379
+ # Arbitrary structured context the AI fills per intent (center/radius_m/
380
+ # altitude_m for a composition, etc.). Passed through to the hub as-is.
381
+ ctx = dict(arguments.get("context") or {})
382
+
383
+ # Merge the home-automation convenience fields into context for backward
384
+ # compatibility, without overwriting anything the AI put in `context`.
385
+ for k, v in (("message", message), ("location", location)):
386
+ if v and k not in ctx:
387
+ ctx[k] = v
388
+ ctx.setdefault("trigger", "mcp_client")
389
+
390
+ body = {
391
+ "intent": intent,
392
+ "urgency": urgency,
393
+ "subject": subject,
394
+ "source": "mcp",
395
+ "context": ctx,
396
+ }
397
+
398
+ # Fire async — returns intent_id immediately, no blocking
399
+ fire_result = await hub_request("POST", "/v1/intent/async", body)
400
+
401
+ if "error" in fire_result:
402
+ return [types.TextContent(type="text",
403
+ text=f"❌ Error ejecutando intent '{intent}': {fire_result['error']}")]
404
+
405
+ intent_id = fire_result.get("intent_id")
406
+ if not intent_id:
407
+ return [types.TextContent(type="text",
408
+ text=f"❌ Hub no retornó intent_id para '{intent}'")]
409
+
410
+ # Poll until completed or timeout
411
+ # Timeout = DOSYNC_INTENT_TIMEOUT + 3s network margin
412
+ poll_timeout = MCP_EMERGENCY_TIMEOUT if urgency == "emergency" else MCP_DEFAULT_TIMEOUT
413
+ import time as _mcp_time
414
+ deadline = _mcp_time.monotonic() + poll_timeout
415
+ result = None
416
+
417
+ while _mcp_time.monotonic() < deadline:
418
+ await asyncio.sleep(POLL_INTERVAL)
419
+ poll = await hub_request("GET", f"/v1/intent/{intent_id}")
420
+ if "error" in poll:
421
+ break
422
+ if poll.get("status") != "pending":
423
+ result = poll
424
+ break
425
+
426
+ # Polling timeout — make one final aggressive attempt before giving up
427
+ if result is None:
428
+ for _ in range(3):
429
+ await asyncio.sleep(1.5)
430
+ final_poll = await hub_request("GET", f"/v1/intent/{intent_id}")
431
+ if not final_poll.get("error") and final_poll.get("status") != "pending":
432
+ result = final_poll
433
+ break
434
+
435
+ # Still no result before the deadline — but "no final result" is NOT "no
436
+ # information". MCP-V13: read the partial progress the hub has been
437
+ # publishing and report what ALREADY happened, instead of an opaque
438
+ # "still processing". In an emergency, "8 actions already executed, 2
439
+ # devices still pending" is the difference between a useful answer and a
440
+ # blind one.
441
+ if result is None:
442
+ last = await hub_request("GET", f"/v1/intent/{intent_id}")
443
+ partial = (last or {}).get("partial") or {}
444
+ done = partial.get("results", [])
445
+ n_done = partial.get("actions_completed", len(done))
446
+ ok_done = [r for r in done if r.get("success")]
447
+
448
+ text = f"⏳ Intent '{intent}' [{urgency}] accepted — still executing after "
449
+ text += f"{poll_timeout + 4.5:.0f}s, partial result below\n"
450
+ if n_done:
451
+ text += f" Already completed: {len(ok_done)}/{n_done} action(s) succeeded so far\n"
452
+ for r in ok_done[:8]:
453
+ text += f" ✓ {r.get('device_id')} — {r.get('action')}\n"
454
+ slow = [r for r in done if not r.get("success")]
455
+ if slow:
456
+ text += f" Still pending / unreachable: {len(slow)} device(s)\n"
457
+ else:
458
+ text += f" No actions have completed yet — the hub is still resolving or every device is slow.\n"
459
+ text += f" Intent ID: {intent_id} — poll GET /v1/intent/{{id}} for the final result.\n"
460
+ return [types.TextContent(type="text", text=text)]
461
+
462
+ actions = result.get("actions_taken", 0)
463
+ failed = result.get("failed_devices", [])
464
+ aborted = result.get("aborted_devices", [])
465
+ results_list = result.get("results", [])
466
+ intent_status = result.get("status", "unknown")
467
+
468
+ # Determine display icon based on what actually happened:
469
+ # - success / partial with some actions → ✅
470
+ # - partial with 0 actions (all unreachable) → ⚠️ with clear explanation
471
+ # - failed / blocked → ❌
472
+ if intent_status == "success" or (intent_status in ("partial", "partial_abort") and actions > 0):
473
+ icon = "✅"
474
+ elif intent_status in ("partial", "partial_abort", "failed") and failed:
475
+ icon = "⚠️" # devices unreachable — protocol worked, hardware was off
476
+ else:
477
+ icon = "❌"
478
+
479
+ text = f"{icon} Intent '{intent}' [{urgency}] ejecutado\n"
480
+ text += f" Acciones completadas: {actions}\n"
481
+
482
+ if failed:
483
+ text += f" Sin respuesta ({len(failed)} dispositivos físicamente apagados) — excluidos automáticamente por ~30 min\n"
484
+ if aborted:
485
+ text += f" Cancelados por FailurePolicy: {len(aborted)} dispositivos\n"
486
+
487
+ critical = [r for r in results_list
488
+ if r.get("success") and r.get("action") in
489
+ ("unlock","alarm","call","notify","turn_on","set_brightness")]
490
+ if critical:
491
+ text += "\nAcciones críticas ejecutadas:\n"
492
+ for r in critical[:8]:
493
+ resp = r.get("response", {})
494
+ status_val = resp.get("status","ok") if isinstance(resp, dict) else "ok"
495
+ text += f" ✓ [{r['device_id']}] {r['action']} → {status_val}\n"
496
+
497
+ return [types.TextContent(type="text", text=text)]
498
+ elif name == "dosync_list_devices":
499
+ filter_tag = arguments.get("filter_tag", "")
500
+ emergency_only = arguments.get("emergency_only", False)
501
+
502
+ result = await hub_request("GET", "/v1/devices")
503
+
504
+ if "error" in result:
505
+ return [types.TextContent(type="text", text=f"Error: {result['error']}")]
506
+
507
+ devices = result.get("devices", [])
508
+
509
+ # Aplicar filtros
510
+ if filter_tag:
511
+ devices = [d for d in devices if filter_tag in d.get("tags", [])]
512
+ if emergency_only:
513
+ devices = [d for d in devices if d.get("emergency_capable")]
514
+
515
+ if not devices:
516
+ return [types.TextContent(type="text",
517
+ text="No se encontraron dispositivos con los filtros aplicados.")]
518
+
519
+ text = f"📡 {len(devices)} dispositivo(s) registrado(s):\n\n"
520
+ for d in devices:
521
+ emerg = "🚨 " if d.get("emergency_capable") else " "
522
+ adapter = d.get("adapter") or "simulated"
523
+ tags = ", ".join(d.get("tags", []))
524
+ caps = d.get("capabilities", {})
525
+ acts = [a["type"] for a in caps.get("actuators", [])]
526
+ text += f"{emerg}{d['device_name']} [{d['device_id']}]\n"
527
+ text += f" Adapter: {adapter}\n"
528
+ text += f" Tags: {tags}\n"
529
+ if acts:
530
+ text += f" Acciones: {', '.join(acts)}\n"
531
+ text += "\n"
532
+
533
+ return [types.TextContent(type="text", text=text)]
534
+
535
+ # ── dosync_get_status ─────────────────────────────────────────────────────
536
+ elif name == "dosync_get_status":
537
+ result = await hub_request("GET", "/v1/status")
538
+
539
+ if "error" in result:
540
+ return [types.TextContent(type="text", text=f"Error: {result['error']}")]
541
+
542
+ occupied = result.get("occupied", False)
543
+ ws_clients = result.get("ws_connections", 0)
544
+ db = result.get("db", {})
545
+ integrity = result.get("audit_integrity", True)
546
+
547
+ text = f"🏠 DoSync Hub v{result.get('version', '?')}\n\n"
548
+ text += f" Protocolo: {result.get('protocol', '?')}\n"
549
+ text += f" Dispositivos: {result.get('devices', 0)}\n"
550
+ text += f" Ocupación: {'Sí, hay alguien en casa' if occupied else 'Casa vacía'}\n"
551
+ text += f" Audit log: {result.get('audit_entries', 0)} entradas "
552
+ text += f"({'✓ íntegro' if integrity else '✗ comprometido'})\n"
553
+ text += f" WS clientes: {ws_clients}\n"
554
+ text += f" DB: {db.get('db_size_kb', '?')} KB en {db.get('db_path', '?')}\n"
555
+ if result.get("family_profile"):
556
+ text += f" Perfil: Familia {result['family_profile']}\n"
557
+
558
+ return [types.TextContent(type="text", text=text)]
559
+
560
+ # ── dosync_send_event ─────────────────────────────────────────────────────
561
+ elif name == "dosync_send_event":
562
+ body = {
563
+ "device_id": arguments.get("device_id"),
564
+ "event_id": arguments.get("event_id"),
565
+ "severity": arguments.get("severity", "info"),
566
+ "data": arguments.get("data", {}),
567
+ }
568
+
569
+ result = await hub_request("POST", "/v1/event", body)
570
+
571
+ if "error" in result:
572
+ text = f"❌ Error: {result['error']}"
573
+ else:
574
+ text = (f"✅ Evento recibido por el hub:\n"
575
+ f" Dispositivo: {result.get('device_id')}\n"
576
+ f" Evento: {result.get('event_id')}\n"
577
+ f" Severidad: {result.get('severity')}\n")
578
+
579
+ return [types.TextContent(type="text", text=text)]
580
+
581
+ # ── dosync_get_audit_log ──────────────────────────────────────────────────
582
+ elif name == "dosync_get_audit_log":
583
+ last_n = arguments.get("last_n", 10)
584
+ result = await hub_request("GET", "/v1/audit")
585
+
586
+ if "error" in result:
587
+ return [types.TextContent(type="text", text=f"Error: {result['error']}")]
588
+
589
+ entries = result.get("entries", [])
590
+ integrity = result.get("integrity", True)
591
+ total = result.get("count", 0)
592
+
593
+ text = f"📋 Audit Log DoSync\n"
594
+ text += f" Total: {total} entradas | "
595
+ text += f"Integridad: {'✓ íntegra' if integrity else '✗ comprometida'}\n\n"
596
+
597
+ # Mostrar las últimas N
598
+ for entry in list(reversed(entries))[:last_n]:
599
+ kind = entry.get("type", "?")
600
+ hash_ = entry.get("hash", "")[:10]
601
+ extra = ""
602
+ if kind == "intent_executed":
603
+ extra = f" → {entry.get('intent')} [{entry.get('urgency')}]"
604
+ elif kind == "device_event":
605
+ extra = f" → {entry.get('device_id')}: {entry.get('event_id')}"
606
+ elif kind == "phase_executed":
607
+ extra = f" → fase '{entry.get('phase')}'"
608
+ elif kind == "presence_updated":
609
+ conf = entry.get('occ_confidence', 0)
610
+ extra = f" → occupied={entry.get('occupied')} conf={conf:.0%}"
611
+ text += f" [{hash_}] {kind}{extra}\n"
612
+
613
+ return [types.TextContent(type="text", text=text)]
614
+
615
+ # ── dosync_get_scenarios ──────────────────────────────────────────────────
616
+ elif name == "dosync_get_scenarios":
617
+ text = """🏠 Escenarios disponibles en DoSync:
618
+
619
+ EMERGENCIAS (urgency=emergency):
620
+ ensure_safety — Alguien se cayó, hay peligro. Abre puertas, llama emergencias, activa alarma.
621
+ (+ send_event) — Enviar evento smoke_detected para emergencia de incendio (3 fases).
622
+
623
+ NOTIFICACIONES (urgency=warning/info):
624
+ notify — Push information to any target (people, displays, channels).
625
+ alert_anomaly — Consumo eléctrico anormal, algo inesperado detectado.
626
+ remind_chore — Recordar una tarea doméstica (lavarropas terminó, etc.).
627
+
628
+ ENERGÍA:
629
+ save_energy — Nadie en casa, activar modo ahorro (luces, clima).
630
+ away_mode — Todos salieron, armar seguridad y bajar consumo.
631
+
632
+ RUTINAS:
633
+ morning_routine — Buenos días: persianas, cafetera, clima.
634
+ bedtime_routine — Hora de dormir: atenuar luces, bajar persianas.
635
+ # Domain-specific intents (e.g. children arrival, shift change) can be registered via POST /v1/intent-classes
636
+
637
+ AMBIENTE:
638
+ set_environment — Ajustar luces, temperatura, persianas.
639
+ control_access — Bloquear/desbloquear puertas.
640
+ monitor_health — Activar monitoreo continuo de una persona.
641
+
642
+ INFORMACIÓN:
643
+ report_status — Leer el estado de todos los sensores.
644
+
645
+ Niveles de urgencia:
646
+ info — rutinas, recordatorios
647
+ warning — alertas no críticas
648
+ alert — situación que requiere atención
649
+ emergency — actúa inmediatamente sin confirmación
650
+ """
651
+ return [types.TextContent(type="text", text=text)]
652
+
653
+ # ── dosync_control_device ─────────────────────────────────────────────────
654
+ elif name == "dosync_control_device":
655
+ device_id = arguments.get("device_id")
656
+ action = arguments.get("action")
657
+ params = {}
658
+ if "brightness" in arguments: params["brightness"] = arguments["brightness"]
659
+ if "r" in arguments: params["r"] = arguments["r"]
660
+ if "g" in arguments: params["g"] = arguments["g"]
661
+ if "b" in arguments: params["b"] = arguments["b"]
662
+ if "kelvin" in arguments: params["kelvin"] = arguments["kelvin"]
663
+ if "effect" in arguments: params["effect"] = arguments["effect"]
664
+
665
+ # all_lights: get all light devices and control them
666
+ if device_id == "all_lights":
667
+ devices_result = await hub_request("GET", "/v1/devices")
668
+ if "error" in devices_result:
669
+ return [types.TextContent(type="text",
670
+ text=f"Error obteniendo dispositivos: {devices_result['error']}")]
671
+
672
+ light_devices = [
673
+ d["device_id"] for d in devices_result.get("devices", [])
674
+ if any(t in d.get("tags", []) for t in ["light", "wiz"])
675
+ ]
676
+
677
+ if not light_devices:
678
+ return [types.TextContent(type="text",
679
+ text="No se encontraron dispositivos de luz registrados.")]
680
+
681
+ results = []
682
+ for did in light_devices:
683
+ body = {"device_id": did, "action": action, "params": params}
684
+ r = await hub_request("POST", "/v1/device/action", body)
685
+ icon = "✓" if r.get("success") and not r.get("error") else "✗"
686
+ results.append(f" {icon} {did}")
687
+
688
+ icon_on = "✅" if action == "turn_on" else "🌑"
689
+ text = f"{icon_on} {action} en {len(light_devices)} luces:\n"
690
+ text += "\n".join(results)
691
+ return [types.TextContent(type="text", text=text)]
692
+
693
+ # Single device
694
+ body = {"device_id": device_id, "action": action, "params": params}
695
+ result = await hub_request("POST", "/v1/device/action", body)
696
+
697
+ if result.get("error"):
698
+ text = f"❌ Error: {result['error']}"
699
+ elif result.get("success"):
700
+ text = f"✅ {device_id}: {action} ejecutado correctamente"
701
+ else:
702
+ text = f"⚠️ {device_id}: {action} falló"
703
+
704
+ return [types.TextContent(type="text", text=text)]
705
+
706
+ else:
707
+ return [types.TextContent(
708
+ type="text",
709
+ text=f"Herramienta desconocida: {name}",
710
+ )]
711
+
712
+
713
+ # ── Main ──────────────────────────────────────────────────────────────────────
714
+
715
+ async def main():
716
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
717
+
718
+ if not HUB_TOKEN:
719
+ log.warning(
720
+ "DOSYNC_TOKEN no configurado — los requests al hub pueden fallar "
721
+ "si la autenticación está habilitada. "
722
+ "Usar: DOSYNC_AUTH=false para deshabilitar, o "
723
+ "DOSYNC_TOKEN=<token> para autenticar."
724
+ )
725
+
726
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
727
+ from mcp.server.models import InitializationOptions
728
+ from mcp.server.lowlevel.server import NotificationOptions
729
+ caps = server.get_capabilities(
730
+ notification_options=NotificationOptions(),
731
+ experimental_capabilities={},
732
+ )
733
+
734
+ await server.run(
735
+ read_stream,
736
+ write_stream,
737
+ InitializationOptions(
738
+ server_name="dosync-hub",
739
+ server_version="0.1.0",
740
+ capabilities=caps,
741
+ ),
742
+ )
743
+
744
+
745
+ if __name__ == "__main__":
746
+ asyncio.run(main())