feat(crm): let an agent add CRM fields from chat, over MCP
Two tools on the maskan_crm server: one to list the fields that exist, one to define a new one. Asking in MaskanX chat for a field now creates it in the CRM, where it is usable on the next record and visible in the CRM's own screens. The list tool exists mainly so the create tool has something to check against — without it an agent invents a near-duplicate of a field that is already there under a slightly different name, and its description says so. Nulls are stripped from the definition before it is sent, so the CRM's defaults apply rather than being overwritten with None. A rejected definition comes back as the CRM's own message, which is what lets an agent correct itself and retry rather than reporting a constraint violation to the user. _request now accepts a list response behind an explicit flag. Every endpoint returns an object except the collection reads, and an unexpected array is more likely a proxy's error page than data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user