Initial commit: Handoff Pro MCP server for Kellow Construction

This commit is contained in:
brian
2026-05-29 14:38:57 -07:00
commit 3a62428751
28 changed files with 1860 additions and 0 deletions

343
server.py Normal file
View File

@@ -0,0 +1,343 @@
"""Handoff Pro MCP Server — SSE transport for mcporter compatibility."""
import json
import os
import sys
import queue
import threading
import uuid
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import db
# Import tool handlers
from scripts import pricing_catalog, project_mgmt, jobtread_sync
from scripts import estimate_generator, proposal_drafter, invoice_mgmt
from scripts import scope_generator, material_list, daily_log
from scripts import site_analyzer, budget_tracker, project_scheduler
from scripts import signature_mgmt, activity_logger
PORT = int(os.environ.get("HANDOFF_PORT", 3101))
# Tool registry: name -> {description, input_schema, handler}
TOOLS = {}
def tool(name, description, schema):
"""Decorator to register an MCP tool."""
def decorator(fn):
TOOLS[name] = {"description": description, "inputSchema": schema, "handler": fn}
return fn
return decorator
# --- Pricing Catalog Tools ---
@tool("list_catalogs", "List all pricing catalogs", {"type": "object", "properties": {}})
def _list_catalogs(args): return pricing_catalog.list_catalogs()
@tool("create_catalog", "Create a pricing catalog", {
"type": "object", "properties": {
"name": {"type": "string"}, "labor_rates": {"type": "object"},
"material_markups": {"type": "object"}
}, "required": ["name", "labor_rates"]})
def _create_catalog(args): return pricing_catalog.create_catalog(**args)
@tool("get_catalog", "Get a pricing catalog by ID", {
"type": "object", "properties": {"catalog_id": {"type": "string"}}, "required": ["catalog_id"]})
def _get_catalog(args): return pricing_catalog.get_catalog(args["catalog_id"])
@tool("update_catalog", "Update a pricing catalog", {
"type": "object", "properties": {
"catalog_id": {"type": "string"}, "name": {"type": "string"},
"labor_rates": {"type": "object"}, "material_markups": {"type": "object"}
}, "required": ["catalog_id"]})
def _update_catalog(args): return pricing_catalog.update_catalog(**args)
@tool("delete_catalog", "Delete a pricing catalog", {
"type": "object", "properties": {"catalog_id": {"type": "string"}}, "required": ["catalog_id"]})
def _delete_catalog(args): return pricing_catalog.delete_catalog(args["catalog_id"])
# --- Project Management Tools ---
@tool("create_job", "Create a new job", {
"type": "object", "properties": {
"client_name": {"type": "string"}, "address": {"type": "string"},
"description": {"type": "string"}
}, "required": ["client_name"]})
def _create_job(args): return project_mgmt.create_job(**args)
@tool("list_jobs", "List all jobs", {
"type": "object", "properties": {"status": {"type": "string"}}})
def _list_jobs(args): return project_mgmt.list_jobs(args.get("status"))
@tool("get_job", "Get job details", {
"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]})
def _get_job(args): return project_mgmt.get_job(args["job_id"])
@tool("update_job", "Update a job", {
"type": "object", "properties": {
"job_id": {"type": "string"}, "status": {"type": "string"},
"client_name": {"type": "string"}, "address": {"type": "string"},
"description": {"type": "string"}
}, "required": ["job_id"]})
def _update_job(args): return project_mgmt.update_job(**args)
@tool("create_estimate", "Create an estimate for a job", {
"type": "object", "properties": {
"job_id": {"type": "string"},
"line_items": {"type": "array", "items": {"type": "object"}},
"labor_hours": {"type": "number"}, "labor_rate": {"type": "number"},
"markup_percent": {"type": "number"}
}, "required": ["job_id", "line_items"]})
def _create_estimate(args): return project_mgmt.create_estimate(**args)
@tool("get_estimate", "Get estimate details with line items", {
"type": "object", "properties": {"estimate_id": {"type": "string"}}, "required": ["estimate_id"]})
def _get_estimate(args): return project_mgmt.get_estimate(args["estimate_id"])
# --- JobTread Sync Tools ---
@tool("jt_list_customers", "List customers from JobTread", {"type": "object", "properties": {}})
def _jt_customers(args): return jobtread_sync.list_customers()
@tool("jt_pull_jobs", "Pull all jobs from JobTread into local DB", {"type": "object", "properties": {}})
def _jt_pull(args): return jobtread_sync.pull_jobs()
@tool("jt_push_estimate", "Push estimate to JobTread", {
"type": "object", "properties": {"estimate_id": {"type": "string"}}, "required": ["estimate_id"]})
def _jt_push_est(args): return jobtread_sync.push_estimate(args["estimate_id"])
@tool("jt_push_invoice", "Push invoice to JobTread", {
"type": "object", "properties": {"invoice_id": {"type": "string"}}, "required": ["invoice_id"]})
def _jt_push_inv(args): return jobtread_sync.push_invoice(args["invoice_id"])
@tool("jt_sync_status", "Get sync status for a job", {
"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]})
def _jt_status(args): return jobtread_sync.get_sync_status(args["job_id"])
# --- Estimate Generator ---
@tool("generate_estimate", "Generate estimate from job description", {
"type": "object", "properties": {
"description": {"type": "string"}, "job_id": {"type": "string"},
"catalog_id": {"type": "string"}, "markup_percent": {"type": "number"}
}, "required": ["description"]})
def _gen_estimate(args): return estimate_generator.generate(**args)
# --- Proposal Drafter ---
@tool("draft_proposal", "Generate a professional proposal from an estimate", {
"type": "object", "properties": {"estimate_id": {"type": "string"}}, "required": ["estimate_id"]})
def _draft_proposal(args): return proposal_drafter.draft(args["estimate_id"])
# --- Invoice Management ---
@tool("create_invoice", "Create invoice from estimate", {
"type": "object", "properties": {
"estimate_id": {"type": "string"}, "due_date": {"type": "string"}
}, "required": ["estimate_id"]})
def _create_invoice(args): return invoice_mgmt.create(**args)
@tool("list_invoices", "List invoices", {
"type": "object", "properties": {"job_id": {"type": "string"}}})
def _list_invoices(args): return invoice_mgmt.list_invoices(args.get("job_id"))
@tool("mark_invoice_paid", "Mark invoice as paid", {
"type": "object", "properties": {
"invoice_id": {"type": "string"}, "amount_paid": {"type": "number"}
}, "required": ["invoice_id"]})
def _mark_paid(args): return invoice_mgmt.mark_paid(**args)
@tool("sync_invoice_qbo", "Sync invoice to QuickBooks Online", {
"type": "object", "properties": {"invoice_id": {"type": "string"}}, "required": ["invoice_id"]})
def _sync_qbo(args): return invoice_mgmt.sync_to_qbo(args["invoice_id"])
# --- Scope & Materials ---
@tool("generate_scope", "Generate scope of work from estimate", {
"type": "object", "properties": {"estimate_id": {"type": "string"}}, "required": ["estimate_id"]})
def _gen_scope(args): return scope_generator.generate(args["estimate_id"])
@tool("generate_material_list", "Generate material shopping list from estimate", {
"type": "object", "properties": {"estimate_id": {"type": "string"}}, "required": ["estimate_id"]})
def _gen_materials(args): return material_list.generate(args["estimate_id"])
# --- Daily Logs ---
@tool("create_daily_log", "Create a daily job log entry", {
"type": "object", "properties": {
"job_id": {"type": "string"}, "crew_names": {"type": "string"},
"hours_worked": {"type": "number"}, "notes": {"type": "string"},
"photos": {"type": "array", "items": {"type": "string"}}
}, "required": ["job_id", "notes"]})
def _create_log(args): return daily_log.create(**args)
@tool("list_daily_logs", "List daily logs for a job", {
"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]})
def _list_logs(args): return daily_log.list_logs(args["job_id"])
# --- Site Analyzer ---
@tool("analyze_site", "Analyze site conditions from observations", {
"type": "object", "properties": {
"job_id": {"type": "string"}, "observations": {"type": "string"},
"photos_description": {"type": "string"}
}, "required": ["observations"]})
def _analyze_site(args): return site_analyzer.analyze(**args)
# --- Budget Tracker ---
@tool("get_budget_report", "Get budget vs actual report for a job", {
"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]})
def _budget_report(args): return budget_tracker.report(args["job_id"])
# --- Project Scheduler ---
@tool("set_milestone", "Set a project milestone", {
"type": "object", "properties": {
"job_id": {"type": "string"}, "phase_name": {"type": "string"},
"start_date": {"type": "string"}, "end_date": {"type": "string"}
}, "required": ["job_id", "phase_name"]})
def _set_milestone(args): return project_scheduler.set_milestone(**args)
@tool("get_schedule", "Get project schedule/timeline", {
"type": "object", "properties": {"job_id": {"type": "string"}}, "required": ["job_id"]})
def _get_schedule(args): return project_scheduler.get_schedule(args["job_id"])
# --- Signatures & Activity ---
@tool("request_signature", "Request client signature on estimate", {
"type": "object", "properties": {
"estimate_id": {"type": "string"}, "client_name": {"type": "string"}
}, "required": ["estimate_id", "client_name"]})
def _req_sig(args): return signature_mgmt.request_signature(**args)
@tool("record_signature", "Record a client signature", {
"type": "object", "properties": {
"signature_id": {"type": "string"}, "signature_data": {"type": "string"}
}, "required": ["signature_id"]})
def _rec_sig(args): return signature_mgmt.record_signature(**args)
@tool("log_activity", "Log client activity (viewed, approved, signed, paid)", {
"type": "object", "properties": {
"estimate_id": {"type": "string"}, "job_id": {"type": "string"},
"action": {"type": "string"}
}, "required": ["action"]})
def _log_activity(args): return activity_logger.log(**args)
@tool("get_activity", "Get activity log for an estimate or job", {
"type": "object", "properties": {
"estimate_id": {"type": "string"}, "job_id": {"type": "string"}
}})
def _get_activity(args): return activity_logger.get(**args)
# --- MCP SSE Server Implementation ---
class SSEClient:
def __init__(self):
self.queue = queue.Queue()
self.id = str(uuid.uuid4())
clients = {}
def send_sse(client_id, event, data):
if client_id in clients:
clients[client_id].queue.put(f"event: {event}\ndata: {json.dumps(data)}\n\n")
class MCPHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args): pass
def do_GET(self):
path = urlparse(self.path).path
if path == "/sse":
self._handle_sse()
elif path == "/health":
self._json_response({"ok": True, "tools": len(TOOLS)})
else:
self.send_error(404)
def do_POST(self):
path = urlparse(self.path).path
if path == "/messages":
self._handle_message()
else:
self.send_error(404)
def _handle_sse(self):
client = SSEClient()
clients[client.id] = client
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
# Send endpoint info
endpoint_msg = {"jsonrpc": "2.0", "method": "endpoint",
"params": {"uri": f"http://0.0.0.0:{PORT}/messages?client_id={client.id}"}}
self.wfile.write(f"event: endpoint\ndata: {json.dumps(endpoint_msg)}\n\n".encode())
self.wfile.flush()
try:
while True:
try:
msg = client.queue.get(timeout=30)
self.wfile.write(msg.encode())
self.wfile.flush()
except queue.Empty:
self.wfile.write(": keepalive\n\n".encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
clients.pop(client.id, None)
def _handle_message(self):
params = parse_qs(urlparse(self.path).query)
client_id = params.get("client_id", [None])[0]
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
response = self._process_jsonrpc(body)
if client_id and client_id in clients:
send_sse(client_id, "message", response)
self._json_response(response)
def _process_jsonrpc(self, msg):
method = msg.get("method", "")
msg_id = msg.get("id")
if method == "initialize":
return {"jsonrpc": "2.0", "id": msg_id, "result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "handoff-pro", "version": "1.0.0"}
}}
elif method == "notifications/initialized":
return {"jsonrpc": "2.0", "id": msg_id, "result": None}
elif method == "tools/list":
tools_list = [{"name": k, "description": v["description"],
"inputSchema": v["inputSchema"]} for k, v in TOOLS.items()]
return {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": tools_list}}
elif method == "tools/call":
tool_name = msg.get("params", {}).get("name", "")
arguments = msg.get("params", {}).get("arguments", {})
if tool_name not in TOOLS:
return {"jsonrpc": "2.0", "id": msg_id, "error": {
"code": -32601, "message": f"Unknown tool: {tool_name}"}}
try:
result = TOOLS[tool_name]["handler"](arguments)
return {"jsonrpc": "2.0", "id": msg_id, "result": {
"content": [{"type": "text", "text": json.dumps(result, default=str)}]
}}
except Exception as e:
return {"jsonrpc": "2.0", "id": msg_id, "result": {
"content": [{"type": "text", "text": json.dumps({"error": str(e)})}],
"isError": True
}}
return {"jsonrpc": "2.0", "id": msg_id, "error": {
"code": -32601, "message": f"Unknown method: {method}"}}
def _json_response(self, data):
body = json.dumps(data).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", len(body))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def main():
db.init_db()
server = HTTPServer(("0.0.0.0", PORT), MCPHandler)
print(f"Handoff Pro MCP server running on port {PORT}")
print(f"SSE endpoint: http://0.0.0.0:{PORT}/sse")
print(f"Tools registered: {len(TOOLS)}")
server.serve_forever()
if __name__ == "__main__":
main()