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:
AFFAANh
2026-08-04 11:07:35 +05:30
co-authored by Claude Opus 5
parent b4007ce825
commit b63b852cbe
4 changed files with 384 additions and 1 deletions
+45
View File
@@ -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
+33 -1
View File
@@ -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.
+83
View File
@@ -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)
+223
View File
@@ -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"]