contractor-foreman-api-mcp 1.0.2__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.
- contractor_foreman_api_mcp/__init__.py +28 -0
- contractor_foreman_api_mcp/api.py +213 -0
- contractor_foreman_api_mcp/app.py +41 -0
- contractor_foreman_api_mcp/cli.py +199 -0
- contractor_foreman_api_mcp/contractor_foreman/__init__.py +0 -0
- contractor_foreman_api_mcp/contractor_foreman/auth.py +23 -0
- contractor_foreman_api_mcp/contractor_foreman/client.py +30 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/__init__.py +0 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_change_orders.py +111 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_daily_logs.py +186 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_finance_common.py +264 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_invoices.py +119 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_common.py +203 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_directory.py +99 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_field.py +124 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_notes.py +28 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_opportunities.py +52 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_purchasing.py +173 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_ops_timecards.py +64 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_projects_common.py +104 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_punchlists.py +68 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/_todos.py +90 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/add_change_order_items.py +64 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/add_estimate_items.py +86 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/add_invoice_items.py +70 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/add_project_note.py +55 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_bill.py +79 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_change_order.py +129 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_contact.py +62 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_daily_log.py +67 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_estimate.py +120 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_expense.py +75 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_inspection.py +70 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_invoice.py +111 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_lead.py +66 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_opportunity.py +90 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_project.py +131 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_punchlist.py +51 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_purchase_order.py +71 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_rfi.py +70 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_service_ticket.py +76 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_share_link.py +47 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_sub_contract.py +75 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_todo.py +71 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/create_work_order.py +64 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/delete_change_order.py +45 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/delete_estimate.py +39 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/delete_invoice.py +44 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_bill.py +86 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_change_order.py +71 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_clock_status.py +78 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_contact.py +93 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_daily_log.py +39 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_estimate.py +146 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_expense.py +64 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_inspection.py +58 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_invoice.py +98 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_job_costing.py +152 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_opportunity.py +57 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_project.py +124 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_punchlist.py +40 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_purchase_order.py +82 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_rfi.py +44 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_service_ticket.py +85 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_sub_contract.py +81 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_submittal.py +86 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_time_card.py +73 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_todo.py +32 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/get_work_order.py +88 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_bills.py +59 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_change_orders.py +95 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_contacts.py +76 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_cost_codes.py +54 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_cost_items.py +108 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_daily_logs.py +97 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_estimates.py +107 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_expenses.py +55 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_inspections.py +56 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_invoices.py +95 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_leads.py +69 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_opportunities.py +60 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_payments.py +79 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_project_crew_hours.py +126 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_project_files.py +78 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_project_notes.py +38 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_projects.py +113 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_punchlists.py +87 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_purchase_orders.py +61 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_rfis.py +77 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_schedule_tasks.py +111 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_service_tickets.py +55 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_sub_contracts.py +61 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_submittals.py +53 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_time_cards.py +69 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_todos.py +76 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/list_work_orders.py +57 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/record_inspection_result.py +49 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/record_payment.py +104 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/remove_estimate_items.py +61 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/send_change_order.py +82 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/send_estimate.py +83 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/send_invoice.py +90 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_change_order.py +77 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_daily_log.py +50 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_estimate.py +88 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_invoice.py +69 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_project.py +66 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_schedule_task.py +133 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_service_ticket_status.py +50 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_todo.py +67 -0
- contractor_foreman_api_mcp/contractor_foreman/tools/update_work_order_status.py +43 -0
- contractor_foreman_api_mcp/core/__init__.py +0 -0
- contractor_foreman_api_mcp/core/account_guard.py +33 -0
- contractor_foreman_api_mcp/core/context.py +189 -0
- contractor_foreman_api_mcp/core/errors.py +78 -0
- contractor_foreman_api_mcp/core/helpers.py +33 -0
- contractor_foreman_api_mcp/core/tools.py +108 -0
- contractor_foreman_api_mcp/mcp_context.py +126 -0
- contractor_foreman_api_mcp/mcp_server.py +67 -0
- contractor_foreman_api_mcp/runner.py +119 -0
- contractor_foreman_api_mcp/scripting.py +73 -0
- contractor_foreman_api_mcp/session.py +245 -0
- contractor_foreman_api_mcp-1.0.2.dist-info/METADATA +517 -0
- contractor_foreman_api_mcp-1.0.2.dist-info/RECORD +127 -0
- contractor_foreman_api_mcp-1.0.2.dist-info/WHEEL +4 -0
- contractor_foreman_api_mcp-1.0.2.dist-info/entry_points.txt +3 -0
- contractor_foreman_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Self-hosted API and MCP server for one app (see app.py). Personal use only, see LICENSE.
|
|
2
|
+
|
|
3
|
+
From Python, without a server:
|
|
4
|
+
|
|
5
|
+
from contractor_foreman_api_mcp import call
|
|
6
|
+
projects = call("list_projects")["projects"]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.2"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def call(tool: str, args: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
|
18
|
+
"""Run one tool with the saved session and return its answer. Raises ToolError on failure."""
|
|
19
|
+
from contractor_foreman_api_mcp.runner import Runner
|
|
20
|
+
|
|
21
|
+
async def go() -> Any:
|
|
22
|
+
runner = Runner()
|
|
23
|
+
try:
|
|
24
|
+
return await runner.run(tool, {**(args or {}), **kwargs})
|
|
25
|
+
finally:
|
|
26
|
+
await runner.close()
|
|
27
|
+
|
|
28
|
+
return asyncio.run(go())
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""The REST API and the MCP endpoint on one port.
|
|
2
|
+
|
|
3
|
+
- `GET|POST|PUT|PATCH|DELETE <app.API_PREFIX>/<route>`: each tool at its route, the same paths as the
|
|
4
|
+
hosted API at cotlet.dev. Path parts and the query string are arguments; a JSON body adds the rest.
|
|
5
|
+
- `POST <app.API_PREFIX>/tools/{name}`: any tool by name, the JSON body is its arguments.
|
|
6
|
+
- `GET <app.API_PREFIX>/tools`: every tool with its schema. OpenAPI at `/docs` and `/openapi.json`.
|
|
7
|
+
- `/mcp`: the MCP server over streamable HTTP.
|
|
8
|
+
|
|
9
|
+
With <ENV>_MCP_TOKEN set, every request needs `Authorization: Bearer <token>`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, Request
|
|
23
|
+
from fastapi.responses import JSONResponse
|
|
24
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
25
|
+
|
|
26
|
+
from contractor_foreman_api_mcp import __version__, app, mcp_server
|
|
27
|
+
from contractor_foreman_api_mcp.core.errors import HTTP_STATUS, ErrorCode, ToolError
|
|
28
|
+
from contractor_foreman_api_mcp.core.tools import ToolSpec
|
|
29
|
+
from contractor_foreman_api_mcp.runner import Runner, description, tools
|
|
30
|
+
|
|
31
|
+
PREFIX = app.API_PREFIX
|
|
32
|
+
ASGI = Callable[[dict[str, Any], Callable[[], Awaitable[Any]], Callable[[Any], Awaitable[None]]], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _error(code: ErrorCode, message: str) -> JSONResponse:
|
|
36
|
+
return JSONResponse({"error": code.value, "message": message}, status_code=HTTP_STATUS.get(code, 500))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _typed(value: str, schema: dict[str, Any]) -> Any:
|
|
40
|
+
kind = schema.get("type")
|
|
41
|
+
try:
|
|
42
|
+
if kind == "integer":
|
|
43
|
+
return int(value)
|
|
44
|
+
if kind == "number":
|
|
45
|
+
return float(value)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return value
|
|
48
|
+
if kind == "boolean":
|
|
49
|
+
return value.lower() in ("1", "true", "yes", "on")
|
|
50
|
+
if kind == "array":
|
|
51
|
+
return [_typed(v, schema.get("items") or {}) for v in value.split(",") if v]
|
|
52
|
+
if kind == "object":
|
|
53
|
+
with contextlib.suppress(ValueError):
|
|
54
|
+
return json.loads(value)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _route_order(spec: ToolSpec) -> list[int]:
|
|
59
|
+
"""Literal segments before {placeholders}, so /projects/archived would win over /projects/{project_id}."""
|
|
60
|
+
path = (spec.route or "").split(" ", 1)[1]
|
|
61
|
+
return [1 if part.startswith("{") else 0 for part in path.strip("/").split("/")]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tag(path: str) -> str:
|
|
65
|
+
"""OpenAPI group: the part after /groups/{group}/ in group apps, else the first part (projects, invoices ...)."""
|
|
66
|
+
parts = path.strip("/").split("/")
|
|
67
|
+
return parts[2] if parts[:2] == ["groups", "{group}"] and len(parts) > 2 else parts[0]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _openapi(spec: ToolSpec, method: str, path: str) -> dict[str, Any]:
|
|
71
|
+
props: dict[str, Any] = spec.input_schema.get("properties") or {}
|
|
72
|
+
required = set(spec.input_schema.get("required") or [])
|
|
73
|
+
in_path = set(re.findall(r"\{(\w+)\}", path))
|
|
74
|
+
parameters = [
|
|
75
|
+
{"name": name, "in": "path", "required": True, "schema": props.get(name, {"type": "string"})}
|
|
76
|
+
for name in in_path
|
|
77
|
+
]
|
|
78
|
+
rest = {k: v for k, v in props.items() if k not in in_path}
|
|
79
|
+
extra: dict[str, Any] = {}
|
|
80
|
+
if method == "GET":
|
|
81
|
+
parameters += [
|
|
82
|
+
{"name": name, "in": "query", "required": name in required, "schema": schema}
|
|
83
|
+
for name, schema in rest.items()
|
|
84
|
+
]
|
|
85
|
+
elif rest:
|
|
86
|
+
body = {"type": "object", "properties": rest, "required": sorted(required - in_path)}
|
|
87
|
+
extra["requestBody"] = {"required": bool(body["required"]), "content": {"application/json": {"schema": body}}}
|
|
88
|
+
if spec.example_response:
|
|
89
|
+
extra["responses"] = {
|
|
90
|
+
"200": {"description": "OK", "content": {"application/json": {"example": spec.example_response}}}
|
|
91
|
+
}
|
|
92
|
+
return {"parameters": parameters, **extra}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _arguments(request: Request, spec: ToolSpec) -> dict[str, Any]:
|
|
96
|
+
props: dict[str, Any] = spec.input_schema.get("properties") or {}
|
|
97
|
+
args: dict[str, Any] = {}
|
|
98
|
+
if request.method != "GET":
|
|
99
|
+
raw = await request.body()
|
|
100
|
+
if raw.strip():
|
|
101
|
+
try:
|
|
102
|
+
body = json.loads(raw)
|
|
103
|
+
except ValueError:
|
|
104
|
+
raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT) from None
|
|
105
|
+
if not isinstance(body, dict):
|
|
106
|
+
raise ToolError("The body must be a JSON object.", ErrorCode.INVALID_INPUT)
|
|
107
|
+
args.update(body)
|
|
108
|
+
for name in request.query_params:
|
|
109
|
+
values = request.query_params.getlist(name)
|
|
110
|
+
schema = props.get(name) or {}
|
|
111
|
+
args[name] = (
|
|
112
|
+
[_typed(v, schema.get("items") or {}) for v in values]
|
|
113
|
+
if schema.get("type") == "array" and len(values) > 1
|
|
114
|
+
else _typed(values[-1], schema)
|
|
115
|
+
)
|
|
116
|
+
for name, value in request.path_params.items():
|
|
117
|
+
args[name] = _typed(value, props.get(name) or {})
|
|
118
|
+
return args
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_app(runner: Runner) -> ASGI:
|
|
122
|
+
manager = StreamableHTTPSessionManager(app=mcp_server.build(runner), json_response=True, stateless=True)
|
|
123
|
+
token = os.environ.get(f"{app.ENV}_MCP_TOKEN", "").strip()
|
|
124
|
+
|
|
125
|
+
@contextlib.asynccontextmanager
|
|
126
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
127
|
+
async with manager.run():
|
|
128
|
+
yield
|
|
129
|
+
await runner.close()
|
|
130
|
+
|
|
131
|
+
api = FastAPI(
|
|
132
|
+
title=f"{app.NAME} API (self-hosted)",
|
|
133
|
+
version=__version__,
|
|
134
|
+
description=f"Your {app.NAME} account as a REST API, run on your machine with your Chrome's session.",
|
|
135
|
+
lifespan=lifespan,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def call(spec: ToolSpec, args: dict[str, Any]) -> JSONResponse:
|
|
139
|
+
try:
|
|
140
|
+
result = await runner.run(spec.name, args)
|
|
141
|
+
except ToolError as exc:
|
|
142
|
+
return _error(exc.code, str(exc))
|
|
143
|
+
return JSONResponse(result)
|
|
144
|
+
|
|
145
|
+
def endpoint(spec: ToolSpec) -> Callable[[Request], Awaitable[JSONResponse]]:
|
|
146
|
+
async def handle(request: Request) -> JSONResponse:
|
|
147
|
+
try:
|
|
148
|
+
args = await _arguments(request, spec)
|
|
149
|
+
except ToolError as exc:
|
|
150
|
+
return _error(exc.code, str(exc))
|
|
151
|
+
return await call(spec, args)
|
|
152
|
+
|
|
153
|
+
return handle
|
|
154
|
+
|
|
155
|
+
@api.get(f"{PREFIX}/tools", summary="Every tool with its route and input schema")
|
|
156
|
+
async def list_tools() -> list[dict[str, Any]]:
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
"name": spec.name,
|
|
160
|
+
"title": spec.title,
|
|
161
|
+
"access": spec.access,
|
|
162
|
+
"route": spec.route,
|
|
163
|
+
"description": description(spec),
|
|
164
|
+
"input_schema": spec.input_schema,
|
|
165
|
+
"example_response": spec.example_response,
|
|
166
|
+
}
|
|
167
|
+
for spec in tools(runner.read_only)
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
@api.post(f"{PREFIX}/tools/{{name}}", summary="Call any tool by name; the JSON body is its arguments")
|
|
171
|
+
async def call_by_name(name: str, request: Request) -> JSONResponse:
|
|
172
|
+
raw = await request.body()
|
|
173
|
+
try:
|
|
174
|
+
args = json.loads(raw) if raw.strip() else {}
|
|
175
|
+
except ValueError:
|
|
176
|
+
return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
|
|
177
|
+
if not isinstance(args, dict):
|
|
178
|
+
return _error(ErrorCode.INVALID_INPUT, "The body must be a JSON object.")
|
|
179
|
+
spec = next((s for s in tools() if s.name == name), None)
|
|
180
|
+
if spec is None:
|
|
181
|
+
return _error(ErrorCode.UNKNOWN_TOOL, f"No tool '{name}'. GET {PREFIX}/tools lists them.")
|
|
182
|
+
return await call(spec, args)
|
|
183
|
+
|
|
184
|
+
for spec in sorted(tools(runner.read_only), key=_route_order):
|
|
185
|
+
method, path = (spec.route or "").split(" ", 1)
|
|
186
|
+
api.add_api_route(
|
|
187
|
+
PREFIX + path,
|
|
188
|
+
endpoint(spec),
|
|
189
|
+
methods=[method],
|
|
190
|
+
name=spec.name,
|
|
191
|
+
operation_id=spec.name,
|
|
192
|
+
summary=spec.title,
|
|
193
|
+
description=description(spec),
|
|
194
|
+
tags=[_tag(path)],
|
|
195
|
+
openapi_extra=_openapi(spec, method, path),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def asgi(
|
|
199
|
+
scope: dict[str, Any], receive: Callable[[], Awaitable[Any]], send: Callable[[Any], Awaitable[None]]
|
|
200
|
+
) -> None:
|
|
201
|
+
if scope["type"] == "http" and token:
|
|
202
|
+
sent = dict(scope.get("headers") or []).get(b"authorization", b"").decode()
|
|
203
|
+
if not hmac.compare_digest(sent, f"Bearer {token}"):
|
|
204
|
+
await _error(ErrorCode.AUTH_REQUIRED, f"Send Authorization: Bearer <{app.ENV}_MCP_TOKEN>.")(
|
|
205
|
+
scope, receive, send
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
if scope["type"] == "http" and (scope["path"] == "/mcp" or scope["path"].startswith("/mcp/")):
|
|
209
|
+
await manager.handle_request(scope, receive, send)
|
|
210
|
+
return
|
|
211
|
+
await api(scope, receive, send)
|
|
212
|
+
|
|
213
|
+
return asgi
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Everything the server knows about Contractor Foreman outside `contractor_foreman/`: names, domains, cookies.
|
|
2
|
+
|
|
3
|
+
The rest of the package reads these values and never names the app itself.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
NAME = "Contractor Foreman"
|
|
9
|
+
SLUG = "contractor-foreman"
|
|
10
|
+
# The command and the prefix of every environment variable (CONTRACTOR_FOREMAN_AUTH_TOKEN, ..._MCP_TOKEN ...).
|
|
11
|
+
CLI = "contractor-foreman-mcp"
|
|
12
|
+
ENV = "CONTRACTOR_FOREMAN"
|
|
13
|
+
# Requests may reach only these hosts and their subdomains. Their cookies are read from Chrome.
|
|
14
|
+
DOMAINS = ("contractorforeman.net",)
|
|
15
|
+
# Present together only while signed in (a signed-out visitor gets the first one too), with the domain a
|
|
16
|
+
# pasted token sets each on.
|
|
17
|
+
COOKIE_DOMAINS = {"_session__240322": "app.contractorforeman.net", "auth_data_session": ".contractorforeman.net"}
|
|
18
|
+
SESSION_COOKIES = tuple(COOKIE_DOMAINS)
|
|
19
|
+
LOGIN_URL = "https://app.contractorforeman.net/sign-in"
|
|
20
|
+
# A final URL with one of these means the app signed the session out.
|
|
21
|
+
LOGGED_OUT_MARKERS = ("/sign-in",)
|
|
22
|
+
# Paths a tool may never change: account, company setup, billing. Reads (GET) stay allowed; every other
|
|
23
|
+
# method to a match is refused before it leaves (core/context.py).
|
|
24
|
+
ACCOUNT_PATHS = (
|
|
25
|
+
r"^/api/auth/",
|
|
26
|
+
r"^/api/settings/",
|
|
27
|
+
r"^/api/subscription",
|
|
28
|
+
r"^/api/stripe",
|
|
29
|
+
r"^/api/checkout-link",
|
|
30
|
+
r"^/api/common/submit-cancellation-request",
|
|
31
|
+
r"^/api/forgot-password",
|
|
32
|
+
r"^/api/directory/\d+/dir-reset-password",
|
|
33
|
+
r"^/api/remove-demo-data",
|
|
34
|
+
r"^/api/common/replicate-demo-data",
|
|
35
|
+
)
|
|
36
|
+
# A 401 or 403 whose body has one of these asks to sign in again: auth_expired. Contractor Foreman has none.
|
|
37
|
+
RELOGIN_MARKERS: tuple[str, ...] = ()
|
|
38
|
+
# REST routes live under this path, the same as on cotlet.dev.
|
|
39
|
+
API_PREFIX = "/api/v1/contractor-foreman"
|
|
40
|
+
# Prepended to the MCP server's instructions: where an agent starts.
|
|
41
|
+
AGENT_START = "Start with list_projects: its `project_id` values are what most other tools take."
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""<cli>: connect your Chrome, then run the REST API and MCP server, or call one tool.
|
|
2
|
+
|
|
3
|
+
<cli> connect # read your session of the app from Chrome (remote debugging on)
|
|
4
|
+
<cli> status # which account the saved session is
|
|
5
|
+
<cli> tools # every tool, read or write
|
|
6
|
+
<cli> call <tool> k=v # one tool, arguments as JSON or key=value
|
|
7
|
+
<cli> mcp # MCP server over stdio, for Claude Code, Claude Desktop, Cursor
|
|
8
|
+
<cli> serve # REST API + MCP over HTTP on http://127.0.0.1:8787
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import getpass
|
|
16
|
+
import ipaddress
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
from contractor_foreman_api_mcp import __version__, app
|
|
24
|
+
from contractor_foreman_api_mcp import session as sessions
|
|
25
|
+
from contractor_foreman_api_mcp.core.errors import ToolError
|
|
26
|
+
from contractor_foreman_api_mcp.runner import Runner, all_tools, tools
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _connect(args: argparse.Namespace) -> None:
|
|
30
|
+
if args.token:
|
|
31
|
+
names = "; ".join(f"{name}=..." for name in app.SESSION_COOKIES)
|
|
32
|
+
token = getpass.getpass(f"Cookies as one line, {names} (input hidden): ")
|
|
33
|
+
session = sessions.from_token(token)
|
|
34
|
+
else:
|
|
35
|
+
if not args.cdp:
|
|
36
|
+
print("Connecting to Chrome. Click Allow if Chrome asks about remote debugging.", file=sys.stderr)
|
|
37
|
+
session = sessions.from_chrome(args.cdp, args.profile_dir)
|
|
38
|
+
path = sessions.save(session)
|
|
39
|
+
who = asyncio.run(_whoami())
|
|
40
|
+
print(f"Saved {len(session.cookies)} {app.NAME} cookies to {path}")
|
|
41
|
+
print(f"Signed in to {app.NAME} as {who}" if who else f"Saved, but {app.NAME} did not confirm the session.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _whoami() -> str | None:
|
|
45
|
+
from contractor_foreman_api_mcp.contractor_foreman.auth import whoami
|
|
46
|
+
from contractor_foreman_api_mcp.core.context import ToolContext
|
|
47
|
+
|
|
48
|
+
ctx = ToolContext(session=sessions.load())
|
|
49
|
+
try:
|
|
50
|
+
return await whoami(ctx)
|
|
51
|
+
except ToolError:
|
|
52
|
+
return None
|
|
53
|
+
finally:
|
|
54
|
+
await ctx.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _status(_: argparse.Namespace) -> None:
|
|
58
|
+
session = sessions.load()
|
|
59
|
+
if not session.signed_in:
|
|
60
|
+
sys.exit(f"No {app.NAME} session. Run: {app.CLI} connect")
|
|
61
|
+
who = asyncio.run(_whoami())
|
|
62
|
+
if not who:
|
|
63
|
+
sys.exit(f"{app.NAME} signed this session out. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect")
|
|
64
|
+
source = {"auto": "your Chrome", "token": "a pasted token", None: f"{app.ENV}_AUTH_TOKEN"}.get(
|
|
65
|
+
session.source, f"Chrome at {urlsplit(session.source or '').netloc}"
|
|
66
|
+
)
|
|
67
|
+
print(f"Signed in to {app.NAME} as {who} (session from {source}, {sessions.session_path()})")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tools(args: argparse.Namespace) -> None:
|
|
71
|
+
specs = tools(args.read_only)
|
|
72
|
+
if args.json:
|
|
73
|
+
rows = [{"name": s.name, "access": s.access, "route": s.route, "input_schema": s.input_schema} for s in specs]
|
|
74
|
+
print(json.dumps(rows, indent=2))
|
|
75
|
+
return
|
|
76
|
+
for spec in specs:
|
|
77
|
+
print(f"{spec.name:<30} {spec.access:<5} {spec.route:<62} {spec.description}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_args(tool: str, pairs: list[str]) -> dict[str, Any]:
|
|
81
|
+
"""key=value pairs; a value is JSON unless the tool's schema wants a string (IDs like 1678164)."""
|
|
82
|
+
if len(pairs) == 1 and pairs[0].lstrip().startswith("{"):
|
|
83
|
+
return json.loads(pairs[0])
|
|
84
|
+
spec = all_tools().get(tool)
|
|
85
|
+
properties = spec.input_schema.get("properties", {}) if spec else {}
|
|
86
|
+
out: dict[str, Any] = {}
|
|
87
|
+
for pair in pairs:
|
|
88
|
+
key, sep, value = pair.partition("=")
|
|
89
|
+
if not sep:
|
|
90
|
+
sys.exit(f"Arguments are key=value or one JSON object, got {pair!r}")
|
|
91
|
+
if properties.get(key, {}).get("type") == "string":
|
|
92
|
+
out[key] = value
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
out[key] = json.loads(value)
|
|
96
|
+
except ValueError:
|
|
97
|
+
out[key] = value
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _call(args: argparse.Namespace) -> None:
|
|
102
|
+
runner = Runner(read_only=args.read_only)
|
|
103
|
+
|
|
104
|
+
async def go() -> Any:
|
|
105
|
+
try:
|
|
106
|
+
return await runner.run(args.tool, _parse_args(args.tool, args.arguments))
|
|
107
|
+
finally:
|
|
108
|
+
await runner.close()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
result = asyncio.run(go())
|
|
112
|
+
except ToolError as exc:
|
|
113
|
+
sys.exit(f"{exc.code}: {exc}")
|
|
114
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _mcp(args: argparse.Namespace) -> None:
|
|
118
|
+
from contractor_foreman_api_mcp.mcp_server import run_stdio
|
|
119
|
+
|
|
120
|
+
asyncio.run(run_stdio(Runner(read_only=args.read_only)))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _serve(args: argparse.Namespace) -> None:
|
|
124
|
+
import uvicorn
|
|
125
|
+
|
|
126
|
+
from contractor_foreman_api_mcp.api import create_app
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
loopback = ipaddress.ip_address(args.host).is_loopback
|
|
130
|
+
except ValueError:
|
|
131
|
+
loopback = args.host == "localhost"
|
|
132
|
+
if not loopback and not os.environ.get(f"{app.ENV}_MCP_TOKEN"):
|
|
133
|
+
sys.exit(
|
|
134
|
+
f"Listening beyond this machine needs {app.ENV}_MCP_TOKEN, or anyone on the network can use your {app.NAME} account."
|
|
135
|
+
)
|
|
136
|
+
print(
|
|
137
|
+
f"REST API http://{args.host}:{args.port}{app.API_PREFIX} docs http://{args.host}:{args.port}/docs",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
print(f"MCP http://{args.host}:{args.port}/mcp", file=sys.stderr)
|
|
141
|
+
uvicorn.run(create_app(Runner(read_only=args.read_only)), host=args.host, port=args.port, log_level="warning")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main() -> None:
|
|
145
|
+
parser = argparse.ArgumentParser(
|
|
146
|
+
prog=app.CLI,
|
|
147
|
+
description=(__doc__ or "").replace("<cli>", app.CLI),
|
|
148
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
151
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
152
|
+
|
|
153
|
+
p = sub.add_parser("connect", help=f"read your {app.NAME} session from Chrome and save it")
|
|
154
|
+
p.add_argument(
|
|
155
|
+
"--cdp", help="DevTools address of a Chrome started with --remote-debugging-port, e.g. http://127.0.0.1:9222"
|
|
156
|
+
)
|
|
157
|
+
p.add_argument("--profile-dir", help="Chrome profile folder holding DevToolsActivePort, when not the default")
|
|
158
|
+
p.add_argument("--token", action="store_true", help="paste the session cookies by hand instead")
|
|
159
|
+
p.set_defaults(func=_connect)
|
|
160
|
+
|
|
161
|
+
p = sub.add_parser("status", help=f"check the saved session with {app.NAME}")
|
|
162
|
+
p.set_defaults(func=_status)
|
|
163
|
+
|
|
164
|
+
for name, func, text in (
|
|
165
|
+
("tools", _tools, "list the tools"),
|
|
166
|
+
("mcp", _mcp, "MCP server over stdio"),
|
|
167
|
+
("serve", _serve, "REST API and MCP over HTTP"),
|
|
168
|
+
):
|
|
169
|
+
p = sub.add_parser(name, help=text)
|
|
170
|
+
p.add_argument(
|
|
171
|
+
"--read-only",
|
|
172
|
+
action="store_true",
|
|
173
|
+
default=os.environ.get(f"{app.ENV}_MCP_READ_ONLY") == "1",
|
|
174
|
+
help="only tools that read",
|
|
175
|
+
)
|
|
176
|
+
p.set_defaults(func=func)
|
|
177
|
+
if name == "tools":
|
|
178
|
+
p.add_argument("--json", action="store_true", help="names, routes and input schemas as JSON")
|
|
179
|
+
if name == "serve":
|
|
180
|
+
p.add_argument("--host", default=os.environ.get(f"{app.ENV}_MCP_HOST", "127.0.0.1"))
|
|
181
|
+
p.add_argument("--port", type=int, default=int(os.environ.get(f"{app.ENV}_MCP_PORT", "8787")))
|
|
182
|
+
|
|
183
|
+
p = sub.add_parser("call", help="run one tool and print its JSON answer")
|
|
184
|
+
p.add_argument("tool")
|
|
185
|
+
p.add_argument("arguments", nargs="*", help="key=value pairs or one JSON object, e.g. project_id=123 limit=10")
|
|
186
|
+
p.add_argument("--read-only", action="store_true")
|
|
187
|
+
p.set_defaults(func=_call)
|
|
188
|
+
|
|
189
|
+
args = parser.parse_args()
|
|
190
|
+
try:
|
|
191
|
+
args.func(args)
|
|
192
|
+
except ToolError as exc:
|
|
193
|
+
sys.exit(f"{exc.code}: {exc}")
|
|
194
|
+
except KeyboardInterrupt:
|
|
195
|
+
sys.exit(130)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Who is signed in: the panel's own app data (POST /api/get-app-data), for "Signed in as ..."."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from contractor_foreman_api_mcp.contractor_foreman.client import api
|
|
8
|
+
from contractor_foreman_api_mcp.core.errors import AuthExpiredError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from contractor_foreman_api_mcp.core.context import ToolContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def whoami(ctx: ToolContext) -> str | None:
|
|
15
|
+
"""None only when Contractor Foreman says signed out; any other failure raises (detection tries again later)."""
|
|
16
|
+
try:
|
|
17
|
+
body = await api(ctx, "get-app-data")
|
|
18
|
+
except AuthExpiredError:
|
|
19
|
+
return None
|
|
20
|
+
me = ((body.get("data") or {}).get("user") or {}).get("data") or {}
|
|
21
|
+
name = me.get("display_name") or " ".join(p for p in (me.get("first_name"), me.get("last_name")) if p).strip()
|
|
22
|
+
email = me.get("email")
|
|
23
|
+
return f"{name} ({email})" if name and email else name or email or None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Contractor Foreman panel requests, recorded 21.10.2026.
|
|
2
|
+
|
|
3
|
+
The panel (app.contractorforeman.net, React Router) sends its API calls from a web worker as plain
|
|
4
|
+
`fetch` to same-host `/api/...` paths: JSON bodies with `content-type: text/plain;charset=UTF-8`, no auth
|
|
5
|
+
header, only the `_session__240322` cookie. A signed-out call still answers HTTP 200, with
|
|
6
|
+
`{"statusCode": 401, "unauthorize": true}` in the body, so the core cannot see it: `api` turns it into
|
|
7
|
+
`auth_expired`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from contractor_foreman_api_mcp.core.errors import AuthExpiredError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from contractor_foreman_api_mcp.core.context import ToolContext
|
|
19
|
+
|
|
20
|
+
APP = "https://app.contractorforeman.net"
|
|
21
|
+
_HEADERS = {"content-type": "text/plain;charset=UTF-8", "origin": APP, "referer": f"{APP}/"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def api(ctx: ToolContext, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
25
|
+
"""POST one panel API call and return its JSON. Raises `auth_expired` when the app says signed out."""
|
|
26
|
+
response = await ctx.call("POST", f"{APP}/api/{path}", data=json.dumps(payload or {}), headers=_HEADERS)
|
|
27
|
+
body: dict[str, Any] = response.json()
|
|
28
|
+
if body.get("unauthorize") or body.get("statusCode") == 401:
|
|
29
|
+
raise AuthExpiredError("The app signed this account out. Reconnect it in the console.")
|
|
30
|
+
return body
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Change order reads and item writes shared by the change order tools (recorded 21.10.2026).
|
|
2
|
+
|
|
3
|
+
A change order (`type` co) or a change order request (`type` cor) belongs to a project. Its status is
|
|
4
|
+
`billing_status`, an id from two lists: CHANGE_ORDER_STATUSES for co, REQUEST_STATUSES for cor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from contractor_foreman_api_mcp.contractor_foreman.tools._finance_common import (
|
|
12
|
+
CHANGE_ORDER_STATUSES,
|
|
13
|
+
REQUEST_STATUSES,
|
|
14
|
+
call,
|
|
15
|
+
dollars,
|
|
16
|
+
iso,
|
|
17
|
+
item_in,
|
|
18
|
+
item_out,
|
|
19
|
+
key_of,
|
|
20
|
+
number,
|
|
21
|
+
plain,
|
|
22
|
+
)
|
|
23
|
+
from contractor_foreman_api_mcp.core.errors import ErrorCode, ToolError
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from contractor_foreman_api_mcp.core.context import ToolContext
|
|
27
|
+
|
|
28
|
+
STATUS_NAMES = sorted(set(CHANGE_ORDER_STATUSES) | set(REQUEST_STATUSES))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def status_of(kind: str, value: Any) -> str | None:
|
|
32
|
+
return key_of(REQUEST_STATUSES if kind == "cor" else CHANGE_ORDER_STATUSES, value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def status_id(kind: str, status: str) -> int:
|
|
36
|
+
statuses = REQUEST_STATUSES if kind == "cor" else CHANGE_ORDER_STATUSES
|
|
37
|
+
if status not in statuses:
|
|
38
|
+
label = "a change order request" if kind == "cor" else "a change order"
|
|
39
|
+
raise ToolError(f"Status of {label} is one of: {', '.join(statuses)}.", ErrorCode.INVALID_INPUT)
|
|
40
|
+
return statuses[status]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def row_out(row: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
kind = row.get("type") or "co"
|
|
45
|
+
return {
|
|
46
|
+
"change_order_id": str(row.get("change_order_id")),
|
|
47
|
+
"number": row.get("prefix_company_order_id") or row.get("company_order_id"),
|
|
48
|
+
"title": row.get("subject"),
|
|
49
|
+
"kind": "request" if kind == "cor" else "change_order",
|
|
50
|
+
"status": status_of(kind, row.get("billing_status")),
|
|
51
|
+
"status_name": row.get("billing_status_name"),
|
|
52
|
+
"project_id": str(row["project_id"]) if number(row.get("project_id")) else None,
|
|
53
|
+
"project_name": row.get("project_name"),
|
|
54
|
+
"customer_name": plain(row.get("customer_name")),
|
|
55
|
+
"date": iso(row.get("order_date")),
|
|
56
|
+
"total": dollars(row.get("total")) or 0.0,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def detail(ctx: ToolContext, change_order_id: str) -> dict[str, Any]:
|
|
61
|
+
data = (await call(ctx, "change-orders/get-order-detail", {"change_order_id": int(change_order_id)})).get("data")
|
|
62
|
+
if not data:
|
|
63
|
+
raise ToolError("No such change order.", ErrorCode.INVALID_INPUT)
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def sections(ctx: ToolContext, change_order_id: str) -> list[dict[str, Any]]:
|
|
68
|
+
body = await call(ctx, "change-orders/get-items", {"change_order_id": int(change_order_id), "need_section": 1})
|
|
69
|
+
return body.get("data") or []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def full_out(data: dict[str, Any], parts: list[dict[str, Any]]) -> dict[str, Any]:
|
|
73
|
+
items = [item_out(item) for part in parts for item in part.get("items") or []]
|
|
74
|
+
return {
|
|
75
|
+
**row_out(data),
|
|
76
|
+
"description": plain(data.get("notes")),
|
|
77
|
+
"customer_id": str(data["customer_id"]) if number(data.get("customer_id")) else None,
|
|
78
|
+
"estimate_id": str(data["estimate_id"]) if number(data.get("estimate_id")) else None,
|
|
79
|
+
"time_delay_days": data.get("day_delay") or None,
|
|
80
|
+
"items": items,
|
|
81
|
+
"sections": [{"section_id": str(part.get("section_id")), "name": part.get("section_name")} for part in parts],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def add_items(ctx: ToolContext, change_order_id: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
86
|
+
parts = await sections(ctx, change_order_id)
|
|
87
|
+
if not parts:
|
|
88
|
+
raise ToolError("This change order has no section to add items to.", ErrorCode.TARGET_ERROR)
|
|
89
|
+
start = sum(len(part.get("items") or []) for part in parts)
|
|
90
|
+
section_id = parts[0]["section_id"]
|
|
91
|
+
payload = [item_in(item, section_id, start + i + 1, "order_item_no") for i, item in enumerate(items)]
|
|
92
|
+
saved = await call(ctx, "change-orders/add-items", {"change_order_id": int(change_order_id), "items": payload})
|
|
93
|
+
return [item_out(row) for row in saved.get("data") or []]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def customer_email(ctx: ToolContext, data: dict[str, Any]) -> str | None:
|
|
97
|
+
"""The detail call has no customer email; the list row of the same change order has it."""
|
|
98
|
+
filters = {"status": "", "project": str(data.get("project_id") or "")}
|
|
99
|
+
for page in range(5):
|
|
100
|
+
body = await call(
|
|
101
|
+
ctx,
|
|
102
|
+
"change-orders/get-list",
|
|
103
|
+
{"page": page, "limit": 100, "type": data.get("type") or "co", "ignore_filter": 1, "filter": filters},
|
|
104
|
+
)
|
|
105
|
+
rows = (body.get("data") or {}).get("changeOrders") or []
|
|
106
|
+
for row in rows:
|
|
107
|
+
if str(row.get("change_order_id")) == str(data.get("change_order_id")):
|
|
108
|
+
return plain(row.get("email"))
|
|
109
|
+
if len(rows) < 100:
|
|
110
|
+
break
|
|
111
|
+
return None
|