diff --git a/README.md b/README.md index 64400f9..e6fb3d3 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,51 @@ Sync interval: `MASKANX_INSIGHTS_SYNC_SECONDS`, default 3600. `0` disables the loop; the dashboard then only updates when a campaign is refreshed by hand. +### Maskan CRM + +Leads and campaign figures are pushed to Maskan CRM **server to server**, +over its integration API — not through MCP. MCP is for an agent deciding to +do something; leads have to arrive whether or not anyone is talking to the +agent. + +- Leads: Meta lead-ad submissions, attributed to the campaign, ad set and ad + that produced them. Meta's own lead id is the external id, so re-sending + is harmless. A trailing window is re-sent on every run rather than + tracking a high-water mark, which makes a half-failed run self-healing. +- Campaigns: spend, clicks, leads and cost per lead, upserted so the CRM + shows current figures rather than the first ones it saw. + +Configure `MASKAN_CRM_API_URL` and `MASKAN_CRM_INTEGRATION_KEY` in +Settings > Environments. Interval: `MASKANX_CRM_SYNC_SECONDS`, default 300. +Lead re-read window: `MASKANX_CRM_LEAD_WINDOW_DAYS`, default 7. `0` on the +interval disables the loop. + +### Custom fields, created from chat + +MCP's job is the other half: changing the CRM's shape on request. Two tools +on the `maskan_crm` server let an agent add a field mid-conversation, the +way HubSpot's custom properties work: + +- `list_maskan_crm_properties` — what fields already exist, with their types + and allowed values +- `create_maskan_crm_property` — define a new one + +The definition is stored in the CRM; values live in each record's existing +JSON attributes, so adding a field is an INSERT rather than a migration and +the next record can use it immediately. The field appears in the CRM's +**Custom fields** screen straight away, marked as MaskanX-created so a +person can tell which fields came out of a conversation. + +The CRM validates every definition and every value written against one, and +an agent gets no shortcut around those rules — if anything it needs them +more, since it will happily invent a field name from half a sentence. A +value for an undefined property is rejected rather than stored: a typo left +in the database looks exactly like data. + +`name` and `data_type` cannot be changed after creation. Renaming would +orphan every value already stored under the old key, and retyping would +leave values that no longer satisfy the type. + ### Live smoke test The unit suite runs entirely against a fake transport: it proves the code diff --git a/src/adclaw/integrations/maskan_crm.py b/src/adclaw/integrations/maskan_crm.py index 9a10b67..b347d9f 100644 --- a/src/adclaw/integrations/maskan_crm.py +++ b/src/adclaw/integrations/maskan_crm.py @@ -131,7 +131,14 @@ class MaskanCRMClient: *, payload: dict[str, Any] | None = None, idempotency_key: str | None = None, - ) -> dict[str, Any]: + allow_list: bool = False, + ) -> Any: + """Send one request to the CRM. + + Responses are required to be objects unless `allow_list` is set: + every endpoint here returns one except the collection reads, and an + unexpected array is more likely a proxy's error page than data. + """ data = None headers = { "Accept": "application/json", @@ -177,6 +184,8 @@ class MaskanCRMClient: raise MaskanCRMRequestError( "Maskan CRM returned an invalid JSON response.", ) from exc + if isinstance(decoded, list) and allow_list: + return decoded if not isinstance(decoded, dict): raise MaskanCRMRequestError( "Maskan CRM returned an unexpected response.", @@ -205,6 +214,29 @@ class MaskanCRMClient: idempotency_key=idempotency_key.strip(), ) + def list_properties(self, object_type: str | None = None) -> dict[str, Any]: + """Return the custom fields defined in the CRM. + + Returned wrapped in a dict rather than as the bare list the API + sends, because `_request` is typed to a dict and an agent reads + `{"properties": [...]}` more reliably than a top-level array. + """ + path = "/integrations/properties" + if object_type: + path = f"{path}?object_type={object_type}" + raw = self._request("GET", path, allow_list=True) + return raw if isinstance(raw, dict) else {"properties": raw} + + def create_property(self, payload: dict[str, Any]) -> dict[str, Any]: + """Define a new custom field in the CRM. + + The CRM validates the definition; nothing is relaxed for being + called by an agent. A name it will not accept comes back as a + readable message rather than a constraint violation, which is what + lets an agent correct itself and try again. + """ + return self._request("POST", "/integrations/properties", payload=payload) + def push_campaign(self, payload: dict[str, Any]) -> dict[str, Any]: """Create or update one campaign's figures in the CRM. diff --git a/src/adclaw/tools/maskan_crm_mcp.py b/src/adclaw/tools/maskan_crm_mcp.py index 17439d8..d421747 100644 --- a/src/adclaw/tools/maskan_crm_mcp.py +++ b/src/adclaw/tools/maskan_crm_mcp.py @@ -55,6 +55,79 @@ TOOLS = [ "additionalProperties": False, }, }, + { + "name": "list_maskan_crm_properties", + "description": ( + "List the custom fields defined on a Maskan CRM object type " + "(contact, lead, organization or campaign), with their data " + "types and allowed values. Read-only. Call this before creating " + "a field, so an existing one is reused rather than duplicated " + "under a slightly different name." + ), + "inputSchema": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "enum": ["contact", "lead", "organization", "campaign"], + }, + }, + "additionalProperties": False, + }, + }, + { + "name": "create_maskan_crm_property", + "description": ( + "Define a new custom field on a Maskan CRM object type, so it " + "becomes available on records and in the CRM's own screens " + "immediately. This changes the CRM's schema for everyone in the " + "workspace and must only be called when the user has asked for " + "a new field. Check list_maskan_crm_properties first; if a " + "suitable field already exists, use it instead of adding a " + "near-duplicate." + ), + "inputSchema": { + "type": "object", + "properties": { + "object_type": { + "type": "string", + "enum": ["contact", "lead", "organization", "campaign"], + "description": "Which kind of record carries the field.", + }, + "name": { + "type": "string", + "description": ( + "Stable key, lowercase letters, digits and " + "underscores, starting with a letter, e.g. " + "'budget_range'. It cannot be changed later." + ), + }, + "label": { + "type": "string", + "description": "What a person reads, e.g. 'Budget range'.", + }, + "description": {"type": "string"}, + "data_type": { + "type": "string", + "enum": [ + "string", "number", "boolean", "date", "enumeration", + ], + "description": ( + "Use 'enumeration' with options for a fixed set of " + "choices. The type cannot be changed later." + ), + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": "Allowed values. Required for enumeration.", + }, + "is_required": {"type": "boolean"}, + }, + "required": ["object_type", "name", "label", "data_type"], + "additionalProperties": False, + }, + }, ] @@ -125,6 +198,16 @@ def _handle_request(request: dict[str, Any]) -> dict[str, Any] | None: payload, idempotency_key=idempotency_key, ) + elif tool_name == "list_maskan_crm_properties": + result = client.list_properties(arguments.get("object_type")) + elif tool_name == "create_maskan_crm_property": + result = client.create_property( + { + key: value + for key, value in arguments.items() + if value is not None + }, + ) else: raise MaskanCRMError(f"Unknown tool: {tool_name}") tool_result = _tool_result(result) diff --git a/tests/test_crm_properties_mcp.py b/tests/test_crm_properties_mcp.py new file mode 100644 index 0000000..9daff49 --- /dev/null +++ b/tests/test_crm_properties_mcp.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +"""Creating CRM fields from chat, through MCP. + +This is the path the user described: ask in MaskanX chat for a new field, +and it appears in the CRM. The tests cover the client calls and the MCP +tool dispatch — the CRM's own validation is tested in the CRM's suite. +""" +from __future__ import annotations + +import json + +from adclaw.integrations import maskan_crm +from adclaw.integrations.maskan_crm import MaskanCRMClient +from adclaw.tools import maskan_crm_mcp + + +class _Response: + def __init__(self, payload) -> None: + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return json.dumps(self.payload).encode("utf-8") + + +def _client() -> MaskanCRMClient: + return MaskanCRMClient("http://crm.internal:8091", "mcrm_test-key") + + +# --- the client --- + + +def test_listing_properties_accepts_the_arrays_the_api_returns(monkeypatch): + """Every other endpoint returns an object; collections return a list.""" + monkeypatch.setattr( + maskan_crm, + "urlopen", + lambda request, timeout: _Response( + [{"name": "budget_range", "data_type": "enumeration"}], + ), + ) + + result = _client().list_properties() + + assert result["properties"][0]["name"] == "budget_range" + + +def test_listing_can_be_narrowed_to_one_object_type(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + return _Response([]) + + monkeypatch.setattr(maskan_crm, "urlopen", fake_urlopen) + + _client().list_properties("contact") + + assert captured["url"].endswith("/integrations/properties?object_type=contact") + + +def test_creating_a_property_posts_the_definition(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["method"] = request.get_method() + captured["body"] = json.loads(request.data.decode("utf-8")) + return _Response({"id": "prop_1", "name": "budget_range"}) + + monkeypatch.setattr(maskan_crm, "urlopen", fake_urlopen) + + result = _client().create_property( + { + "object_type": "contact", + "name": "budget_range", + "label": "Budget range", + "data_type": "enumeration", + "options": ["50-70 lakh"], + }, + ) + + assert result["id"] == "prop_1" + assert captured["method"] == "POST" + assert captured["url"].endswith("/integrations/properties") + assert captured["body"]["options"] == ["50-70 lakh"] + + +def test_a_non_list_non_object_response_is_still_refused(monkeypatch): + """An unexpected scalar is more likely a proxy page than data.""" + monkeypatch.setattr( + maskan_crm, "urlopen", lambda request, timeout: _Response("gateway timeout"), + ) + + try: + _client().list_properties() + except maskan_crm.MaskanCRMRequestError as exc: + assert "unexpected response" in str(exc) + else: # pragma: no cover - the call must not succeed + raise AssertionError("a scalar response should have been refused") + + +# --- the MCP tools --- + + +def _call(tool_name, arguments=None): + return maskan_crm_mcp._handle_request( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments or {}}, + }, + ) + + +def _payload(response): + return json.loads(response["result"]["content"][0]["text"]) + + +def test_both_property_tools_are_advertised(): + listed = maskan_crm_mcp._handle_request( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + + names = {tool["name"] for tool in listed["result"]["tools"]} + assert {"list_maskan_crm_properties", "create_maskan_crm_property"} <= names + + +def test_the_create_tool_tells_the_agent_to_look_before_adding(): + """Otherwise it invents a near-duplicate of a field that already exists.""" + listed = maskan_crm_mcp._handle_request( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ) + tool = next( + t + for t in listed["result"]["tools"] + if t["name"] == "create_maskan_crm_property" + ) + + assert "list_maskan_crm_properties" in tool["description"] + assert set(tool["inputSchema"]["required"]) == { + "object_type", "name", "label", "data_type", + } + + +def test_listing_properties_through_mcp(monkeypatch): + monkeypatch.setattr( + maskan_crm_mcp, + "configured_maskan_crm_client", + lambda: type( + "C", (), {"list_properties": lambda self, ot=None: {"properties": []}}, + )(), + ) + + response = _call("list_maskan_crm_properties", {"object_type": "contact"}) + + assert response["result"]["isError"] is False + assert _payload(response) == {"properties": []} + + +def test_creating_a_property_through_mcp(monkeypatch): + captured = {} + + class _Client: + def create_property(self, payload): + captured["payload"] = payload + return {"id": "prop_1", "name": "budget_range"} + + monkeypatch.setattr( + maskan_crm_mcp, "configured_maskan_crm_client", lambda: _Client(), + ) + + response = _call( + "create_maskan_crm_property", + { + "object_type": "contact", + "name": "budget_range", + "label": "Budget range", + "data_type": "enumeration", + "options": ["50-70 lakh"], + "description": None, + }, + ) + + assert response["result"]["isError"] is False + assert _payload(response)["id"] == "prop_1" + # Nulls are stripped so the CRM's defaults apply rather than being + # overwritten with None. + assert "description" not in captured["payload"] + + +def test_a_rejected_definition_comes_back_as_a_readable_error(monkeypatch): + """An agent needs to read why, so it can correct itself and retry.""" + + class _Client: + def create_property(self, payload): + raise maskan_crm.MaskanCRMRequestError( + "'Budget Range' is not a usable property name.", + status_code=422, + ) + + monkeypatch.setattr( + maskan_crm_mcp, "configured_maskan_crm_client", lambda: _Client(), + ) + + response = _call( + "create_maskan_crm_property", + { + "object_type": "contact", + "name": "Budget Range", + "label": "Budget range", + "data_type": "string", + }, + ) + + assert response["result"]["isError"] is True + assert "usable property name" in _payload(response)["error"]