From d8c116abdd6ee4989b2988de72060c85b4472124 Mon Sep 17 00:00:00 2001 From: Krishnakalani111 Date: Sat, 11 Jul 2026 18:21:16 +0530 Subject: [PATCH 1/6] feat: DocType field to JSON schema helper First piece for auto-exposing DocTypes as MCP tools. --- frappe_mcp/server/tools/doctype.py | 71 +++++++++++++ frappe_mcp/server/tools/test_doctype.py | 132 ++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 frappe_mcp/server/tools/doctype.py create mode 100644 frappe_mcp/server/tools/test_doctype.py diff --git a/frappe_mcp/server/tools/doctype.py b/frappe_mcp/server/tools/doctype.py new file mode 100644 index 0000000..ede4218 --- /dev/null +++ b/frappe_mcp/server/tools/doctype.py @@ -0,0 +1,71 @@ +"""Translate Frappe DocType fields into JSON Schema fragments. + +Frappe speaks fieldtypes ("Currency", "Link", "Check"); MCP clients speak +JSON Schema. Bridging that gap is what lets `expose_doctype()` (upcoming) +auto-generate LLM-callable tools from a DocType's metadata. +""" + +from __future__ import annotations + +from typing import Any + +# Not derived from `frappe.model.numeric_fieldtypes` etc. — Frappe's groupings +# classify by DB storage, not JSON Schema shape: Check would fall in as int, +# Rating wouldn't fall in at all. +_BASE_SCHEMAS: dict[str, dict[str, Any]] = { + "Check": {"type": "boolean"}, # Frappe stores 0/1; JSON bool is nicer for LLMs. + "Int": {"type": "integer"}, + "Long Int": {"type": "integer"}, + "Float": {"type": "number"}, + "Currency": {"type": "number"}, + "Percent": {"type": "number"}, + "Rating": {"type": "number"}, + "Date": {"type": "string", "format": "date"}, + "Datetime": {"type": "string", "format": "date-time"}, + "Time": {"type": "string", "format": "time"}, +} + + +def frappe_fieldtype_to_json_schema(field: dict[str, Any]) -> dict[str, Any]: + """One entry for a tool's `inputSchema.properties[field.fieldname]`. + + Reads only `fieldtype`, `options`, `label`, `fieldname` from `field`. + Select gets an `enum`; Link/Dynamic Link get an `x-frappe-link-doctype` + hint carrying the target DocType. + """ + fieldtype = field.get("fieldtype") or "Data" + + if fieldtype == "Select": + schema = _select_schema(field) + elif fieldtype in ("Link", "Dynamic Link"): + schema = _link_schema(field) + else: + schema = dict(_BASE_SCHEMAS.get(fieldtype, {"type": "string"})) + + return _with_label(schema, field) + + +def _select_schema(field: dict[str, Any]) -> dict[str, Any]: + # Frappe stores Select options as a newline-separated string. + raw = (field.get("options") or "").strip() + options = [line.strip() for line in raw.splitlines() if line.strip()] + schema: dict[str, Any] = {"type": "string"} + if options: + schema["enum"] = options + return schema + + +def _link_schema(field: dict[str, Any]) -> dict[str, Any]: + schema: dict[str, Any] = {"type": "string"} + target = (field.get("options") or "").strip() + if target: + schema["x-frappe-link-doctype"] = target + return schema + + +def _with_label(schema: dict[str, Any], field: dict[str, Any]) -> dict[str, Any]: + if "description" not in schema: + label = (field.get("label") or field.get("fieldname") or "").strip() + if label: + schema["description"] = label + return schema diff --git a/frappe_mcp/server/tools/test_doctype.py b/frappe_mcp/server/tools/test_doctype.py new file mode 100644 index 0000000..1dfc81c --- /dev/null +++ b/frappe_mcp/server/tools/test_doctype.py @@ -0,0 +1,132 @@ +"""Tests for the DocType fieldtype -> JSON Schema helper. + +These tests use plain dict fixtures shaped like Frappe `DocField` objects. +They exercise `frappe_fieldtype_to_json_schema` in isolation — no bench, no +site, no database. That's deliberate: the helper is a pure function of its +input, and the DocType-integration tests live in test_tools.py once the +`expose_doctype` wiring lands. +""" + +from __future__ import annotations + +import pytest + +from frappe_mcp.server.tools.doctype import frappe_fieldtype_to_json_schema + + +# A minimal DocField shape. Real DocFields have ~40 keys; the helper only +# reads these four, and the test proves that boundary. +def _field(fieldtype: str, **overrides) -> dict: + # Deliberately no default `fieldname` / `label`: tests that don't set them + # expect the schema to omit `description`. + base = {"fieldtype": fieldtype, "label": "", "fieldname": "", "options": ""} + base.update(overrides) + return base + + +class TestTextFieldtypes: + """Text-shaped fieldtypes and unknown fieldtypes both fall through to string.""" + + @pytest.mark.parametrize( + "fieldtype", + [ + "Data", "Small Text", "Long Text", "Text", + "Text Editor", "Markdown Editor", "HTML Editor", + "Code", "JSON", "Password", "Read Only", + "Autocomplete", "Attach", "Attach Image", "Signature", + "Color", "Geolocation", "Barcode", "Duration", + "Phone", "Icon", # Frappe adds these to data_fieldtypes; still strings. + "SomeFutureFieldtypeFrappeMightAdd", # unknown -> permissive default. + ], + ) + def test_maps_to_string(self, fieldtype): + assert frappe_fieldtype_to_json_schema(_field(fieldtype))["type"] == "string" + + +class TestNumericFieldtypes: + @pytest.mark.parametrize("fieldtype", ["Int", "Long Int"]) + def test_integer_types(self, fieldtype): + assert frappe_fieldtype_to_json_schema(_field(fieldtype)) == {"type": "integer"} + + @pytest.mark.parametrize("fieldtype", ["Float", "Currency", "Percent", "Rating"]) + def test_number_types(self, fieldtype): + # Rating is deliberately here: Frappe classifies it outside + # numeric_fieldtypes, but semantically it is a number. + assert frappe_fieldtype_to_json_schema(_field(fieldtype)) == {"type": "number"} + + +class TestCheckIsBoolean: + def test_check_maps_to_boolean_not_integer(self): + # Frappe stores Check as 0/1 (it's in numeric_fieldtypes), but a + # JSON boolean is what an LLM should generate. Regression guard. + assert frappe_fieldtype_to_json_schema(_field("Check")) == {"type": "boolean"} + + +class TestDateTimeFieldtypes: + @pytest.mark.parametrize( + "fieldtype, expected_format", + [("Date", "date"), ("Datetime", "date-time"), ("Time", "time")], + ) + def test_datetime_types(self, fieldtype, expected_format): + schema = frappe_fieldtype_to_json_schema(_field(fieldtype)) + assert schema["type"] == "string" + assert schema["format"] == expected_format + + +class TestSelectFieldtype: + def test_options_become_enum(self): + field = _field("Select", options="Open\nClosed\nCancelled", label="Status") + assert frappe_fieldtype_to_json_schema(field) == { + "type": "string", + "enum": ["Open", "Closed", "Cancelled"], + "description": "Status", + } + + def test_blank_lines_and_whitespace_are_stripped(self): + field = _field("Select", options="\n Open \n\nClosed\n \n") + assert frappe_fieldtype_to_json_schema(field)["enum"] == ["Open", "Closed"] + + def test_no_options_omits_enum(self): + # Select fields without options are unusual but must not blow up. + schema = frappe_fieldtype_to_json_schema(_field("Select", options="")) + assert schema == {"type": "string"} + + +class TestLinkFieldtypes: + @pytest.mark.parametrize("fieldtype", ["Link", "Dynamic Link"]) + def test_target_doctype_becomes_hint(self, fieldtype): + field = _field(fieldtype, options="User", label="Assigned To") + schema = frappe_fieldtype_to_json_schema(field) + # The x-frappe-* extension is what MCP clients / LLMs use to know + # "pass the primary key of a User here" rather than any string. + assert schema == { + "type": "string", + "x-frappe-link-doctype": "User", + "description": "Assigned To", + } + + def test_link_without_target_still_returns_string(self): + schema = frappe_fieldtype_to_json_schema(_field("Link", options="")) + assert schema == {"type": "string"} + assert "x-frappe-link-doctype" not in schema + + +class TestLabelBecomesDescription: + def test_label_is_used_as_description(self): + schema = frappe_fieldtype_to_json_schema(_field("Data", label="Full Name")) + assert schema["description"] == "Full Name" + + def test_fieldname_used_when_label_missing(self): + schema = frappe_fieldtype_to_json_schema(_field("Data", label="", fieldname="full_name")) + assert schema["description"] == "full_name" + + def test_no_description_when_both_missing(self): + schema = frappe_fieldtype_to_json_schema({"fieldtype": "Data"}) + assert "description" not in schema + + +class TestDefaults: + def test_missing_fieldtype_treated_as_data(self): + # DocFields nearly always have fieldtype, but defensive: if it's + # missing/None the caller should still get a callable schema. + assert frappe_fieldtype_to_json_schema({})["type"] == "string" From 0cd7a96a81c6315a839121902eee282aa35b7241 Mon Sep 17 00:00:00 2001 From: Krishnakalani111 Date: Sat, 11 Jul 2026 18:59:45 +0530 Subject: [PATCH 2/6] feat: add 'get' operation via build_tool dispatch One factory (build_tool(doctype, op)) that match/cases on op. Get is the first case. --- frappe_mcp/server/tools/doctype.py | 58 +++++++++++++++++++++ frappe_mcp/server/tools/test_doctype.py | 67 ++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/frappe_mcp/server/tools/doctype.py b/frappe_mcp/server/tools/doctype.py index ede4218..44c2c52 100644 --- a/frappe_mcp/server/tools/doctype.py +++ b/frappe_mcp/server/tools/doctype.py @@ -9,6 +9,8 @@ from typing import Any +from frappe_mcp.server.tools import Tool + # Not derived from `frappe.model.numeric_fieldtypes` etc. — Frappe's groupings # classify by DB storage, not JSON Schema shape: Check would fall in as int, # Rating wouldn't fall in at all. @@ -69,3 +71,59 @@ def _with_label(schema: dict[str, Any], field: dict[str, Any]) -> dict[str, Any] if label: schema["description"] = label return schema + + +def _frappe(): + # Lazy: keep this module importable without a bench, so pure helpers stay + # unit-testable and the Frappe dependency is confined to the tool handlers. + import frappe + return frappe + + +def _snake(doctype: str) -> str: + # Matches frappe.scrub: whitespace -> underscore, lowercased. + return doctype.replace(" ", "_").lower() + + +def _require_permission(doctype: str, ptype: str, *, doc: str | None = None) -> None: + """Raise `PermissionError` if the current user can't `ptype` on `doctype`.""" + frappe = _frappe() + if frappe.has_permission(doctype, ptype=ptype, doc=doc, throw=False): + return + target = f"{doctype} '{doc}'" if doc else doctype + raise PermissionError(f"No {ptype} permission on {target}") + + +def build_tool(doctype: str, operation: str, *, check_permissions: bool = True) -> Tool: + """Return the MCP Tool spec for one `operation` on `doctype`. + + A spec — this doesn't call Frappe. Frappe is invoked only when a client + (Claude, an MCP Inspector session, ...) actually calls the returned tool. + """ + match operation: + case "get": + return _get(doctype, check_permissions=check_permissions) + case _: + raise ValueError(f"Unsupported operation: {operation!r}") + + +def _get(doctype: str, *, check_permissions: bool) -> Tool: + input_schema = { + "type": "object", + "properties": {"name": {"type": "string", "description": f"{doctype} name"}}, + "required": ["name"], + } + + def handler(name: str) -> dict[str, Any]: + if check_permissions: + _require_permission(doctype, "read", doc=name) + return _frappe().get_doc(doctype, name).as_dict() + + return Tool( + name=f"get_{_snake(doctype)}", + description=f"Get a {doctype} by name.", + input_schema=input_schema, + output_schema=None, + annotations={"readOnlyHint": True}, + fn=handler, + ) diff --git a/frappe_mcp/server/tools/test_doctype.py b/frappe_mcp/server/tools/test_doctype.py index 1dfc81c..a552ccc 100644 --- a/frappe_mcp/server/tools/test_doctype.py +++ b/frappe_mcp/server/tools/test_doctype.py @@ -9,9 +9,21 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest -from frappe_mcp.server.tools.doctype import frappe_fieldtype_to_json_schema +from frappe_mcp.server.tools import doctype as doctype_module +from frappe_mcp.server.tools.doctype import build_tool, frappe_fieldtype_to_json_schema + + +def _fake_frappe(*, permission: bool = True, doc: dict | None = None): + """Minimal frappe stand-in for handler tests.""" + doc = doc if doc is not None else {"name": "x", "doctype": "ToDo"} + return SimpleNamespace( + has_permission=lambda *a, **k: permission, + get_doc=lambda dt, name: SimpleNamespace(as_dict=lambda: {**doc, "name": name, "doctype": dt}), + ) # A minimal DocField shape. Real DocFields have ~40 keys; the helper only @@ -130,3 +142,56 @@ def test_missing_fieldtype_treated_as_data(self): # DocFields nearly always have fieldtype, but defensive: if it's # missing/None the caller should still get a callable schema. assert frappe_fieldtype_to_json_schema({})["type"] == "string" + + +class TestBuildToolDispatch: + def test_unknown_operation_raises(self): + with pytest.raises(ValueError, match="Unsupported operation"): + build_tool("ToDo", "explode") + + +class TestGetOperation: + @pytest.mark.parametrize( + "doctype, expected_name", + [("ToDo", "get_todo"), ("Sales Invoice", "get_sales_invoice"), ("User", "get_user")], + ) + def test_tool_name_is_snake_cased(self, doctype, expected_name): + assert build_tool(doctype, "get")["name"] == expected_name + + def test_input_schema_requires_string_name(self): + schema = build_tool("ToDo", "get")["input_schema"] + assert schema["type"] == "object" + assert schema["required"] == ["name"] + assert schema["properties"]["name"]["type"] == "string" + + def test_marked_read_only(self): + # Signals to MCP clients / LLMs that the tool is safe to call speculatively. + assert build_tool("ToDo", "get")["annotations"]["readOnlyHint"] is True + + def test_handler_returns_document_as_dict(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe()) + result = build_tool("ToDo", "get")["fn"](name="task-1") + assert result["doctype"] == "ToDo" + assert result["name"] == "task-1" + + def test_handler_raises_when_permission_denied(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(permission=False)) + with pytest.raises(PermissionError): + build_tool("ToDo", "get")["fn"](name="task-1") + + def test_check_permissions_false_skips_the_check(self, monkeypatch): + calls = [] + + def has_permission(*a, **k): + calls.append((a, k)) + return False + + fake = SimpleNamespace( + has_permission=has_permission, + get_doc=lambda dt, name: SimpleNamespace(as_dict=lambda: {"name": name}), + ) + monkeypatch.setattr(doctype_module, "_frappe", lambda: fake) + + build_tool("ToDo", "get", check_permissions=False)["fn"](name="task-1") + + assert calls == [] From 8d7498a2023f1e51ae04e2d2fe9f2393267ca8fa Mon Sep 17 00:00:00 2001 From: Krishnakalani111 Date: Sat, 11 Jul 2026 19:08:47 +0530 Subject: [PATCH 3/6] feat: add 'list' operation, route both ops through get_tool() List supports optional filters/fields/limit/offset/order_by. Both _get and _list now build their schema through the existing get_tool() pipeline so hand-written and auto-generated tools follow one code path. --- frappe_mcp/server/tools/doctype.py | 65 ++++++++++++++---- frappe_mcp/server/tools/test_doctype.py | 89 ++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/frappe_mcp/server/tools/doctype.py b/frappe_mcp/server/tools/doctype.py index 44c2c52..7bf3bac 100644 --- a/frappe_mcp/server/tools/doctype.py +++ b/frappe_mcp/server/tools/doctype.py @@ -9,7 +9,7 @@ from typing import Any -from frappe_mcp.server.tools import Tool +from frappe_mcp.server.tools import Tool, ToolOptions, get_tool # Not derived from `frappe.model.numeric_fieldtypes` etc. — Frappe's groupings # classify by DB storage, not JSON Schema shape: Check would fall in as int, @@ -103,27 +103,64 @@ def build_tool(doctype: str, operation: str, *, check_permissions: bool = True) match operation: case "get": return _get(doctype, check_permissions=check_permissions) + case "list": + return _list(doctype, check_permissions=check_permissions) case _: raise ValueError(f"Unsupported operation: {operation!r}") def _get(doctype: str, *, check_permissions: bool) -> Tool: - input_schema = { - "type": "object", - "properties": {"name": {"type": "string", "description": f"{doctype} name"}}, - "required": ["name"], - } - def handler(name: str) -> dict[str, Any]: if check_permissions: _require_permission(doctype, "read", doc=name) return _frappe().get_doc(doctype, name).as_dict() - return Tool( - name=f"get_{_snake(doctype)}", - description=f"Get a {doctype} by name.", - input_schema=input_schema, - output_schema=None, - annotations={"readOnlyHint": True}, - fn=handler, + handler.__name__ = f"get_{_snake(doctype)}" + handler.__doc__ = ( + f"Get a {doctype} by name.\n" + f"\n" + f"Args:\n" + f" name: {doctype} name." + ) + return get_tool(handler, ToolOptions(annotations={"readOnlyHint": True})) + + +_DEFAULT_LIST_LIMIT = 20 + + +def _list(doctype: str, *, check_permissions: bool) -> Tool: + def handler( + filters: dict | None = None, + fields: list[str] | None = None, + limit: int = _DEFAULT_LIST_LIMIT, + offset: int = 0, + order_by: str | None = None, + ) -> list[dict[str, Any]]: + if check_permissions: + _require_permission(doctype, "read") + return _frappe().db.get_list( + doctype, + filters=filters or {}, + fields=fields or ["name"], + limit=limit, + start=offset, + order_by=order_by, + ) + + handler.__name__ = f"list_{_snake(doctype)}" + handler.__doc__ = ( + f"List {doctype} records with optional filters, fields, limit, and ordering.\n" + f"\n" + f"Args:\n" + f" filters: Frappe filters, e.g. {{'status': 'Open'}}.\n" + f" fields: Fieldnames to return; defaults to ['name'].\n" + f" limit: Max rows to return (default {_DEFAULT_LIST_LIMIT}).\n" + f" offset: Row offset for pagination.\n" + f" order_by: e.g. 'modified desc'." ) + + tool = get_tool(handler, ToolOptions(annotations={"readOnlyHint": True})) + # Semantic constraints the type system can't express. + tool["input_schema"]["properties"]["limit"]["minimum"] = 1 + tool["input_schema"]["properties"]["offset"]["minimum"] = 0 + return tool diff --git a/frappe_mcp/server/tools/test_doctype.py b/frappe_mcp/server/tools/test_doctype.py index a552ccc..503a342 100644 --- a/frappe_mcp/server/tools/test_doctype.py +++ b/frappe_mcp/server/tools/test_doctype.py @@ -17,12 +17,20 @@ from frappe_mcp.server.tools.doctype import build_tool, frappe_fieldtype_to_json_schema -def _fake_frappe(*, permission: bool = True, doc: dict | None = None): +def _fake_frappe(*, permission: bool = True, doc: dict | None = None, list_result=None, list_calls=None): """Minimal frappe stand-in for handler tests.""" doc = doc if doc is not None else {"name": "x", "doctype": "ToDo"} + list_result = list_result if list_result is not None else [] + + def get_list(dt, **kwargs): + if list_calls is not None: + list_calls.append({"doctype": dt, **kwargs}) + return list_result + return SimpleNamespace( has_permission=lambda *a, **k: permission, get_doc=lambda dt, name: SimpleNamespace(as_dict=lambda: {**doc, "name": name, "doctype": dt}), + db=SimpleNamespace(get_list=get_list), ) @@ -195,3 +203,82 @@ def has_permission(*a, **k): build_tool("ToDo", "get", check_permissions=False)["fn"](name="task-1") assert calls == [] + + +class TestListOperation: + @pytest.mark.parametrize( + "doctype, expected_name", + [("ToDo", "list_todo"), ("Sales Invoice", "list_sales_invoice")], + ) + def test_tool_name_is_snake_cased(self, doctype, expected_name): + assert build_tool(doctype, "list")["name"] == expected_name + + def test_input_schema_has_no_required_args(self): + schema = build_tool("ToDo", "list")["input_schema"] + assert schema["type"] == "object" + assert "required" not in schema # everything is optional + assert set(schema["properties"]) == {"filters", "fields", "limit", "offset", "order_by"} + + def test_marked_read_only(self): + assert build_tool("ToDo", "list")["annotations"]["readOnlyHint"] is True + + def test_handler_defaults_are_applied(self, monkeypatch): + calls = [] + monkeypatch.setattr( + doctype_module, "_frappe", lambda: _fake_frappe(list_calls=calls, list_result=[]) + ) + + build_tool("ToDo", "list")["fn"]() + + assert calls == [{ + "doctype": "ToDo", + "filters": {}, + "fields": ["name"], + "limit": 20, + "start": 0, + "order_by": None, + }] + + def test_handler_passes_arguments_through(self, monkeypatch): + calls = [] + monkeypatch.setattr( + doctype_module, "_frappe", lambda: _fake_frappe(list_calls=calls, list_result=[]) + ) + + build_tool("ToDo", "list")["fn"]( + filters={"status": "Open"}, + fields=["name", "description"], + limit=5, + offset=10, + order_by="modified desc", + ) + + assert calls[0]["filters"] == {"status": "Open"} + assert calls[0]["fields"] == ["name", "description"] + assert calls[0]["limit"] == 5 + assert calls[0]["start"] == 10 # note: Frappe's API param is `start`, not `offset` + assert calls[0]["order_by"] == "modified desc" + + def test_handler_returns_frappe_list_verbatim(self, monkeypatch): + rows = [{"name": "task-1"}, {"name": "task-2"}] + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(list_result=rows)) + + assert build_tool("ToDo", "list")["fn"]() == rows + + def test_handler_raises_when_permission_denied(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(permission=False)) + with pytest.raises(PermissionError): + build_tool("ToDo", "list")["fn"]() + + def test_check_permissions_false_skips_the_check(self, monkeypatch): + # Confirms we don't hit has_permission before the DB call at all. + def has_permission(*a, **k): + raise AssertionError("permission should not have been checked") + + fake = SimpleNamespace( + has_permission=has_permission, + db=SimpleNamespace(get_list=lambda dt, **k: []), + ) + monkeypatch.setattr(doctype_module, "_frappe", lambda: fake) + + build_tool("ToDo", "list", check_permissions=False)["fn"]() From 540d0d4e3b9d130438b4e373d09e03a98611ad01 Mon Sep 17 00:00:00 2001 From: Krishnakalani111 Date: Sat, 11 Jul 2026 19:13:30 +0530 Subject: [PATCH 4/6] feat: add 'create' and 'update' operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both derive their input schema from frappe.get_meta(doctype).fields — skipping NO_VALUE_FIELDS and Frappe's DEFAULT_FIELDS. Create marks reqd=1 fields as required; update requires only name. --- frappe_mcp/server/tools/doctype.py | 82 ++++++++++++++ frappe_mcp/server/tools/test_doctype.py | 144 +++++++++++++++++++++++- 2 files changed, 224 insertions(+), 2 deletions(-) diff --git a/frappe_mcp/server/tools/doctype.py b/frappe_mcp/server/tools/doctype.py index 7bf3bac..d4d2172 100644 --- a/frappe_mcp/server/tools/doctype.py +++ b/frappe_mcp/server/tools/doctype.py @@ -105,6 +105,10 @@ def build_tool(doctype: str, operation: str, *, check_permissions: bool = True) return _get(doctype, check_permissions=check_permissions) case "list": return _list(doctype, check_permissions=check_permissions) + case "create": + return _create(doctype, check_permissions=check_permissions) + case "update": + return _update(doctype, check_permissions=check_permissions) case _: raise ValueError(f"Unsupported operation: {operation!r}") @@ -164,3 +168,81 @@ def handler( tool["input_schema"]["properties"]["limit"]["minimum"] = 1 tool["input_schema"]["properties"]["offset"]["minimum"] = 0 return tool + + +def _writable_field_schemas(doctype: str) -> tuple[dict[str, dict], list[str]]: + """Build (properties, required) for a `doctype` at build time. + + Skips Frappe's NO_VALUE_FIELDS (Section Break, HTML, Table, ...) and + DEFAULT_FIELDS (owner, creation, modified, ...) that the framework + manages itself. Fields with `reqd=1` end up in the required list. + """ + frappe = _frappe() + properties: dict[str, dict] = {} + required: list[str] = [] + for field in frappe.get_meta(doctype).fields: + fieldtype = field.get("fieldtype") + fieldname = field.get("fieldname") + if not fieldname or fieldtype in frappe.model.NO_VALUE_FIELDS: + continue + if fieldname in frappe.model.DEFAULT_FIELDS: + continue + properties[fieldname] = frappe_fieldtype_to_json_schema(field) + if field.get("reqd"): + required.append(fieldname) + return properties, required + + +def _create(doctype: str, *, check_permissions: bool) -> Tool: + properties, required = _writable_field_schemas(doctype) + input_schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + input_schema["required"] = required + + def handler(**values: Any) -> dict[str, Any]: + if check_permissions: + _require_permission(doctype, "create") + doc = _frappe().get_doc({"doctype": doctype, **values}) + doc.insert() + return doc.as_dict() + + return Tool( + name=f"create_{_snake(doctype)}", + description=f"Create a new {doctype}.", + input_schema=input_schema, + output_schema=None, + annotations={"readOnlyHint": False}, + fn=handler, + ) + + +def _update(doctype: str, *, check_permissions: bool) -> Tool: + properties, _ = _writable_field_schemas(doctype) + properties = { + "name": {"type": "string", "description": f"{doctype} name"}, + **properties, + } + input_schema = { + "type": "object", + "properties": properties, + "required": ["name"], + } + + def handler(name: str, **values: Any) -> dict[str, Any]: + if check_permissions: + _require_permission(doctype, "write", doc=name) + frappe = _frappe() + doc = frappe.get_doc(doctype, name) + for k, v in values.items(): + doc.set(k, v) + doc.save() + return doc.as_dict() + + return Tool( + name=f"update_{_snake(doctype)}", + description=f"Update fields on an existing {doctype}. Only pass fields you want to change.", + input_schema=input_schema, + output_schema=None, + annotations={"readOnlyHint": False}, + fn=handler, + ) diff --git a/frappe_mcp/server/tools/test_doctype.py b/frappe_mcp/server/tools/test_doctype.py index 503a342..c81668c 100644 --- a/frappe_mcp/server/tools/test_doctype.py +++ b/frappe_mcp/server/tools/test_doctype.py @@ -17,20 +17,68 @@ from frappe_mcp.server.tools.doctype import build_tool, frappe_fieldtype_to_json_schema -def _fake_frappe(*, permission: bool = True, doc: dict | None = None, list_result=None, list_calls=None): +_DEFAULT_FIELDS = frozenset({"owner", "creation", "modified", "modified_by", "docstatus", "idx"}) +_NO_VALUE_FIELDS = frozenset({"Section Break", "Column Break", "Tab Break", "HTML", "Table", "Button"}) + + +def _fake_frappe( + *, + permission: bool = True, + doc: dict | None = None, + list_result=None, + list_calls=None, + meta_fields: list[dict] | None = None, + inserted: list[dict] | None = None, + saved: list[dict] | None = None, + existing: dict | None = None, +): """Minimal frappe stand-in for handler tests.""" doc = doc if doc is not None else {"name": "x", "doctype": "ToDo"} list_result = list_result if list_result is not None else [] + meta_fields = meta_fields if meta_fields is not None else [] def get_list(dt, **kwargs): if list_calls is not None: list_calls.append({"doctype": dt, **kwargs}) return list_result + class _NewDoc: + def __init__(self, data): + self._data = dict(data) + + def insert(self): + if inserted is not None: + inserted.append(dict(self._data)) + + def set(self, k, v): + self._data[k] = v + + def save(self): + if saved is not None: + saved.append(dict(self._data)) + + def as_dict(self): + return dict(self._data) + + def get_doc(*args): + # frappe.get_doc({...}) — used by _create + if len(args) == 1 and isinstance(args[0], dict): + return _NewDoc(args[0]) + # frappe.get_doc(doctype, name) — used by _get / _update + dt, name = args + base = dict(existing or doc) + base.update({"doctype": dt, "name": name}) + return _NewDoc(base) + return SimpleNamespace( has_permission=lambda *a, **k: permission, - get_doc=lambda dt, name: SimpleNamespace(as_dict=lambda: {**doc, "name": name, "doctype": dt}), + get_doc=get_doc, + get_meta=lambda dt: SimpleNamespace(fields=list(meta_fields)), db=SimpleNamespace(get_list=get_list), + model=SimpleNamespace( + NO_VALUE_FIELDS=_NO_VALUE_FIELDS, + DEFAULT_FIELDS=_DEFAULT_FIELDS, + ), ) @@ -282,3 +330,95 @@ def has_permission(*a, **k): monkeypatch.setattr(doctype_module, "_frappe", lambda: fake) build_tool("ToDo", "list", check_permissions=False)["fn"]() + + +_SAMPLE_FIELDS = [ + {"fieldname": "description", "fieldtype": "Data", "label": "Description", "reqd": 1}, + {"fieldname": "status", "fieldtype": "Select", "options": "Open\nClosed", "label": "Status"}, + {"fieldname": "priority", "fieldtype": "Int", "label": "Priority"}, + {"fieldname": "sb", "fieldtype": "Section Break"}, # NO_VALUE — skip + {"fieldname": "owner", "fieldtype": "Link", "options": "User"}, # DEFAULT — skip +] + + +class TestCreateOperation: + def test_tool_name_is_snake_cased(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + assert build_tool("ToDo", "create")["name"] == "create_todo" + + def test_input_schema_derives_from_meta(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + schema = build_tool("ToDo", "create")["input_schema"] + + assert set(schema["properties"]) == {"description", "status", "priority"} + assert schema["properties"]["description"]["type"] == "string" + assert schema["properties"]["status"]["enum"] == ["Open", "Closed"] + assert schema["properties"]["priority"]["type"] == "integer" + assert schema["required"] == ["description"] + + def test_omits_required_when_empty(self, monkeypatch): + no_required = [{"fieldname": "note", "fieldtype": "Data"}] + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=no_required)) + assert "required" not in build_tool("ToDo", "create")["input_schema"] + + def test_marked_as_not_read_only(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=[])) + assert build_tool("ToDo", "create")["annotations"]["readOnlyHint"] is False + + def test_handler_inserts_new_doc(self, monkeypatch): + inserted: list[dict] = [] + monkeypatch.setattr( + doctype_module, "_frappe", + lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS, inserted=inserted), + ) + + result = build_tool("ToDo", "create")["fn"](description="call dentist", status="Open") + + assert inserted == [{"doctype": "ToDo", "description": "call dentist", "status": "Open"}] + assert result["description"] == "call dentist" + + def test_handler_raises_when_permission_denied(self, monkeypatch): + monkeypatch.setattr( + doctype_module, "_frappe", + lambda: _fake_frappe(permission=False, meta_fields=_SAMPLE_FIELDS), + ) + with pytest.raises(PermissionError): + build_tool("ToDo", "create")["fn"](description="test") + + +class TestUpdateOperation: + def test_tool_name_is_snake_cased(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + assert build_tool("ToDo", "update")["name"] == "update_todo" + + def test_only_name_is_required(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + schema = build_tool("ToDo", "update")["input_schema"] + + assert schema["required"] == ["name"] + assert schema["properties"]["name"]["type"] == "string" + # Every writable field is still in properties, just not required. + assert {"name", "description", "status", "priority"} <= set(schema["properties"]) + + def test_handler_saves_updated_fields(self, monkeypatch): + saved: list[dict] = [] + monkeypatch.setattr( + doctype_module, "_frappe", + lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS, saved=saved, + existing={"description": "old"}), + ) + + result = build_tool("ToDo", "update")["fn"](name="task-1", description="new") + + # doc mutated in place, then save() captured it + assert saved[0]["name"] == "task-1" + assert saved[0]["description"] == "new" + assert result["description"] == "new" + + def test_handler_raises_when_permission_denied(self, monkeypatch): + monkeypatch.setattr( + doctype_module, "_frappe", + lambda: _fake_frappe(permission=False, meta_fields=_SAMPLE_FIELDS), + ) + with pytest.raises(PermissionError): + build_tool("ToDo", "update")["fn"](name="task-1", description="x") From 4a05f6d60aa6925eff154fd3cf3327bcc4cf31a2 Mon Sep 17 00:00:00 2001 From: Krishnakalani111 Date: Sat, 11 Jul 2026 22:28:41 +0530 Subject: [PATCH 5/6] feat: add expose_doctype() and opt-in 'delete' op Public one-liner API: mcp.expose_doctype("ToDo") # -> get_todo, list_todo, create_todo, update_todo Delete is a valid op but excluded from the default operations tuple; callers opt in explicitly and the tool carries destructiveHint=True so MCP clients treat it accordingly. --- frappe_mcp/server/server.py | 28 ++++++++ frappe_mcp/server/tools/doctype.py | 64 ++++++++++++++++-- frappe_mcp/server/tools/test_doctype.py | 89 ++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 5 deletions(-) diff --git a/frappe_mcp/server/server.py b/frappe_mcp/server/server.py index aad2603..9c22827 100644 --- a/frappe_mcp/server/server.py +++ b/frappe_mcp/server/server.py @@ -264,6 +264,34 @@ def add_prompt(self, prompt: prompts.Prompt): """ self._prompt_registry[prompt['name']] = prompt + def expose_doctype( + self, + doctype: str, + *, + operations=None, + check_permissions: bool = True, + ) -> None: + """Register CRUD-ish MCP tools for a Frappe DocType. + + Example: + mcp = frappe_mcp.MCP("my-server") + mcp.expose_doctype("ToDo") + # -> registers get_todo, list_todo, create_todo, update_todo + + mcp.expose_doctype("ToDo", operations=("get", "list")) # read-only + mcp.expose_doctype("ToDo", operations=("get", "list", "create", + "update", "delete")) + """ + from frappe_mcp.server.tools.doctype import DEFAULT_OPERATIONS + from frappe_mcp.server.tools.doctype import expose_doctype as _expose + + _expose( + self, + doctype, + operations=operations if operations is not None else DEFAULT_OPERATIONS, + check_permissions=check_permissions, + ) + def _handle_request( self, request_id: types.RequestId, diff --git a/frappe_mcp/server/tools/doctype.py b/frappe_mcp/server/tools/doctype.py index d4d2172..10e4c51 100644 --- a/frappe_mcp/server/tools/doctype.py +++ b/frappe_mcp/server/tools/doctype.py @@ -1,16 +1,29 @@ -"""Translate Frappe DocType fields into JSON Schema fragments. +"""Auto-generate MCP tools from a Frappe DocType. + +Public surface: + expose_doctype(mcp, doctype, ...) — register CRUD-ish tools on `mcp` + build_tool(doctype, operation) — build one Tool for one op Frappe speaks fieldtypes ("Currency", "Link", "Check"); MCP clients speak -JSON Schema. Bridging that gap is what lets `expose_doctype()` (upcoming) -auto-generate LLM-callable tools from a DocType's metadata. +JSON Schema. This module bridges them so hand-written and auto-generated +tools follow the same code path. """ from __future__ import annotations -from typing import Any +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any from frappe_mcp.server.tools import Tool, ToolOptions, get_tool +if TYPE_CHECKING: + from frappe_mcp.server.server import MCP + + +# Operations exposed by expose_doctype() when the caller doesn't specify. +# Delete is deliberately excluded — opt in explicitly. +DEFAULT_OPERATIONS: tuple[str, ...] = ("get", "list", "create", "update") + # Not derived from `frappe.model.numeric_fieldtypes` etc. — Frappe's groupings # classify by DB storage, not JSON Schema shape: Check would fall in as int, # Rating wouldn't fall in at all. @@ -109,6 +122,8 @@ def build_tool(doctype: str, operation: str, *, check_permissions: bool = True) return _create(doctype, check_permissions=check_permissions) case "update": return _update(doctype, check_permissions=check_permissions) + case "delete": + return _delete(doctype, check_permissions=check_permissions) case _: raise ValueError(f"Unsupported operation: {operation!r}") @@ -246,3 +261,44 @@ def handler(name: str, **values: Any) -> dict[str, Any]: annotations={"readOnlyHint": False}, fn=handler, ) + + +def _delete(doctype: str, *, check_permissions: bool) -> Tool: + def handler(name: str) -> dict[str, Any]: + if check_permissions: + _require_permission(doctype, "delete", doc=name) + _frappe().delete_doc(doctype, name) + return {"deleted": True, "doctype": doctype, "name": name} + + handler.__name__ = f"delete_{_snake(doctype)}" + handler.__doc__ = ( + f"Delete a {doctype} by name. Irreversible.\n" + f"\n" + f"Args:\n" + f" name: {doctype} name." + ) + return get_tool( + handler, + ToolOptions(annotations={"readOnlyHint": False, "destructiveHint": True}), + ) + + +def expose_doctype( + mcp: MCP, + doctype: str, + *, + operations: Sequence[str] = DEFAULT_OPERATIONS, + check_permissions: bool = True, +) -> None: + """Register one MCP tool per `operations` entry for `doctype` on `mcp`. + + Example: + mcp = frappe_mcp.MCP("my-server") + expose_doctype(mcp, "ToDo") + # → registers get_todo, list_todo, create_todo, update_todo + + expose_doctype(mcp, "ToDo", operations=("get", "list")) # read-only + expose_doctype(mcp, "ToDo", operations=(*DEFAULT_OPERATIONS, "delete")) + """ + for op in operations: + mcp.add_tool(build_tool(doctype, op, check_permissions=check_permissions)) diff --git a/frappe_mcp/server/tools/test_doctype.py b/frappe_mcp/server/tools/test_doctype.py index c81668c..95d941b 100644 --- a/frappe_mcp/server/tools/test_doctype.py +++ b/frappe_mcp/server/tools/test_doctype.py @@ -14,7 +14,12 @@ import pytest from frappe_mcp.server.tools import doctype as doctype_module -from frappe_mcp.server.tools.doctype import build_tool, frappe_fieldtype_to_json_schema +from frappe_mcp.server.tools.doctype import ( + DEFAULT_OPERATIONS, + build_tool, + expose_doctype, + frappe_fieldtype_to_json_schema, +) _DEFAULT_FIELDS = frozenset({"owner", "creation", "modified", "modified_by", "docstatus", "idx"}) @@ -31,6 +36,7 @@ def _fake_frappe( inserted: list[dict] | None = None, saved: list[dict] | None = None, existing: dict | None = None, + deleted: list[tuple[str, str]] | None = None, ): """Minimal frappe stand-in for handler tests.""" doc = doc if doc is not None else {"name": "x", "doctype": "ToDo"} @@ -70,10 +76,15 @@ def get_doc(*args): base.update({"doctype": dt, "name": name}) return _NewDoc(base) + def delete_doc(dt, name): + if deleted is not None: + deleted.append((dt, name)) + return SimpleNamespace( has_permission=lambda *a, **k: permission, get_doc=get_doc, get_meta=lambda dt: SimpleNamespace(fields=list(meta_fields)), + delete_doc=delete_doc, db=SimpleNamespace(get_list=get_list), model=SimpleNamespace( NO_VALUE_FIELDS=_NO_VALUE_FIELDS, @@ -422,3 +433,79 @@ def test_handler_raises_when_permission_denied(self, monkeypatch): ) with pytest.raises(PermissionError): build_tool("ToDo", "update")["fn"](name="task-1", description="x") + + +class TestDeleteOperation: + def test_tool_name_is_snake_cased(self): + assert build_tool("ToDo", "delete")["name"] == "delete_todo" + + def test_marked_destructive_and_not_read_only(self): + annotations = build_tool("ToDo", "delete")["annotations"] + assert annotations["destructiveHint"] is True + assert annotations["readOnlyHint"] is False + + def test_input_schema_requires_name(self): + schema = build_tool("ToDo", "delete")["input_schema"] + assert schema["required"] == ["name"] + assert schema["properties"]["name"]["type"] == "string" + + def test_handler_calls_frappe_delete_doc(self, monkeypatch): + deleted: list[tuple[str, str]] = [] + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(deleted=deleted)) + + result = build_tool("ToDo", "delete")["fn"](name="task-1") + + assert deleted == [("ToDo", "task-1")] + assert result == {"deleted": True, "doctype": "ToDo", "name": "task-1"} + + def test_handler_raises_when_permission_denied(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(permission=False)) + with pytest.raises(PermissionError): + build_tool("ToDo", "delete")["fn"](name="task-1") + + +class TestExposeDoctype: + def _fake_mcp(self): + added: list[dict] = [] + return SimpleNamespace(add_tool=added.append), added + + def test_defaults_exclude_delete(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + mcp, added = self._fake_mcp() + + expose_doctype(mcp, "ToDo") + + names = [t["name"] for t in added] + assert names == ["get_todo", "list_todo", "create_todo", "update_todo"] + assert "delete_todo" not in names + + def test_delete_is_opt_in(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + mcp, added = self._fake_mcp() + + expose_doctype(mcp, "ToDo", operations=(*DEFAULT_OPERATIONS, "delete")) + + assert [t["name"] for t in added][-1] == "delete_todo" + + def test_read_only_scope(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + mcp, added = self._fake_mcp() + + expose_doctype(mcp, "ToDo", operations=("get", "list")) + + assert [t["name"] for t in added] == ["get_todo", "list_todo"] + + def test_check_permissions_propagates(self, monkeypatch): + monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS)) + mcp, added = self._fake_mcp() + + expose_doctype(mcp, "ToDo", operations=("get",), check_permissions=False) + + # Handler shouldn't call has_permission when check_permissions=False: + def blow_up(*a, **k): + raise AssertionError("permission check should have been skipped") + + fake = _fake_frappe(meta_fields=_SAMPLE_FIELDS) + fake.has_permission = blow_up + monkeypatch.setattr(doctype_module, "_frappe", lambda: fake) + added[0]["fn"](name="task-1") From adef4a931d29950cdfb2692e5c9556e59e26f74c Mon Sep 17 00:00:00 2001 From: Krishnakalani111 Date: Sat, 11 Jul 2026 22:58:06 +0530 Subject: [PATCH 6/6] refactor: defer create/update schema derivation to first read --- frappe_mcp/server/tools/doctype.py | 84 ++++++++++++++++++++----- frappe_mcp/server/tools/test_doctype.py | 36 +++++++++++ 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/frappe_mcp/server/tools/doctype.py b/frappe_mcp/server/tools/doctype.py index 10e4c51..0f0be9f 100644 --- a/frappe_mcp/server/tools/doctype.py +++ b/frappe_mcp/server/tools/doctype.py @@ -185,6 +185,59 @@ def handler( return tool +class _LazyDict(dict): + """A dict that runs its factory on first read. + + Used to defer `frappe.get_meta` from build_tool() time to the first + time an MCP client actually asks for the schema — so tool + registration stays Frappe-free and consistent with get/list/delete. + Result is cached on the instance after the first resolve. + """ + + def __init__(self, factory): + super().__init__() + self._factory = factory + + def _resolve(self): + f = self._factory + if f is None: + return + self._factory = None + dict.update(self, f()) + + def __getitem__(self, key): + self._resolve() + return dict.__getitem__(self, key) + + def __contains__(self, key): + self._resolve() + return dict.__contains__(self, key) + + def __iter__(self): + self._resolve() + return dict.__iter__(self) + + def __len__(self): + self._resolve() + return dict.__len__(self) + + def get(self, key, default=None): + self._resolve() + return dict.get(self, key, default) + + def keys(self): + self._resolve() + return dict.keys(self) + + def values(self): + self._resolve() + return dict.values(self) + + def items(self): + self._resolve() + return dict.items(self) + + def _writable_field_schemas(doctype: str) -> tuple[dict[str, dict], list[str]]: """Build (properties, required) for a `doctype` at build time. @@ -209,10 +262,14 @@ def _writable_field_schemas(doctype: str) -> tuple[dict[str, dict], list[str]]: def _create(doctype: str, *, check_permissions: bool) -> Tool: - properties, required = _writable_field_schemas(doctype) - input_schema: dict[str, Any] = {"type": "object", "properties": properties} - if required: - input_schema["required"] = required + def _build_schema() -> dict[str, Any]: + properties, required = _writable_field_schemas(doctype) + schema: dict[str, Any] = {"type": "object", "properties": properties} + if required: + schema["required"] = required + return schema + + input_schema = _LazyDict(_build_schema) def handler(**values: Any) -> dict[str, Any]: if check_permissions: @@ -232,16 +289,15 @@ def handler(**values: Any) -> dict[str, Any]: def _update(doctype: str, *, check_permissions: bool) -> Tool: - properties, _ = _writable_field_schemas(doctype) - properties = { - "name": {"type": "string", "description": f"{doctype} name"}, - **properties, - } - input_schema = { - "type": "object", - "properties": properties, - "required": ["name"], - } + def _build_schema() -> dict[str, Any]: + properties, _ = _writable_field_schemas(doctype) + properties = { + "name": {"type": "string", "description": f"{doctype} name"}, + **properties, + } + return {"type": "object", "properties": properties, "required": ["name"]} + + input_schema = _LazyDict(_build_schema) def handler(name: str, **values: Any) -> dict[str, Any]: if check_permissions: diff --git a/frappe_mcp/server/tools/test_doctype.py b/frappe_mcp/server/tools/test_doctype.py index 95d941b..22f9f6d 100644 --- a/frappe_mcp/server/tools/test_doctype.py +++ b/frappe_mcp/server/tools/test_doctype.py @@ -352,6 +352,42 @@ def has_permission(*a, **k): ] +class TestLazySchemaDerivation: + """build_tool() must not touch Frappe — schema is deferred to first read.""" + + @pytest.mark.parametrize("operation", ["create", "update"]) + def test_build_does_not_call_frappe(self, monkeypatch, operation): + def blow_up(): + raise AssertionError(f"_frappe() called at build_tool time for {operation}") + + monkeypatch.setattr(doctype_module, "_frappe", blow_up) + + # Building the tool must succeed even though Frappe would raise. + tool = build_tool("ToDo", operation) + assert tool["name"].endswith(f"_todo") + + @pytest.mark.parametrize("operation", ["create", "update"]) + def test_schema_resolves_on_first_read(self, monkeypatch, operation): + calls = [] + + def counting_frappe(): + calls.append(1) + return _fake_frappe(meta_fields=_SAMPLE_FIELDS) + + monkeypatch.setattr(doctype_module, "_frappe", counting_frappe) + schema = build_tool("ToDo", operation)["input_schema"] + + # First access triggers exactly one _frappe() call. + _ = schema["properties"] + assert calls == [1] + + # Repeated access uses cached data. + _ = schema["properties"] + _ = schema.get("required") + list(schema) + assert calls == [1] + + class TestCreateOperation: def test_tool_name_is_snake_cased(self, monkeypatch): monkeypatch.setattr(doctype_module, "_frappe", lambda: _fake_frappe(meta_fields=_SAMPLE_FIELDS))