Init commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
COMFYUI_URL=http://192.168.1.13:8188
|
||||
COMFYUI_TIMEOUT=300
|
||||
WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows
|
||||
COMFY_PROMPT_NODE=6
|
||||
COMFY_VISION_IMAGE_NODE=1
|
||||
COMFY_VISION_PROMPT_NODE=2
|
||||
ALFRED_RENDER_URL=http://127.0.0.1:54321/api/render
|
||||
@@ -0,0 +1,6 @@
|
||||
COMFYUI_URL=http://192.168.1.13:8188
|
||||
COMFYUI_TIMEOUT=300
|
||||
WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows
|
||||
COMFY_PROMPT_NODE=6
|
||||
COMFY_VISION_IMAGE_NODE=1
|
||||
COMFY_VISION_PROMPT_NODE=2
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP_comfyui
|
||||
|
||||
Copier `.env.example` vers `.env`, puis créer le venv et installer `requirements.txt`.
|
||||
@@ -0,0 +1,151 @@
|
||||
"""MCP ComfyUI pour Hermes avec transmission d'images vers l'interface Alfred.
|
||||
|
||||
Fonctionne avec un workflow API ComfyUI possédant un nœud de sauvegarde d'image.
|
||||
L'envoi HTTP à Alfred est indépendant de la réponse textuelle d'Hermes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
load_dotenv(Path(__file__).with_name(".env"))
|
||||
|
||||
mcp = FastMCP("Alfred ComfyUI")
|
||||
BASE = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||
WF = Path(os.getenv("WORKFLOWS_DIR", str(Path(__file__).parent / "workflows")))
|
||||
ALFRED_RENDER_URL = os.getenv(
|
||||
"ALFRED_RENDER_URL", "http://127.0.0.1:54321/api/render"
|
||||
)
|
||||
ALFRED_RENDER_TOKEN = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
|
||||
|
||||
def submit(workflow: dict) -> str:
|
||||
response = requests.post(
|
||||
f"{BASE}/prompt",
|
||||
json={"prompt": workflow, "client_id": str(uuid.uuid4())},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["prompt_id"]
|
||||
|
||||
|
||||
def wait(prompt_id: str) -> dict:
|
||||
deadline = time.monotonic() + int(os.getenv("COMFYUI_TIMEOUT", "300"))
|
||||
while time.monotonic() < deadline:
|
||||
response = requests.get(f"{BASE}/history/{prompt_id}", timeout=15)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if prompt_id in result:
|
||||
return result[prompt_id]
|
||||
time.sleep(1)
|
||||
raise TimeoutError(f"ComfyUI timeout ({prompt_id})")
|
||||
|
||||
|
||||
def extract_images(history: dict) -> list[dict]:
|
||||
"""Construit les URL de /view depuis les sorties ComfyUI (SaveImage)."""
|
||||
images = []
|
||||
for node_id, output in history.get("outputs", {}).items():
|
||||
for image in output.get("images", []):
|
||||
filename = image.get("filename")
|
||||
if not filename:
|
||||
continue
|
||||
subfolder = image.get("subfolder", "")
|
||||
image_type = image.get("type", "output")
|
||||
query = urlencode(
|
||||
{
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": image_type,
|
||||
}
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"node_id": str(node_id),
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": image_type,
|
||||
"url": f"{BASE}/view?{query}",
|
||||
}
|
||||
)
|
||||
return images
|
||||
|
||||
|
||||
def show_on_alfred(image: dict, caption: str) -> tuple[bool, str | None]:
|
||||
"""Ne fait pas échouer la génération si Alfred est hors ligne."""
|
||||
headers = {}
|
||||
if ALFRED_RENDER_TOKEN:
|
||||
headers["X-Alfred-Render-Token"] = ALFRED_RENDER_TOKEN
|
||||
try:
|
||||
response = requests.post(
|
||||
ALFRED_RENDER_URL,
|
||||
json={
|
||||
"type": "image",
|
||||
"url": image["url"],
|
||||
"alt": "Image générée par ComfyUI",
|
||||
"caption": caption,
|
||||
},
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True, None
|
||||
except requests.RequestException as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def generate_image(prompt: str) -> dict:
|
||||
"""Génère une image par ComfyUI et l'affiche automatiquement sur Alfred.
|
||||
|
||||
Utiliser cet outil lorsque l'utilisateur demande de créer/générer une image.
|
||||
Ne pas prétendre que l'image a été affichée si displayed_on_alfred est faux.
|
||||
"""
|
||||
prompt = str(prompt).strip()
|
||||
if not prompt:
|
||||
raise ValueError("Le prompt de génération est vide")
|
||||
|
||||
workflow = json.loads((WF / "text_to_image.json").read_text(encoding="utf-8"))
|
||||
prompt_node = os.getenv("COMFY_PROMPT_NODE", "6")
|
||||
workflow[prompt_node]["inputs"]["text"] = prompt
|
||||
|
||||
prompt_id = submit(workflow)
|
||||
history = wait(prompt_id)
|
||||
images = extract_images(history)
|
||||
if not images:
|
||||
raise RuntimeError(
|
||||
"ComfyUI a terminé sans image de sortie. "
|
||||
"Vérifie le nœud SaveImage du workflow."
|
||||
)
|
||||
|
||||
displayed, display_error = show_on_alfred(
|
||||
images[0], f"Image générée : {prompt}"
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"prompt_id": prompt_id,
|
||||
"prompt": prompt,
|
||||
"images": images,
|
||||
"first_image_url": images[0]["url"],
|
||||
"displayed_on_alfred": displayed,
|
||||
"display_error": display_error,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def analyze_image(image_path: str, question: str) -> dict:
|
||||
"""Analyse une image via le workflow VLM configuré (inchangé)."""
|
||||
workflow = json.loads((WF / "vision_analyze.json").read_text(encoding="utf-8"))
|
||||
workflow[os.getenv("COMFY_VISION_IMAGE_NODE", "1")]["inputs"]["image"] = image_path
|
||||
workflow[os.getenv("COMFY_VISION_PROMPT_NODE", "2")]["inputs"]["text"] = question
|
||||
return wait(submit(workflow))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,100 @@
|
||||
import os, json, time, uuid, requests
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
load_dotenv()
|
||||
|
||||
mcp = FastMCP("Alfred ComfyUI")
|
||||
BASE = os.getenv("COMFYUI_URL", "http://127.0.0.1:8188").rstrip("/")
|
||||
WF = Path(os.getenv("WORKFLOWS_DIR", Path(__file__).parent / "workflows"))
|
||||
|
||||
|
||||
def submit(workflow):
|
||||
r = requests.post(
|
||||
BASE + "/prompt",
|
||||
json={
|
||||
"prompt": workflow,
|
||||
"client_id": str(uuid.uuid4())
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["prompt_id"]
|
||||
|
||||
|
||||
def wait(pid):
|
||||
deadline = time.time() + int(os.getenv("COMFYUI_TIMEOUT", "300"))
|
||||
while time.time() < deadline:
|
||||
h = requests.get(f"{BASE}/history/{pid}", timeout=15).json()
|
||||
if pid in h:
|
||||
return h[pid]
|
||||
time.sleep(1)
|
||||
raise TimeoutError("ComfyUI timeout")
|
||||
|
||||
|
||||
def build_image_url(filename, subfolder="", img_type="output"):
|
||||
return (
|
||||
f"{BASE}/view?"
|
||||
f"filename={quote(str(filename))}"
|
||||
f"&subfolder={quote(str(subfolder))}"
|
||||
f"&type={quote(str(img_type))}"
|
||||
)
|
||||
|
||||
|
||||
def extract_images(history):
|
||||
images = []
|
||||
|
||||
outputs = history.get("outputs", {})
|
||||
for node_id, node_output in outputs.items():
|
||||
for img in node_output.get("images", []):
|
||||
filename = img.get("filename")
|
||||
subfolder = img.get("subfolder", "")
|
||||
img_type = img.get("type", "output")
|
||||
|
||||
if not filename:
|
||||
continue
|
||||
|
||||
images.append({
|
||||
"node_id": node_id,
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": img_type,
|
||||
"url": build_image_url(filename, subfolder, img_type)
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def generate_image(prompt: str):
|
||||
"""Generate an image with the locked text_to_image workflow."""
|
||||
w = json.loads((WF / "text_to_image.json").read_text())
|
||||
node = os.getenv("COMFY_PROMPT_NODE", "6")
|
||||
w[node]["inputs"]["text"] = prompt
|
||||
|
||||
history = wait(submit(w))
|
||||
images = extract_images(history)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"Image générée pour : {prompt}",
|
||||
"prompt": prompt,
|
||||
"images": images,
|
||||
"first_image_url": images[0]["url"] if images else None,
|
||||
"first_image_filename": images[0]["filename"] if images else None
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def analyze_image(image_path: str, question: str):
|
||||
"""Analyze a camera/local image using the locked VLM workflow. Workflow node IDs are configured in .env."""
|
||||
w = json.loads((WF / "vision_analyze.json").read_text())
|
||||
w[os.getenv("COMFY_VISION_IMAGE_NODE", "1")]["inputs"]["image"] = image_path
|
||||
w[os.getenv("COMFY_VISION_PROMPT_NODE", "2")]["inputs"]["text"] = question
|
||||
return wait(submit(w))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,3 @@
|
||||
mcp>=1.12
|
||||
requests>=2.32
|
||||
python-dotenv>=1.0
|
||||
@@ -0,0 +1 @@
|
||||
Exporter depuis ComfyUI les workflows API JSON et les nommer `text_to_image.json` et `vision_analyze.json`. Les IDs de nœuds sont configurables dans `.env`.
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 156680208700286,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"ckpt_name": "absolutereality_v181.safetensors"
|
||||
},
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {
|
||||
"title": "Load Checkpoint"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "Empty Latent Image"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "text, watermark",
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 156680208700286,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"7",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"5",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "KSampler"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"ckpt_name": "absolutereality_v181.safetensors"
|
||||
},
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {
|
||||
"title": "Load Checkpoint"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "Empty Latent Image"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "text, watermark",
|
||||
"clip": [
|
||||
"4",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Prompt)"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": [
|
||||
"8",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "Save Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
FACE_DB=/home/hermes/faces
|
||||
FACE_TOLERANCE=0.48
|
||||
CAMERA_INDEX=0
|
||||
@@ -0,0 +1,3 @@
|
||||
FACE_DB=/home/hermes/faces
|
||||
FACE_TOLERANCE=0.48
|
||||
CAMERA_INDEX=0
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP_face_recognition
|
||||
|
||||
Copier `.env.example` vers `.env`, puis créer le venv et installer `requirements.txt`.
|
||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
import os, cv2, face_recognition
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
load_dotenv();mcp=FastMCP("Alfred Face Recognition");DB=Path(os.getenv('FACE_DB','/home/hermes/faces'));T=float(os.getenv('FACE_TOLERANCE','0.48'))
|
||||
def encodings():
|
||||
known=[]
|
||||
for person in DB.iterdir() if DB.exists() else []:
|
||||
if not person.is_dir():continue
|
||||
for f in person.glob('*'):
|
||||
try:
|
||||
im=face_recognition.load_image_file(str(f)); es=face_recognition.face_encodings(im)
|
||||
if es:known.append((person.name,es[0]))
|
||||
except Exception:pass
|
||||
return known
|
||||
@mcp.tool()
|
||||
def identify_people(camera_index:int=0):
|
||||
cap=cv2.VideoCapture(camera_index);ok,frame=cap.read();cap.release()
|
||||
if not ok:raise RuntimeError('Caméra indisponible')
|
||||
rgb=cv2.cvtColor(frame,cv2.COLOR_BGR2RGB); locs=face_recognition.face_locations(rgb); es=face_recognition.face_encodings(rgb,locs); known=encodings();out=[]
|
||||
for e in es:
|
||||
name='inconnu';best=T
|
||||
for n,k in known:
|
||||
d=face_recognition.face_distance([k],e)[0]
|
||||
if d<best:name,best=n,d
|
||||
out.append({'name':name,'distance':float(best)})
|
||||
return {'count':len(out),'people':out,'unknown_count':sum(x['name']=='inconnu' for x in out)}
|
||||
if __name__=='__main__':mcp.run()
|
||||
@@ -0,0 +1,4 @@
|
||||
mcp>=1.12
|
||||
python-dotenv>=1.0
|
||||
face-recognition>=1.3
|
||||
opencv-python-headless>=4.10
|
||||
@@ -0,0 +1,2 @@
|
||||
READ_ROOTS=/Disques/Divers/
|
||||
WRITE_ROOT=/home/hermes/alfred-data
|
||||
@@ -0,0 +1,2 @@
|
||||
READ_ROOTS=/mnt/nas/music:/mnt/nas/documents
|
||||
WRITE_ROOT=/home/hermes/alfred-data
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP_files
|
||||
|
||||
Copier `.env.example` vers `.env`, puis créer le venv et installer `requirements.txt`.
|
||||
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
load_dotenv();mcp=FastMCP("Alfred Files")
|
||||
READ=[Path(x).resolve() for x in os.getenv('READ_ROOTS','').split(':') if x]; WRITE=Path(os.getenv('WRITE_ROOT','/home/hermes/alfred-data')).resolve();WRITE.mkdir(parents=True,exist_ok=True)
|
||||
def safe_read(p):
|
||||
p=Path(p).resolve();
|
||||
if not any(p==r or r in p.parents for r in READ): raise PermissionError('Hors des zones de lecture autorisées')
|
||||
return p
|
||||
def safe_write(name):
|
||||
p=(WRITE/name).resolve();
|
||||
if not (p==WRITE or WRITE in p.parents): raise PermissionError('Écriture hors workspace interdite')
|
||||
return p
|
||||
@mcp.tool()
|
||||
def list_directory(path:str): return [{'name':p.name,'is_dir':p.is_dir()} for p in safe_read(path).iterdir()]
|
||||
@mcp.tool()
|
||||
def search_files(query:str):
|
||||
out=[]
|
||||
for r in READ:
|
||||
for p in r.rglob('*'):
|
||||
if query.lower() in p.name.lower(): out.append(str(p))
|
||||
if len(out)>=100:return out
|
||||
return out
|
||||
@mcp.tool()
|
||||
def read_text(path:str, max_chars:int=50000): return safe_read(path).read_text(errors='replace')[:max_chars]
|
||||
@mcp.tool()
|
||||
def write_workspace_file(name:str, content:str):
|
||||
p=safe_write(name);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(content);return str(p)
|
||||
if __name__=='__main__':mcp.run()
|
||||
@@ -0,0 +1,2 @@
|
||||
mcp>=1.12
|
||||
python-dotenv>=1.0
|
||||
@@ -0,0 +1,3 @@
|
||||
HA_URL=http://192.168.1.141:8123
|
||||
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI1N2M1MmY4OGViNDI0MmEzOWM4ZGVkYzY5NjA0ZjAyYiIsImlhdCI6MTc5MTI3NDY5OSwiZXhwIjoyMTA2NjM0Njk5fQ.JHFxqul7PxyoC8X9FgPYnEGYuuFycDw_Af4EOTytG7c
|
||||
HA_ALLOWED_DOMAINS=light,switch,cover,climate,sensor,binary_sensor,media_player,calendar
|
||||
@@ -0,0 +1,3 @@
|
||||
HA_URL=http://homeassistant.local:8123
|
||||
HA_TOKEN=CHANGE_ME
|
||||
HA_ALLOWED_DOMAINS=light,switch,cover,climate,sensor,binary_sensor,media_player,calendar
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP_home_assistant
|
||||
|
||||
Copier `.env.example` vers `.env`, puis créer le venv et installer `requirements.txt`.
|
||||
@@ -0,0 +1,245 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
HA_URL = os.getenv("HA_URL", "http://192.168.1.141:8123").rstrip("/")
|
||||
HA_TOKEN = os.getenv("HA_TOKEN")
|
||||
|
||||
if not HA_TOKEN:
|
||||
raise RuntimeError("HA_TOKEN manquant dans /opt/hermes/ha-mcp/.env")
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {HA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
mcp = FastMCP("home-assistant")
|
||||
|
||||
|
||||
def ha_get(path: str) -> Any:
|
||||
response = requests.get(
|
||||
f"{HA_URL}{path}",
|
||||
headers=HEADERS,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def ha_post(path: str, payload: Optional[Dict[str, Any]] = None) -> Any:
|
||||
response = requests.post(
|
||||
f"{HA_URL}{path}",
|
||||
headers=HEADERS,
|
||||
json=payload or {},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.text:
|
||||
return response.json()
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_check_api() -> Any:
|
||||
"""Check if Home Assistant API is reachable."""
|
||||
return ha_get("/api/")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_list_entities(domain: Optional[str] = None) -> Any:
|
||||
"""
|
||||
List Home Assistant entities.
|
||||
|
||||
Optional domain examples:
|
||||
- light
|
||||
- switch
|
||||
- sensor
|
||||
- binary_sensor
|
||||
- automation
|
||||
- script
|
||||
"""
|
||||
states = ha_get("/api/states")
|
||||
|
||||
if domain:
|
||||
prefix = f"{domain}."
|
||||
states = [
|
||||
entity for entity in states
|
||||
if entity.get("entity_id", "").startswith(prefix)
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"entity_id": entity.get("entity_id"),
|
||||
"state": entity.get("state"),
|
||||
"friendly_name": entity.get("attributes", {}).get("friendly_name"),
|
||||
}
|
||||
for entity in states
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_search_entities(search: str) -> Any:
|
||||
"""
|
||||
Search Home Assistant entities by entity_id or friendly name.
|
||||
"""
|
||||
search_lower = search.lower()
|
||||
states = ha_get("/api/states")
|
||||
|
||||
results = []
|
||||
|
||||
for entity in states:
|
||||
entity_id = entity.get("entity_id", "")
|
||||
friendly_name = entity.get("attributes", {}).get("friendly_name", "")
|
||||
|
||||
if (
|
||||
search_lower in entity_id.lower()
|
||||
or search_lower in friendly_name.lower()
|
||||
):
|
||||
results.append(
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"state": entity.get("state"),
|
||||
"friendly_name": friendly_name,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_get_state(entity_id: str) -> Any:
|
||||
"""Get the current state of a Home Assistant entity."""
|
||||
return ha_get(f"/api/states/{entity_id}")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_turn_on(entity_id: str) -> Any:
|
||||
"""
|
||||
Turn on a Home Assistant entity.
|
||||
|
||||
Works with domains such as:
|
||||
- light
|
||||
- switch
|
||||
- fan
|
||||
- input_boolean
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return ha_post(
|
||||
f"/api/services/{domain}/turn_on",
|
||||
{"entity_id": entity_id},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_turn_off(entity_id: str) -> Any:
|
||||
"""
|
||||
Turn off a Home Assistant entity.
|
||||
|
||||
Works with domains such as:
|
||||
- light
|
||||
- switch
|
||||
- fan
|
||||
- input_boolean
|
||||
"""
|
||||
domain = entity_id.split(".")[0]
|
||||
return ha_post(
|
||||
f"/api/services/{domain}/turn_off",
|
||||
{"entity_id": entity_id},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_toggle(entity_id: str) -> Any:
|
||||
"""Toggle a Home Assistant entity."""
|
||||
domain = entity_id.split(".")[0]
|
||||
return ha_post(
|
||||
f"/api/services/{domain}/toggle",
|
||||
{"entity_id": entity_id},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_call_service(
|
||||
domain: str,
|
||||
service: str,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
"""
|
||||
Call any Home Assistant service.
|
||||
|
||||
Example:
|
||||
domain = "light"
|
||||
service = "turn_on"
|
||||
data = {
|
||||
"entity_id": "light.salon",
|
||||
"brightness_pct": 50
|
||||
}
|
||||
"""
|
||||
return ha_post(
|
||||
f"/api/services/{domain}/{service}",
|
||||
data,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_run_script(script_entity_id: str) -> Any:
|
||||
"""Run a Home Assistant script entity."""
|
||||
return ha_post(
|
||||
"/api/services/script/turn_on",
|
||||
{"entity_id": script_entity_id},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_trigger_automation(
|
||||
automation_entity_id: str,
|
||||
skip_condition: bool = True,
|
||||
) -> Any:
|
||||
"""
|
||||
Trigger a Home Assistant automation manually.
|
||||
|
||||
By default, conditions are skipped.
|
||||
"""
|
||||
return ha_post(
|
||||
"/api/services/automation/trigger",
|
||||
{
|
||||
"entity_id": automation_entity_id,
|
||||
"skip_condition": skip_condition,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_restart_home_assistant() -> Any:
|
||||
"""
|
||||
Restart Home Assistant.
|
||||
|
||||
Use carefully.
|
||||
"""
|
||||
return ha_post("/api/services/homeassistant/restart")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_reload_automations() -> Any:
|
||||
"""Reload Home Assistant automations."""
|
||||
return ha_post("/api/services/automation/reload")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ha_reload_scripts() -> Any:
|
||||
"""Reload Home Assistant scripts."""
|
||||
return ha_post("/api/services/script/reload")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -0,0 +1,20 @@
|
||||
import os, requests
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
load_dotenv(); mcp=FastMCP("Alfred Home Assistant")
|
||||
BASE=os.getenv("HA_URL","").rstrip("/"); TOKEN=os.getenv("HA_TOKEN","")
|
||||
def headers(): return {"Authorization":f"Bearer {TOKEN}","Content-Type":"application/json"}
|
||||
@mcp.tool()
|
||||
def get_state(entity_id:str):
|
||||
"""Read one Home Assistant entity state exposed for Alfred."""
|
||||
r=requests.get(f"{BASE}/api/states/{entity_id}",headers=headers(),timeout=15); r.raise_for_status(); return r.json()
|
||||
@mcp.tool()
|
||||
def call_service(domain:str, service:str, entity_id:str, data:dict|None=None):
|
||||
"""Call an allowed HA service. Prefer entities explicitly exposed to Assist."""
|
||||
allowed=set(x.strip() for x in os.getenv("HA_ALLOWED_DOMAINS","light,switch,cover,climate,media_player,calendar").split(','))
|
||||
if domain not in allowed: raise ValueError("Domaine non autorisé")
|
||||
payload={"entity_id":entity_id, **(data or {})}; r=requests.post(f"{BASE}/api/services/{domain}/{service}",headers=headers(),json=payload,timeout=20); r.raise_for_status(); return r.json()
|
||||
@mcp.tool()
|
||||
def list_states(domain:str=""):
|
||||
r=requests.get(f"{BASE}/api/states",headers=headers(),timeout=20);r.raise_for_status();items=r.json();return [x for x in items if not domain or x['entity_id'].startswith(domain+'.')]
|
||||
if __name__=='__main__': mcp.run()
|
||||
@@ -0,0 +1,3 @@
|
||||
mcp>=1.12
|
||||
requests>=2.32
|
||||
python-dotenv>=1.0
|
||||
@@ -0,0 +1,2 @@
|
||||
PAPERLESS_URL=https://paperless.nerkdesign.com/
|
||||
PAPERLESS_TOKEN=4c55d54776783220375c24bd4701edbae24cec86
|
||||
@@ -0,0 +1,2 @@
|
||||
PAPERLESS_URL=http://paperless.local:8000
|
||||
PAPERLESS_TOKEN=CHANGE_ME
|
||||
@@ -0,0 +1,3 @@
|
||||
# MCP_paperless
|
||||
|
||||
Copier `.env.example` vers `.env`, puis créer le venv et installer `requirements.txt`.
|
||||
@@ -0,0 +1,11 @@
|
||||
import os, requests
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
load_dotenv();mcp=FastMCP("Alfred Paperless");BASE=os.getenv('PAPERLESS_URL','').rstrip('/');TOKEN=os.getenv('PAPERLESS_TOKEN','');H={'Authorization':f'Token {TOKEN}'}
|
||||
@mcp.tool()
|
||||
def search_documents(query:str, limit:int=10):
|
||||
r=requests.get(BASE+'/api/documents/',headers=H,params={'query':query,'page_size':limit},timeout=20);r.raise_for_status();return r.json()
|
||||
@mcp.tool()
|
||||
def get_document(document_id:int):
|
||||
r=requests.get(f'{BASE}/api/documents/{document_id}/',headers=H,timeout=20);r.raise_for_status();return r.json()
|
||||
if __name__=='__main__':mcp.run()
|
||||
@@ -0,0 +1,3 @@
|
||||
mcp>=1.12
|
||||
requests>=2.32
|
||||
python-dotenv>=1.0
|
||||
Reference in New Issue
Block a user