458 lines
18 KiB
Python
458 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""WEVIA Blade MCP Server v1.1 - Extended with apple_* tools
|
|
Exposes Razer Blade + Apple/iCloud scraping as MCP tools.
|
|
Run: python3 blade-mcp-server.py
|
|
Listens: http://0.0.0.0:8765
|
|
"""
|
|
|
|
import json, sys, uuid, time, requests
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
BLADE_API = "https://weval-consulting.com/api/blade-api.php"
|
|
BLADE_KEY = "BLADE2026"
|
|
WEVIA_APPLE_API = "https://weval-consulting.com/api/wevia-apple-ingest.php"
|
|
|
|
TOOLS = [
|
|
# === BLADE CORE (v1.0) ===
|
|
{
|
|
"name": "blade_exec",
|
|
"description": "Execute a PowerShell command on the Razer Blade Windows machine. Returns stdout/stderr/exit_code.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"cmd": {"type": "string", "description": "PowerShell command to execute"},
|
|
"timeout": {"type": "integer", "default": 60}
|
|
},
|
|
"required": ["cmd"]
|
|
}
|
|
},
|
|
{
|
|
"name": "blade_status",
|
|
"description": "Get current Razer Blade health: CPU, RAM, disk, uptime, last heartbeat.",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
},
|
|
{
|
|
"name": "blade_screenshot",
|
|
"description": "Take a screenshot of the Razer desktop, return URL.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"label": {"type": "string", "default": "screenshot"}}
|
|
}
|
|
},
|
|
{
|
|
"name": "blade_chrome_cdp",
|
|
"description": "Send a Chrome DevTools Protocol command to the Razer Chrome browser via debug port 9222.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"url_filter": {"type": "string"},
|
|
"js": {"type": "string"}
|
|
},
|
|
"required": ["js"]
|
|
}
|
|
},
|
|
{
|
|
"name": "blade_open_url",
|
|
"description": "Open a URL in the Razer Chrome (keeping user session cookies).",
|
|
"inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}
|
|
},
|
|
{
|
|
"name": "blade_send_keys",
|
|
"description": "Simulate keyboard input on the Razer (via SendKeys).",
|
|
"inputSchema": {"type": "object", "properties": {"keys": {"type": "string"}}, "required": ["keys"]}
|
|
},
|
|
{
|
|
"name": "blade_file_read",
|
|
"description": "Read a file from the Razer filesystem (text files under 1MB).",
|
|
"inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
|
|
},
|
|
{
|
|
"name": "blade_file_write",
|
|
"description": "Write a text file to the Razer filesystem.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
"required": ["path", "content"]
|
|
}
|
|
},
|
|
|
|
# === APPLE / WEVIA (v1.1 NEW) ===
|
|
{
|
|
"name": "apple_ingest_note",
|
|
"description": "Ingest a note into WEVIA Apple (auto-extracts entities: people, deadlines, money, OSS, tasks). Available everywhere.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"body": {"type": "string", "description": "Note content - will be analyzed by IA"}
|
|
},
|
|
"required": ["body"]
|
|
}
|
|
},
|
|
{
|
|
"name": "apple_ingest_message",
|
|
"description": "Ingest a message (SMS/iMessage/WhatsApp) into WEVIA Apple for AI analysis (urgency, deadlines, reco).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string"},
|
|
"to": {"type": "string", "default": "me"},
|
|
"body": {"type": "string"},
|
|
"date": {"type": "string"}
|
|
},
|
|
"required": ["from", "body"]
|
|
}
|
|
},
|
|
{
|
|
"name": "apple_status",
|
|
"description": "Get WEVIA Apple ingestion status: items, tasks pending, alerts, entities count.",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
},
|
|
{
|
|
"name": "apple_search",
|
|
"description": "Search across all ingested Apple data (OCR, messages, contacts, notes).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"q": {"type": "string", "description": "Search query (min 2 chars)"}},
|
|
"required": ["q"]
|
|
}
|
|
},
|
|
{
|
|
"name": "apple_recommendations",
|
|
"description": "Get top AI-generated recommendations from ingested iPhone data, sorted P0→P3.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"priority_filter": {"type": "string", "enum": ["P0","P1","P2","P3","all"], "default": "all"}}
|
|
}
|
|
},
|
|
{
|
|
"name": "apple_tasks_pending",
|
|
"description": "Get pending tasks auto-generated from Apple data (deadlines → task_create).",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
},
|
|
{
|
|
"name": "apple_mark_task_done",
|
|
"description": "Mark an Apple-generated task as done.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"task_id": {"type": "string"}},
|
|
"required": ["task_id"]
|
|
}
|
|
},
|
|
{
|
|
"name": "apple_mac_scrape_photos",
|
|
"description": "Scrape recent iCloud Photos via AppleScript on Mac (requires Blade agent installed on Mac, NOT Razer Windows).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"limit": {"type": "integer", "default": 20}}
|
|
}
|
|
},
|
|
{
|
|
"name": "apple_mac_scrape_messages",
|
|
"description": "Scrape recent iMessage/SMS from chat.db on Mac (requires Blade Mac agent + Full Disk Access perm).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"limit": {"type": "integer", "default": 50}}
|
|
}
|
|
}
|
|
]
|
|
|
|
def push_blade_task(ps_cmd, label="mcp", priority=100, timeout=60):
|
|
try:
|
|
r = requests.post(BLADE_API, data={
|
|
"k": BLADE_KEY, "action": "push",
|
|
"type": "powershell", "cmd": ps_cmd,
|
|
"label": f"mcp_{label}", "priority": priority
|
|
}, timeout=15)
|
|
task_id = r.json().get("task", {}).get("id")
|
|
if not task_id:
|
|
return {"ok": False, "error": f"push failed: {r.text[:300]}"}
|
|
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
time.sleep(2)
|
|
r2 = requests.get(BLADE_API, params={"k": BLADE_KEY, "action": "list", "id": task_id}, timeout=8)
|
|
try:
|
|
d = r2.json()
|
|
for t in d.get("tasks", []):
|
|
if t.get("id") == task_id:
|
|
st = t.get("status")
|
|
if st in ("done", "failed"):
|
|
return {
|
|
"ok": st == "done", "status": st,
|
|
"result": t.get("result", "")[:4000],
|
|
"error": t.get("error"), "task_id": task_id
|
|
}
|
|
break
|
|
except:
|
|
pass
|
|
return {"ok": False, "error": "timeout waiting for blade", "task_id": task_id}
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"exception: {str(e)[:200]}"}
|
|
|
|
# ==== Blade core handlers (v1.0) ====
|
|
|
|
def tool_blade_exec(args):
|
|
return push_blade_task(args["cmd"], label="exec", priority=200, timeout=args.get("timeout", 60))
|
|
|
|
def tool_blade_status(args):
|
|
try:
|
|
r = requests.get(BLADE_API, params={"k": BLADE_KEY, "action": "status"}, timeout=8)
|
|
return {"ok": True, "status": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:200]}
|
|
|
|
def tool_blade_screenshot(args):
|
|
label = args.get("label", "shot").replace(" ", "_")
|
|
cmd = f"""
|
|
$dir = "C:\\\\ProgramData\\\\WEVAL\\\\shots"
|
|
New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue | Out-Null
|
|
$f = "$dir\\\\{label}-$(Get-Date -Format 'yyyyMMdd_HHmmss').png"
|
|
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
|
|
$b = New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width, [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height)
|
|
$g = [System.Drawing.Graphics]::FromImage($b)
|
|
$g.CopyFromScreen(0, 0, 0, 0, $b.Size)
|
|
$b.Save($f, 'Png')
|
|
Write-Host "SHOT_PATH=$f"
|
|
"""
|
|
return push_blade_task(cmd, label="shot", priority=200, timeout=30)
|
|
|
|
def tool_blade_chrome_cdp(args):
|
|
js = args["js"].replace("`", "\\`").replace("$", "\\$")
|
|
url_filter = args.get("url_filter", "")
|
|
cmd = f"""
|
|
try {{ $targets = Invoke-RestMethod -Uri "http://localhost:9222/json" -Method GET -TimeoutSec 5 }}
|
|
catch {{ Write-Host "NO_CHROME_DEBUG_PORT"; exit 1 }}
|
|
$filter = '{url_filter}'
|
|
$tgt = if ($filter) {{ $targets | Where-Object {{ $_.url -match $filter }} | Select-Object -First 1 }} else {{ $targets | Where-Object {{ $_.type -eq 'page' }} | Select-Object -First 1 }}
|
|
if (!$tgt) {{ Write-Host "NO_TAB_MATCH"; exit 1 }}
|
|
$wsUrl = $tgt.webSocketDebuggerUrl
|
|
$ws = New-Object System.Net.WebSockets.ClientWebSocket
|
|
$cts = New-Object System.Threading.CancellationTokenSource
|
|
$cts.CancelAfter(30000)
|
|
$ws.ConnectAsync([Uri]$wsUrl, $cts.Token).Wait()
|
|
$cmd = @{{id=1; method="Runtime.evaluate"; params=@{{expression=@'
|
|
{js}
|
|
'@; awaitPromise=$true; returnByValue=$true}}}} | ConvertTo-Json -Depth 5 -Compress
|
|
$buf = [Text.Encoding]::UTF8.GetBytes($cmd)
|
|
$seg = New-Object 'System.ArraySegment[byte]' (,$buf)
|
|
$ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $cts.Token).Wait()
|
|
$rxBuf = New-Object byte[] 131072
|
|
$rxSeg = New-Object 'System.ArraySegment[byte]' (,$rxBuf)
|
|
$result = $ws.ReceiveAsync($rxSeg, $cts.Token).Result
|
|
Write-Host ([Text.Encoding]::UTF8.GetString($rxBuf, 0, $result.Count))
|
|
"""
|
|
return push_blade_task(cmd, label="cdp", priority=200, timeout=45)
|
|
|
|
def tool_blade_open_url(args):
|
|
return push_blade_task(f'Start-Process "{args["url"]}"', label="open", priority=200, timeout=15)
|
|
|
|
def tool_blade_send_keys(args):
|
|
keys = args["keys"].replace("'", "''")
|
|
return push_blade_task(f"""Add-Type -AssemblyName System.Windows.Forms
|
|
[System.Windows.Forms.SendKeys]::SendWait('{keys}')
|
|
Write-Host "KEYS_SENT" """, label="keys", priority=200, timeout=15)
|
|
|
|
def tool_blade_file_read(args):
|
|
path = args["path"].replace("'", "''")
|
|
return push_blade_task(f"Get-Content -Path '{path}' -Raw -ErrorAction Stop", label="read", priority=200, timeout=15)
|
|
|
|
def tool_blade_file_write(args):
|
|
path = args["path"].replace("'", "''")
|
|
import base64
|
|
b64 = base64.b64encode(args["content"].encode()).decode()
|
|
return push_blade_task(f"""$c = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{b64}'))
|
|
Set-Content -Path '{path}' -Value $c -Force -Encoding UTF8
|
|
Write-Host "WROTE" """, label="write", priority=200, timeout=15)
|
|
|
|
# ==== Apple / WEVIA handlers (v1.1) ====
|
|
|
|
def tool_apple_ingest_note(args):
|
|
try:
|
|
r = requests.post(f"{WEVIA_APPLE_API}?action=ingest_structured",
|
|
headers={"Content-Type": "application/json"},
|
|
json={"type": "note", "items": [{"title": args.get("title", "MCP capture"), "body": args["body"]}]},
|
|
timeout=30)
|
|
return {"ok": True, "result": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_ingest_message(args):
|
|
try:
|
|
item = {
|
|
"from": args["from"],
|
|
"to": args.get("to", "me"),
|
|
"body": args["body"],
|
|
"date": args.get("date", time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
}
|
|
r = requests.post(f"{WEVIA_APPLE_API}?action=ingest_structured",
|
|
headers={"Content-Type": "application/json"},
|
|
json={"type": "message", "items": [item]}, timeout=30)
|
|
return {"ok": True, "result": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_status(args):
|
|
try:
|
|
r = requests.get(f"{WEVIA_APPLE_API}?action=status", timeout=10)
|
|
return {"ok": True, "status": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_search(args):
|
|
try:
|
|
r = requests.get(f"{WEVIA_APPLE_API}?action=search", params={"q": args["q"]}, timeout=10)
|
|
return {"ok": True, "result": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_recommendations(args):
|
|
try:
|
|
r = requests.get(f"{WEVIA_APPLE_API}?action=recommendations", timeout=10)
|
|
data = r.json()
|
|
recos = data.get("recommendations", [])
|
|
pf = args.get("priority_filter", "all")
|
|
if pf != "all":
|
|
recos = [x for x in recos if x.get("priority") == pf]
|
|
return {"ok": True, "total": len(recos), "recommendations": recos[:20], "by_priority": data.get("by_priority", {})}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_tasks_pending(args):
|
|
try:
|
|
r = requests.get(f"{WEVIA_APPLE_API}?action=tasks", params={"status": "open"}, timeout=10)
|
|
return {"ok": True, "result": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_mark_task_done(args):
|
|
try:
|
|
r = requests.get(f"{WEVIA_APPLE_API}?action=mark_done", params={"id": args["task_id"]}, timeout=10)
|
|
return {"ok": True, "result": r.json()}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)[:300]}
|
|
|
|
def tool_apple_mac_scrape_photos(args):
|
|
# Placeholder - needs Blade Mac agent (not Windows Razer).
|
|
# Returns guidance + passes AppleScript via Blade task (if Mac hostname matches)
|
|
osa = f"""
|
|
osascript -e 'tell application "Photos"
|
|
set recentPhotos to (get last {args.get("limit", 20)} media items of container "Library")
|
|
set output to ""
|
|
repeat with p in recentPhotos
|
|
set output to output & (get filename of p) & "|" & (get creation date of p as string) & "\\n"
|
|
end repeat
|
|
return output
|
|
end tell'
|
|
"""
|
|
return {
|
|
"ok": False,
|
|
"status": "requires_mac_agent",
|
|
"message": "This tool requires Blade MCP agent on a Mac (not Razer Windows). Install blade-agent-v4-mac.sh on macOS first.",
|
|
"applescript_to_run": osa.strip(),
|
|
"fallback": "Use iPhone Shortcut 'Scan WEVIA' + automation iCloud album instead (see /downloads/wevia-shortcut-photos.json)"
|
|
}
|
|
|
|
def tool_apple_mac_scrape_messages(args):
|
|
return {
|
|
"ok": False,
|
|
"status": "requires_mac_agent",
|
|
"message": "This tool requires Blade MCP agent on a Mac with Full Disk Access permission.",
|
|
"sql_to_run": f"SELECT datetime(date/1000000000 + 978307200, 'unixepoch') AS ts, handle.id AS contact, text, is_from_me FROM message LEFT JOIN handle ON message.handle_id = handle.ROWID ORDER BY date DESC LIMIT {args.get('limit', 50)};",
|
|
"db_path": "~/Library/Messages/chat.db",
|
|
"fallback": "Manually share messages via iOS Share Sheet using /downloads/wevia-shortcut-messages.json guide"
|
|
}
|
|
|
|
TOOL_HANDLERS = {
|
|
"blade_exec": tool_blade_exec,
|
|
"blade_status": tool_blade_status,
|
|
"blade_screenshot": tool_blade_screenshot,
|
|
"blade_chrome_cdp": tool_blade_chrome_cdp,
|
|
"blade_open_url": tool_blade_open_url,
|
|
"blade_send_keys": tool_blade_send_keys,
|
|
"blade_file_read": tool_blade_file_read,
|
|
"blade_file_write": tool_blade_file_write,
|
|
"apple_ingest_note": tool_apple_ingest_note,
|
|
"apple_ingest_message": tool_apple_ingest_message,
|
|
"apple_status": tool_apple_status,
|
|
"apple_search": tool_apple_search,
|
|
"apple_recommendations": tool_apple_recommendations,
|
|
"apple_tasks_pending": tool_apple_tasks_pending,
|
|
"apple_mark_task_done": tool_apple_mark_task_done,
|
|
"apple_mac_scrape_photos": tool_apple_mac_scrape_photos,
|
|
"apple_mac_scrape_messages": tool_apple_mac_scrape_messages
|
|
}
|
|
|
|
class MCPHandler(BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *args): pass
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(length).decode()
|
|
try:
|
|
req = json.loads(body)
|
|
except:
|
|
self.send_error(400, "invalid json")
|
|
return
|
|
|
|
method = req.get("method")
|
|
req_id = req.get("id")
|
|
params = req.get("params", {})
|
|
result = None
|
|
error = None
|
|
|
|
if method == "initialize":
|
|
result = {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": "wevia-blade-mcp", "version": "1.1.0"}
|
|
}
|
|
elif method == "tools/list":
|
|
result = {"tools": TOOLS}
|
|
elif method == "tools/call":
|
|
name = params.get("name")
|
|
args = params.get("arguments", {})
|
|
handler = TOOL_HANDLERS.get(name)
|
|
if not handler:
|
|
error = {"code": -32601, "message": f"Unknown tool: {name}"}
|
|
else:
|
|
try:
|
|
r = handler(args)
|
|
result = {"content": [{"type": "text", "text": json.dumps(r, indent=2, ensure_ascii=False)}]}
|
|
except Exception as e:
|
|
error = {"code": -32000, "message": str(e)[:300]}
|
|
elif method == "ping":
|
|
result = {}
|
|
else:
|
|
error = {"code": -32601, "message": f"Unknown method: {method}"}
|
|
|
|
resp = {"jsonrpc": "2.0", "id": req_id}
|
|
if error: resp["error"] = error
|
|
else: resp["result"] = result
|
|
|
|
payload = json.dumps(resp).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def do_GET(self):
|
|
if self.path == "/health":
|
|
payload = json.dumps({"ok": True, "server": "wevia-blade-mcp", "version": "1.1.0", "tools": len(TOOLS)}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
else:
|
|
self.send_error(404)
|
|
|
|
if __name__ == "__main__":
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
|
server = ThreadingHTTPServer(("0.0.0.0", port), MCPHandler)
|
|
sys.stderr.write(f"[WEVIA-BLADE-MCP v1.1] Listening on 0.0.0.0:{port}\n")
|
|
sys.stderr.write(f"[WEVIA-BLADE-MCP] {len(TOOLS)} tools exposed\n")
|
|
sys.stderr.flush()
|
|
server.serve_forever()
|