From 90ff407567360198399d40b4c17ee783631da304 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 22 Jun 2026 14:49:01 +0000 Subject: [PATCH 1/3] feat: expose blueprint_id on devices (SPEC-004) --- specs/SPEC-004-blueprint-id.md | 67 +++++++++++++++++++ .../core/application_server.py | 2 + src/enapter_mcp_server/core/device_dto.py | 1 + src/enapter_mcp_server/domain/device.py | 1 + .../http/enapter_data_mapper.py | 1 + src/enapter_mcp_server/mcp/models/device.py | 2 + src/enapter_mcp_server/mcp/server.py | 1 + tests/integration/schemas/search_devices.json | 6 +- tests/unit/core/test_application_server.py | 23 +++++++ tests/unit/core/test_device_search_query.py | 13 ++++ tests/unit/http/test_enapter_data_mapper.py | 16 +++++ tests/unit/mcp/models/test_device.py | 4 ++ 12 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 specs/SPEC-004-blueprint-id.md diff --git a/specs/SPEC-004-blueprint-id.md b/specs/SPEC-004-blueprint-id.md new file mode 100644 index 0000000..454b0af --- /dev/null +++ b/specs/SPEC-004-blueprint-id.md @@ -0,0 +1,67 @@ +# SPEC-004: Blueprint ID on Devices + +## Context + +Multiple devices of the same model and vendor share an underlying blueprint, and +therefore have identical manifests. An agent reading blueprints to learn device +interfaces issues redundant `read_blueprint` calls for same-blueprint devices, +wasting network round-trips and context. + +The SDK exposes a stable `blueprint_id` on every device — the platform does not +allow creating a device without specifying a blueprint. Exposing this field on +the MCP `Device` model lets the agent dedupe: same `blueprint_id` ⇒ identical +manifest ⇒ read once, reuse for every matching device. + +`device.type` (LUA, VIRTUAL_UCM, GATEWAY, ...) is the transport category, not a +blueprint identifier. `blueprint_id` is a separate field that fills a gap the +existing model has no equivalent for. + +## Architectural Decisions + +1. **Add `blueprint_id: str` to `domain.Device` (required).** All devices have + a blueprint. Sourced directly from the SDK's `Device.blueprint_id`. + +2. **Flow the field end-to-end** through `core.DeviceDTO` and + `mcp.models.Device` so the value propagates from SDK → DTO → domain → MCP + response without loss. + +3. **Dedup is agent-side, guided by the `search_devices` docstring.** Paginating + a site in BASIC view is cheap; the agent sees `blueprint_id` on every device + and can identify which devices share a blueprint. The `search_devices` + docstring guides the agent: devices sharing the same `blueprint_id` have + identical manifests, so `read_blueprint` need only be called once per unique + `blueprint_id`. `read_blueprint`'s parameter list is unchanged. + +## Constraints + +- Do not change `read_blueprint`'s parameter list or behavior. +- Do not add a `blueprint_id` filter to `search_devices` or + `DeviceSearchQuery`. +- Do not change `device.type` semantics or values. +- Do not add profile-related fields or concepts (separate specs). + +## Acceptance Criteria + +1. `domain.Device` has `blueprint_id: str` (required, not optional). + +2. `core.DeviceDTO` has `blueprint_id: str` (required). + +3. `mcp.models.Device` exposes `blueprint_id: str` (required), populated by + `from_domain`. + +4. `EnapterDataMapper.to_device_dto` maps `blueprint_id` from the SDK `Device` + object's `blueprint_id`. + +5. The `search_devices` tool docstring includes guidance that devices sharing + the same `blueprint_id` have identical manifests, and that `read_blueprint` + need only be called once per unique `blueprint_id`. + +6. The `tests/integration/schemas/search_devices.json` snapshot is regenerated + and shows `blueprint_id` as a required string field on the Device item + schema. + +7. Unit tests cover: + - `to_device_dto` maps `blueprint_id` from the SDK object. + - `mcp.models.Device.from_domain` propagates `blueprint_id`. + +8. `make check` passes. diff --git a/src/enapter_mcp_server/core/application_server.py b/src/enapter_mcp_server/core/application_server.py index b633f43..cfeb05f 100644 --- a/src/enapter_mcp_server/core/application_server.py +++ b/src/enapter_mcp_server/core/application_server.py @@ -194,6 +194,7 @@ async def _search_devices_basic( devices.append( domain.Device( id=device_dto.id, + blueprint_id=device_dto.blueprint_id, name=device_dto.name, site_id=device_dto.site_id, type=device_dto.type, @@ -241,6 +242,7 @@ async def _search_devices_full( devices.append( domain.Device( id=device_dto.id, + blueprint_id=device_dto.blueprint_id, name=device_dto.name, site_id=device_dto.site_id, type=device_dto.type, diff --git a/src/enapter_mcp_server/core/device_dto.py b/src/enapter_mcp_server/core/device_dto.py index b348901..b7152dd 100644 --- a/src/enapter_mcp_server/core/device_dto.py +++ b/src/enapter_mcp_server/core/device_dto.py @@ -7,6 +7,7 @@ @dataclasses.dataclass(frozen=True, kw_only=True) class DeviceDTO: id: str + blueprint_id: str name: str site_id: str type: domain.DeviceType diff --git a/src/enapter_mcp_server/domain/device.py b/src/enapter_mcp_server/domain/device.py index c37cfc8..46b0072 100644 --- a/src/enapter_mcp_server/domain/device.py +++ b/src/enapter_mcp_server/domain/device.py @@ -10,6 +10,7 @@ @dataclasses.dataclass(frozen=True, kw_only=True) class Device: id: str + blueprint_id: str name: str site_id: str type: DeviceType diff --git a/src/enapter_mcp_server/http/enapter_data_mapper.py b/src/enapter_mcp_server/http/enapter_data_mapper.py index 1d9db4f..c7f0046 100644 --- a/src/enapter_mcp_server/http/enapter_data_mapper.py +++ b/src/enapter_mcp_server/http/enapter_data_mapper.py @@ -27,6 +27,7 @@ def to_device_dto(self, device: enapter.http.api.devices.Device) -> core.DeviceD return core.DeviceDTO( id=device.id, + blueprint_id=device.blueprint_id, name=device.name, site_id=device.site_id, type=domain.DeviceType(device.type.value.lower()), diff --git a/src/enapter_mcp_server/mcp/models/device.py b/src/enapter_mcp_server/mcp/models/device.py index e2c1a39..7635fa2 100644 --- a/src/enapter_mcp_server/mcp/models/device.py +++ b/src/enapter_mcp_server/mcp/models/device.py @@ -22,6 +22,7 @@ class Device(pydantic.BaseModel): """ id: str + blueprint_id: str name: str site_id: str type: DeviceType @@ -36,6 +37,7 @@ class Device(pydantic.BaseModel): def from_domain(cls, device: domain.Device) -> Self: return cls( id=device.id, + blueprint_id=device.blueprint_id, name=device.name, site_id=device.site_id, type=device.type.value, diff --git a/src/enapter_mcp_server/mcp/server.py b/src/enapter_mcp_server/mcp/server.py index 1f1bbb1..22f8ad0 100644 --- a/src/enapter_mcp_server/mcp/server.py +++ b/src/enapter_mcp_server/mcp/server.py @@ -248,6 +248,7 @@ async def search_devices( - Use `has_active_alerts=True` to quickly find devices that require attention. - The default `view="basic"` returns summary information. To retrieve the `active_alerts` list and device `properties`, use `view="full"` (requires specifying either `site_id` or `device_id`). - `name_regexp` accepts a Python-style regular expression. + - Devices sharing the same `blueprint_id` have identical manifests, so `read_blueprint` need only be called once per unique `blueprint_id`. Reuse the result for every device with a matching `blueprint_id` to avoid redundant calls. Related tools: - `read_blueprint`: Pass the device `id` to read its blueprint and discover its telemetry attributes, commands, alerts, and properties. diff --git a/tests/integration/schemas/search_devices.json b/tests/integration/schemas/search_devices.json index 81b4ee3..a5429b1 100644 --- a/tests/integration/schemas/search_devices.json +++ b/tests/integration/schemas/search_devices.json @@ -6,7 +6,7 @@ "readOnlyHint": true, "title": "Search Devices" }, - "description": "Search for energy system devices.\n\nThis tool is the primary entry point for discovering devices, checking their connectivity, and finding active alerts during diagnostic troubleshooting.\n\nTips:\n- Use `has_active_alerts=True` to quickly find devices that require attention.\n- The default `view=\"basic\"` returns summary information. To retrieve the `active_alerts` list and device `properties`, use `view=\"full\"` (requires specifying either `site_id` or `device_id`).\n- `name_regexp` accepts a Python-style regular expression.\n\nRelated tools:\n- `read_blueprint`: Pass the device `id` to read its blueprint and discover its telemetry attributes, commands, alerts, and properties.\n- `get_historical_telemetry`: Pass the device `id` to retrieve historical time-series data (e.g., hydrogen yield, energy storage).\n- `search_command_executions`: Pass the device `id` to audit actions recently taken on this device.", + "description": "Search for energy system devices.\n\nThis tool is the primary entry point for discovering devices, checking their connectivity, and finding active alerts during diagnostic troubleshooting.\n\nTips:\n- Use `has_active_alerts=True` to quickly find devices that require attention.\n- The default `view=\"basic\"` returns summary information. To retrieve the `active_alerts` list and device `properties`, use `view=\"full\"` (requires specifying either `site_id` or `device_id`).\n- `name_regexp` accepts a Python-style regular expression.\n- Devices sharing the same `blueprint_id` have identical manifests, so `read_blueprint` need only be called once per unique `blueprint_id`. Reuse the result for every device with a matching `blueprint_id` to avoid redundant calls.\n\nRelated tools:\n- `read_blueprint`: Pass the device `id` to read its blueprint and discover its telemetry attributes, commands, alerts, and properties.\n- `get_historical_telemetry`: Pass the device `id` to retrieve historical time-series data (e.g., hydrogen yield, energy storage).\n- `search_command_executions`: Pass the device `id` to audit actions recently taken on this device.", "execution": null, "icons": null, "inputSchema": { @@ -148,6 +148,9 @@ ], "type": "string" }, + "blueprint_id": { + "type": "string" + }, "blueprint_summary": { "description": "A summary of a blueprint's key attributes.\n\nA blueprint is a specification that defines the integration between Enapter\nand a device. It outlines the available telemetry attributes, commands,\nproperties, and alerts that the device supports. Every device has a\nblueprint assigned to it.", "properties": { @@ -241,6 +244,7 @@ }, "required": [ "id", + "blueprint_id", "name", "site_id", "type", diff --git a/tests/unit/core/test_application_server.py b/tests/unit/core/test_application_server.py index e1cee3d..68361ff 100644 --- a/tests/unit/core/test_application_server.py +++ b/tests/unit/core/test_application_server.py @@ -177,6 +177,7 @@ class TestApplicationServer: async def test_search_sites_filtering(self) -> None: devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="dev-1", name="Gateway 1", site_id="1", @@ -185,6 +186,7 @@ async def test_search_sites_filtering(self) -> None: connectivity=domain.ConnectivityStatus.ONLINE, ), core.DeviceDTO( + blueprint_id="bp-1", id="dev-2", name="Device 2", site_id="1", @@ -193,6 +195,7 @@ async def test_search_sites_filtering(self) -> None: connectivity=domain.ConnectivityStatus.OFFLINE, ), core.DeviceDTO( + blueprint_id="bp-1", id="dev-3", name="Gateway 2", site_id="2", @@ -201,6 +204,7 @@ async def test_search_sites_filtering(self) -> None: connectivity=domain.ConnectivityStatus.OFFLINE, ), core.DeviceDTO( + blueprint_id="bp-1", id="dev-4", name="Device 4", site_id="3", @@ -313,6 +317,7 @@ async def test_search_sites(self) -> None: ) devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="dev-1", name="Gateway", site_id="site-1", @@ -321,6 +326,7 @@ async def test_search_sites(self) -> None: connectivity=domain.ConnectivityStatus.ONLINE, ), core.DeviceDTO( + blueprint_id="bp-1", id="dev-2", name="Device 2", site_id="site-1", @@ -371,6 +377,7 @@ async def test_search_sites_no_gateway_skips_rule_engine(self) -> None: ) devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="dev-1", name="Device 1", site_id="site-1", @@ -406,6 +413,7 @@ async def test_search_sites_no_gateway_skips_rule_engine(self) -> None: async def test_search_rules(self) -> None: gateway = core.DeviceDTO( + blueprint_id="bp-1", id="gw-1", name="Gateway", site_id="site-1", @@ -464,6 +472,7 @@ async def test_search_rules(self) -> None: async def test_read_rule(self) -> None: gateway = core.DeviceDTO( + blueprint_id="bp-1", id="gw-1", name="Gateway", site_id="site-1", @@ -511,6 +520,7 @@ async def test_search_rules_gateway_absent(self) -> None: async def test_search_rules_gateway_offline(self) -> None: gateway = core.DeviceDTO( + blueprint_id="bp-1", id="gw-1", name="Gateway", site_id="site-1", @@ -583,6 +593,7 @@ async def test_search_devices(self) -> None: ) devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="1", name="Alpha", site_id="s1", @@ -593,6 +604,7 @@ async def test_search_devices(self) -> None: manifest=manifest, ), core.DeviceDTO( + blueprint_id="bp-1", id="2", name="Beta", site_id="s1", @@ -603,6 +615,7 @@ async def test_search_devices(self) -> None: manifest=manifest, ), core.DeviceDTO( + blueprint_id="bp-1", id="3", name="Gamma", site_id="s2", @@ -722,6 +735,7 @@ async def test_search_devices_full_view(self) -> None: ) devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="1", name="Alpha", site_id="s1", @@ -792,6 +806,7 @@ async def test_search_devices_full_view_with_missing_alerts(self) -> None: ) devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="1", name="Alpha", site_id="s1", @@ -862,6 +877,7 @@ async def test_search_devices_full_view_allows_device_id_without_site_id( ) devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="1", name="Alpha", site_id="s1", @@ -873,6 +889,7 @@ async def test_search_devices_full_view_allows_device_id_without_site_id( manifest=manifest, ), core.DeviceDTO( + blueprint_id="bp-1", id="2", name="Beta", site_id="s2", @@ -953,6 +970,7 @@ async def test_read_blueprint(self) -> None: }, ) device = core.DeviceDTO( + blueprint_id="bp-1", id="dev-1", name="Dev 1", site_id="s1", @@ -1025,6 +1043,7 @@ async def test_get_historical_telemetry(self) -> None: async def test_search_command_executions_by_state(self) -> None: devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="d1", name="D1", site_id="s1", @@ -1085,6 +1104,7 @@ async def test_search_command_executions_by_state(self) -> None: async def test_search_command_executions_basic(self) -> None: devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="d1", name="D1", site_id="s1", @@ -1092,6 +1112,7 @@ async def test_search_command_executions_basic(self) -> None: authorized_role=domain.AccessRole.OWNER, ), core.DeviceDTO( + blueprint_id="bp-1", id="d2", name="D2", site_id="s1", @@ -1099,6 +1120,7 @@ async def test_search_command_executions_basic(self) -> None: authorized_role=domain.AccessRole.OWNER, ), core.DeviceDTO( + blueprint_id="bp-1", id="d3", name="D3", site_id="s2", @@ -1181,6 +1203,7 @@ async def test_search_command_executions_basic(self) -> None: async def test_search_command_executions_full(self) -> None: devices = [ core.DeviceDTO( + blueprint_id="bp-1", id="d1", name="D1", site_id="s1", diff --git a/tests/unit/core/test_device_search_query.py b/tests/unit/core/test_device_search_query.py index 182202f..73e98c0 100644 --- a/tests/unit/core/test_device_search_query.py +++ b/tests/unit/core/test_device_search_query.py @@ -11,6 +11,7 @@ def test_matches_name(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="1", name="Alpha", site_id="s1", @@ -23,6 +24,7 @@ def test_matches_name(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="2", name="Beta", site_id="s1", @@ -38,6 +40,7 @@ def test_matches_type(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="1", name="A", site_id="s1", @@ -50,6 +53,7 @@ def test_matches_type(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="2", name="A", site_id="s1", @@ -65,6 +69,7 @@ def test_matches_site_id(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="1", name="A", site_id="s1", @@ -77,6 +82,7 @@ def test_matches_site_id(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="2", name="A", site_id="s2", @@ -92,6 +98,7 @@ def test_matches_device_id(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="1", name="A", site_id="s1", @@ -104,6 +111,7 @@ def test_matches_device_id(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="2", name="A", site_id="s1", @@ -121,6 +129,7 @@ def test_matches_connectivity_status(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="1", name="A", site_id="s1", @@ -134,6 +143,7 @@ def test_matches_connectivity_status(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="2", name="A", site_id="s1", @@ -152,6 +162,7 @@ def test_matches_all_with_none(self) -> None: assert ( query.matches( core.DeviceDTO( + blueprint_id="bp-1", id="1", name="A", site_id="s1", @@ -167,6 +178,7 @@ def test_matches_has_active_alerts(self) -> None: query_false = core.DeviceSearchQuery(has_active_alerts=False) device_with_alerts = core.DeviceDTO( + blueprint_id="bp-1", id="1", name="A", site_id="s1", @@ -175,6 +187,7 @@ def test_matches_has_active_alerts(self) -> None: active_alerts=["a1"], ) device_without_alerts = core.DeviceDTO( + blueprint_id="bp-1", id="2", name="B", site_id="s1", diff --git a/tests/unit/http/test_enapter_data_mapper.py b/tests/unit/http/test_enapter_data_mapper.py index 35772ff..9216f56 100644 --- a/tests/unit/http/test_enapter_data_mapper.py +++ b/tests/unit/http/test_enapter_data_mapper.py @@ -260,6 +260,22 @@ def test_to_device_dto_authorized_role(self) -> None: assert dto.authorized_role == domain.AccessRole.OWNER + def test_to_device_dto_blueprint_id(self) -> None: + device = enapter.http.api.devices.Device( + id="dev-3", + blueprint_id="bp-3", + name="Dev 3", + site_id="s3", + updated_at=datetime.datetime.now(), + slug="dev-3", + type=enapter.http.api.devices.DeviceType.NATIVE, + authorized_role=enapter.http.api.AccessRole.USER, + ) + + dto = http.EnapterDataMapper().to_device_dto(device) + + assert dto.blueprint_id == "bp-3" + def test_to_command_execution(self) -> None: created_at = datetime.datetime.now() execution = enapter.http.api.commands.Execution( diff --git a/tests/unit/mcp/models/test_device.py b/tests/unit/mcp/models/test_device.py index 8481acb..55f1a5b 100644 --- a/tests/unit/mcp/models/test_device.py +++ b/tests/unit/mcp/models/test_device.py @@ -8,6 +8,7 @@ def test_device_from_domain(self) -> None: """Test creating Device from domain object.""" domain_device = domain.Device( id="device-789", + blueprint_id="bp-789", name="Production Device", site_id="site-999", type=domain.DeviceType.NATIVE, @@ -27,6 +28,7 @@ def test_device_from_domain(self) -> None: device = mcp.models.Device.from_domain(domain_device) assert device.id == "device-789" + assert device.blueprint_id == "bp-789" assert device.name == "Production Device" assert device.site_id == "site-999" assert device.type == "native" @@ -37,6 +39,7 @@ def test_device_from_domain(self) -> None: def test_device_from_domain_with_details(self) -> None: domain_device = domain.Device( id="device-123", + blueprint_id="bp-123", name="Detailed Device", site_id="site-456", type=domain.DeviceType.GATEWAY, @@ -70,6 +73,7 @@ def test_device_from_domain_child_type(self) -> None: """Test creating Device from domain object with CHILD type.""" domain_device = domain.Device( id="device-child", + blueprint_id="bp-child", name="Child Device", site_id="site-999", type=domain.DeviceType.CHILD, From 18a05c868f08f844d3ba54f91f175f3251e6095a Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 22 Jun 2026 16:15:35 +0000 Subject: [PATCH 2/3] feat: expose profile mappings on declarations (SPEC-005) --- ...EC-005-profile-mappings-on-declarations.md | 90 +++++++++++++++++++ .../domain/command_declaration.py | 1 + .../domain/property_declaration.py | 1 + .../domain/telemetry_attribute_declaration.py | 1 + .../http/enapter_data_mapper.py | 3 + .../mcp/models/command_declaration.py | 2 + .../mcp/models/property_declaration.py | 2 + .../models/telemetry_attribute_declaration.py | 2 + tests/integration/schemas/read_blueprint.json | 42 +++++++++ tests/unit/http/test_enapter_data_mapper.py | 63 +++++++++++++ .../mcp/models/test_command_declaration.py | 29 ++++++ .../mcp/models/test_property_declaration.py | 33 +++++++ .../test_telemetry_attribute_declaration.py | 33 +++++++ 13 files changed, 302 insertions(+) create mode 100644 specs/SPEC-005-profile-mappings-on-declarations.md diff --git a/specs/SPEC-005-profile-mappings-on-declarations.md b/specs/SPEC-005-profile-mappings-on-declarations.md new file mode 100644 index 0000000..af95755 --- /dev/null +++ b/specs/SPEC-005-profile-mappings-on-declarations.md @@ -0,0 +1,90 @@ +# SPEC-005: Profile Mappings on Declarations + +## Context + +Enapter blueprints can alias local telemetry, property, or command names to +different profile-defined names via a per-declaration `implements` field. For +example, a blueprint may declare a telemetry attribute `irradiance` locally that +maps to `sensor.solar_irradiance.solar_irradiance` in the profile: + +```yaml +telemetry: + irradiance: + type: float + display_name: Solar Irradiance + unit: W/m2 + implements: sensor.solar_irradiance.solar_irradiance +``` + +Automation rules access telemetry, properties, and commands by their local +blueprint names. When an agent reads rule code alongside a device's blueprint, +it needs to see the per-declaration mapping to connect local names to profile +names — especially when the two differ. + +The current manifest mapper silently drops this field. This spec surfaces it as +optional metadata on existing declaration models. + +## Architectural Decisions + +1. **Add `implements: list[str] | None = None`** to + `TelemetryAttributeDeclaration`, `PropertyDeclaration`, and + `CommandDeclaration` — both domain dataclasses and MCP pydantic models. The + field is a list of dot-notation profile identifier strings (e.g., + `["energy.battery"]`, `["lib.energy.battery.soc"]`). Although the blueprint + YAML declares `implements` as a single string, the platform API serializes it + as a single-element list. The `implements` field name conveys the meaning; + no type alias is introduced (consistent with other list fields in the + codebase). The platform supports per-declaration `implements` on these three + declaration types. + +2. **Do not add `implements` to `AlertDeclaration` or + `CommandArgumentDeclaration`.** The platform does not support per-declaration + `implements` on alerts or command arguments. + +3. **Map the field from the manifest dict's per-declaration `implements` key.** + The API delivers the value as a list. When the key is absent, the field is + `None`. The field is purely additive metadata on existing declarations — no + new tool, no new section, no return-type change. + +## Constraints + +- Do not add `implements` to `AlertDeclaration` or `CommandArgumentDeclaration`. +- Do not add a top-level `implements` field to `DeviceManifest` (separate spec). +- Do not add a new `BlueprintSection` value (separate spec). +- Do not change `read_blueprint`'s return type or existing sections. +- Do not add `implements` to the `Device` model or `BlueprintSummary`. + +## Acceptance Criteria + +1. `domain.TelemetryAttributeDeclaration` has `implements: list[str] | None = + None`. + +2. `domain.PropertyDeclaration` has `implements: list[str] | None = None`. + +3. `domain.CommandDeclaration` has `implements: list[str] | None = None`. + +4. `domain.AlertDeclaration` does NOT have an `implements` field. + +5. `domain.CommandArgumentDeclaration` does NOT have an `implements` field. + +6. `EnapterDataMapper` maps per-declaration `implements` from the declaration + dict's `implements` key for telemetry attributes, properties, and commands. + The API delivers the value as a list. When the key is absent, the field is + `None`. + +7. `mcp.models.TelemetryAttributeDeclaration`, `mcp.models.PropertyDeclaration`, + and `mcp.models.CommandDeclaration` each have `implements: list[str] | None + = None`, populated by `from_domain`. + +8. The `tests/integration/schemas/read_blueprint.json` snapshot is regenerated + and shows `implements` as an optional array-of-strings property on the + `TelemetryAttributeDeclaration`, `PropertyDeclaration`, and + `CommandDeclaration` schemas. + +9. Unit tests cover: + - `to_telemetry_attribute_declaration` / `to_property_declaration` / + `to_command_declaration` map `implements` (present → list of strings; + absent → `None`). + - MCP declaration models' `from_domain` propagates `implements`. + +10. `make check` passes. diff --git a/src/enapter_mcp_server/domain/command_declaration.py b/src/enapter_mcp_server/domain/command_declaration.py index 299d232..c67f408 100644 --- a/src/enapter_mcp_server/domain/command_declaration.py +++ b/src/enapter_mcp_server/domain/command_declaration.py @@ -11,3 +11,4 @@ class CommandDeclaration: access_level: AccessRole description: str | None arguments: list[CommandArgumentDeclaration] + implements: list[str] | None = None diff --git a/src/enapter_mcp_server/domain/property_declaration.py b/src/enapter_mcp_server/domain/property_declaration.py index b5e24cd..ac32f5a 100644 --- a/src/enapter_mcp_server/domain/property_declaration.py +++ b/src/enapter_mcp_server/domain/property_declaration.py @@ -14,3 +14,4 @@ class PropertyDeclaration: description: str | None enum: list[Any] | None unit: str | None + implements: list[str] | None = None diff --git a/src/enapter_mcp_server/domain/telemetry_attribute_declaration.py b/src/enapter_mcp_server/domain/telemetry_attribute_declaration.py index 77b1cb5..e66d2b8 100644 --- a/src/enapter_mcp_server/domain/telemetry_attribute_declaration.py +++ b/src/enapter_mcp_server/domain/telemetry_attribute_declaration.py @@ -14,3 +14,4 @@ class TelemetryAttributeDeclaration: description: str | None enum: list[Any] | None unit: str | None + implements: list[str] | None = None diff --git a/src/enapter_mcp_server/http/enapter_data_mapper.py b/src/enapter_mcp_server/http/enapter_data_mapper.py index c7f0046..7edb1e4 100644 --- a/src/enapter_mcp_server/http/enapter_data_mapper.py +++ b/src/enapter_mcp_server/http/enapter_data_mapper.py @@ -78,6 +78,7 @@ def to_property_declaration( description=dto.get("description"), enum=dto.get("enum"), unit=dto.get("unit"), + implements=dto.get("implements"), ) def to_telemetry_attribute_declaration( @@ -93,6 +94,7 @@ def to_telemetry_attribute_declaration( description=dto.get("description"), enum=dto.get("enum"), unit=dto.get("unit"), + implements=dto.get("implements"), ) def to_alert_declaration( @@ -120,6 +122,7 @@ def to_command_declaration( self.to_command_argument_declaration(arg_name, arg_dto) for arg_name, arg_dto in (dto.get("arguments") or {}).items() ], + implements=dto.get("implements"), ) def to_command_argument_declaration( diff --git a/src/enapter_mcp_server/mcp/models/command_declaration.py b/src/enapter_mcp_server/mcp/models/command_declaration.py index 82f369a..d1273d8 100644 --- a/src/enapter_mcp_server/mcp/models/command_declaration.py +++ b/src/enapter_mcp_server/mcp/models/command_declaration.py @@ -21,6 +21,7 @@ class CommandDeclaration(pydantic.BaseModel): access_level: AccessRole description: str | None arguments: list[CommandArgumentDeclaration] + implements: list[str] | None = None @classmethod def from_domain(cls, declaration: domain.CommandDeclaration) -> Self: @@ -32,4 +33,5 @@ def from_domain(cls, declaration: domain.CommandDeclaration) -> Self: arguments=[ CommandArgumentDeclaration.from_domain(a) for a in declaration.arguments ], + implements=declaration.implements, ) diff --git a/src/enapter_mcp_server/mcp/models/property_declaration.py b/src/enapter_mcp_server/mcp/models/property_declaration.py index e8862b2..e5e974a 100644 --- a/src/enapter_mcp_server/mcp/models/property_declaration.py +++ b/src/enapter_mcp_server/mcp/models/property_declaration.py @@ -27,6 +27,7 @@ class PropertyDeclaration(pydantic.BaseModel): description: str | None enum: list[Any] | None unit: str | None + implements: list[str] | None = None @classmethod def from_domain(cls, declaration: domain.PropertyDeclaration) -> Self: @@ -38,4 +39,5 @@ def from_domain(cls, declaration: domain.PropertyDeclaration) -> Self: description=declaration.description, enum=declaration.enum, unit=declaration.unit, + implements=declaration.implements, ) diff --git a/src/enapter_mcp_server/mcp/models/telemetry_attribute_declaration.py b/src/enapter_mcp_server/mcp/models/telemetry_attribute_declaration.py index 06fc0c0..17faea5 100644 --- a/src/enapter_mcp_server/mcp/models/telemetry_attribute_declaration.py +++ b/src/enapter_mcp_server/mcp/models/telemetry_attribute_declaration.py @@ -27,6 +27,7 @@ class TelemetryAttributeDeclaration(pydantic.BaseModel): description: str | None enum: list[Any] | None unit: str | None + implements: list[str] | None = None @classmethod def from_domain(cls, declaration: domain.TelemetryAttributeDeclaration) -> Self: @@ -38,4 +39,5 @@ def from_domain(cls, declaration: domain.TelemetryAttributeDeclaration) -> Self: description=declaration.description, enum=declaration.enum, unit=declaration.unit, + implements=declaration.implements, ) diff --git a/tests/integration/schemas/read_blueprint.json b/tests/integration/schemas/read_blueprint.json index 3344fdf..e84710a 100644 --- a/tests/integration/schemas/read_blueprint.json +++ b/tests/integration/schemas/read_blueprint.json @@ -106,6 +106,20 @@ } ] }, + "implements": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null + }, "name": { "type": "string" }, @@ -183,6 +197,20 @@ } ] }, + "implements": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null + }, "name": { "type": "string" }, @@ -374,6 +402,20 @@ "display_name": { "type": "string" }, + "implements": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null + }, "name": { "type": "string" } diff --git a/tests/unit/http/test_enapter_data_mapper.py b/tests/unit/http/test_enapter_data_mapper.py index 9216f56..180cccd 100644 --- a/tests/unit/http/test_enapter_data_mapper.py +++ b/tests/unit/http/test_enapter_data_mapper.py @@ -151,6 +151,69 @@ def test_parse_device_manifest_access_level_null(self) -> None: # Commands default to USER assert manifest.commands["c1"].access_level == domain.AccessRole.USER + def test_parse_device_manifest_maps_implements(self) -> None: + """Per-declaration `implements` is mapped for telemetry, properties, commands.""" + manifest = http.EnapterDataMapper().to_device_manifest( + { + "properties": { + "p1": { + "display_name": "P1", + "type": "string", + "implements": ["energy.battery.soc"], + } + }, + "telemetry": { + "t1": { + "display_name": "T1", + "type": "float", + "implements": ["sensor.solar_irradiance.solar_irradiance"], + } + }, + "commands": { + "c1": { + "display_name": "C1", + "implements": ["lib.energy.battery.reboot"], + } + }, + } + ) + + assert manifest is not None + assert manifest.properties["p1"].implements == ["energy.battery.soc"] + assert manifest.telemetry["t1"].implements == [ + "sensor.solar_irradiance.solar_irradiance" + ] + assert manifest.commands["c1"].implements == ["lib.energy.battery.reboot"] + + def test_parse_device_manifest_implements_absent_is_none(self) -> None: + """When `implements` key is absent, the field is None.""" + manifest = http.EnapterDataMapper().to_device_manifest( + { + "properties": { + "p1": { + "display_name": "P1", + "type": "string", + } + }, + "telemetry": { + "t1": { + "display_name": "T1", + "type": "float", + } + }, + "commands": { + "c1": { + "display_name": "C1", + } + }, + } + ) + + assert manifest is not None + assert manifest.properties["p1"].implements is None + assert manifest.telemetry["t1"].implements is None + assert manifest.commands["c1"].implements is None + def test_to_latest_telemetry(self) -> None: timestamp = datetime.datetime.now() telemetry = http.EnapterDataMapper().to_latest_telemetry( diff --git a/tests/unit/mcp/models/test_command_declaration.py b/tests/unit/mcp/models/test_command_declaration.py index a1faf0f..4aa1278 100644 --- a/tests/unit/mcp/models/test_command_declaration.py +++ b/tests/unit/mcp/models/test_command_declaration.py @@ -69,3 +69,32 @@ def test_command_declaration_from_domain_minimal(self) -> None: assert cmd.access_level == "system" assert cmd.description is None assert cmd.arguments == [] + + def test_command_declaration_from_domain_implements(self) -> None: + """`implements` is propagated from the domain object.""" + declaration = domain.CommandDeclaration( + name="reboot", + display_name="Reboot", + access_level=domain.AccessRole.SYSTEM, + description=None, + arguments=[], + implements=["lib.energy.battery.reboot"], + ) + + cmd = mcp.models.CommandDeclaration.from_domain(declaration) + + assert cmd.implements == ["lib.energy.battery.reboot"] + + def test_command_declaration_from_domain_implements_none(self) -> None: + """`implements` defaults to None when not set on the domain object.""" + declaration = domain.CommandDeclaration( + name="reboot", + display_name="Reboot", + access_level=domain.AccessRole.SYSTEM, + description=None, + arguments=[], + ) + + cmd = mcp.models.CommandDeclaration.from_domain(declaration) + + assert cmd.implements is None diff --git a/tests/unit/mcp/models/test_property_declaration.py b/tests/unit/mcp/models/test_property_declaration.py index 3da8087..b468565 100644 --- a/tests/unit/mcp/models/test_property_declaration.py +++ b/tests/unit/mcp/models/test_property_declaration.py @@ -69,3 +69,36 @@ def test_property_declaration_from_domain_minimal(self) -> None: assert prop.description is None assert prop.enum is None assert prop.unit is None + + def test_property_declaration_from_domain_implements(self) -> None: + """`implements` is propagated from the domain object.""" + declaration = domain.PropertyDeclaration( + name="soc", + display_name="State of Charge", + data_type=domain.DataType.FLOAT, + access_level=domain.AccessRole.READONLY, + description=None, + enum=None, + unit="%", + implements=["energy.battery.soc"], + ) + + prop = mcp.models.PropertyDeclaration.from_domain(declaration) + + assert prop.implements == ["energy.battery.soc"] + + def test_property_declaration_from_domain_implements_none(self) -> None: + """`implements` defaults to None when not set on the domain object.""" + declaration = domain.PropertyDeclaration( + name="soc", + display_name="State of Charge", + data_type=domain.DataType.FLOAT, + access_level=domain.AccessRole.READONLY, + description=None, + enum=None, + unit=None, + ) + + prop = mcp.models.PropertyDeclaration.from_domain(declaration) + + assert prop.implements is None diff --git a/tests/unit/mcp/models/test_telemetry_attribute_declaration.py b/tests/unit/mcp/models/test_telemetry_attribute_declaration.py index 5fa9363..7580a46 100644 --- a/tests/unit/mcp/models/test_telemetry_attribute_declaration.py +++ b/tests/unit/mcp/models/test_telemetry_attribute_declaration.py @@ -66,3 +66,36 @@ def test_telemetry_attribute_declaration_from_domain_minimal(self) -> None: assert attr.access_level == "installer" assert attr.description is None assert attr.enum is None + + def test_telemetry_attribute_declaration_from_domain_implements(self) -> None: + """`implements` is propagated from the domain object.""" + declaration = domain.TelemetryAttributeDeclaration( + name="irradiance", + display_name="Solar Irradiance", + data_type=domain.DataType.FLOAT, + access_level=domain.AccessRole.READONLY, + description=None, + enum=None, + unit="W/m2", + implements=["sensor.solar_irradiance.solar_irradiance"], + ) + + attr = mcp.models.TelemetryAttributeDeclaration.from_domain(declaration) + + assert attr.implements == ["sensor.solar_irradiance.solar_irradiance"] + + def test_telemetry_attribute_declaration_from_domain_implements_none(self) -> None: + """`implements` defaults to None when not set on the domain object.""" + declaration = domain.TelemetryAttributeDeclaration( + name="irradiance", + display_name="Solar Irradiance", + data_type=domain.DataType.FLOAT, + access_level=domain.AccessRole.READONLY, + description=None, + enum=None, + unit=None, + ) + + attr = mcp.models.TelemetryAttributeDeclaration.from_domain(declaration) + + assert attr.implements is None From 230fb0e6d2a04566b316cec470b90a787b0eac74 Mon Sep 17 00:00:00 2001 From: Roman Novatorov Date: Mon, 22 Jun 2026 20:42:24 +0000 Subject: [PATCH 3/3] feat: expose device profile membership (SPEC-006) --- specs/SPEC-006-device-profile-membership.md | 101 +++++++++++ .../core/application_server.py | 15 +- .../domain/blueprint_section.py | 1 + .../domain/device_manifest.py | 1 + .../http/enapter_data_mapper.py | 1 + .../mcp/models/blueprint_section.py | 4 +- src/enapter_mcp_server/mcp/server.py | 16 +- tests/integration/schemas/read_blueprint.json | 8 +- tests/unit/core/test_application_server.py | 36 ++++ tests/unit/domain/test_blueprint_summary.py | 2 + tests/unit/http/test_enapter_data_mapper.py | 18 ++ tests/unit/mcp/test_server.py | 169 ++++++++++++++++++ 12 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 specs/SPEC-006-device-profile-membership.md create mode 100644 tests/unit/mcp/test_server.py diff --git a/specs/SPEC-006-device-profile-membership.md b/specs/SPEC-006-device-profile-membership.md new file mode 100644 index 0000000..c60eb02 --- /dev/null +++ b/specs/SPEC-006-device-profile-membership.md @@ -0,0 +1,101 @@ +# SPEC-006: Device Profile Membership + +## Context + +The Enapter rule engine exposes `device.implements()` as a first-class Lua API, +returning the list of profiles a device implements. Rule code branches on +profile membership: + +```lua +local implements, _ = device.implements() +if implements["energy.battery"] then + local soc = device.telemetry.now("battery_soc") + ... +end +``` + +An agent deciphering such a rule needs to resolve which profiles the target +device implements. Profile membership is a composite fact — a profile like +`energy.battery` requires multiple declarations (soc, electrical, status, etc.) +spread across the manifest. This membership is recorded only in the manifest's +top-level `implements` field; it cannot be reconstructed from per-declaration +mappings alone. + +Without exposing this list, the agent cannot answer "does this device implement +`energy.battery`?" from server data. The profiles repository at +`https://github.com/Enapter/profiles` documents what each profile means, but +the server must expose which profiles a device claims to implement. + +## Architectural Decisions + +1. **Add `implements: list[str]` to `domain.DeviceManifest`.** Each entry is a + dot-notation profile identifier string (e.g., `energy.battery`). Mapped from + the manifest dict's `implements` key, defaulting to `[]` when absent. + +2. **Add `BlueprintSection.IMPLEMENTS = "implements"`,** mirroring the YAML key + (consistent with `TELEMETRY`, `PROPERTIES`, `ALERTS`, `COMMANDS`). + +3. **`read_blueprint(section="implements")` returns `list[str]`** — the + device's implemented profile names from `manifest.implements`, filtered by + `name_regexp` (matched against the profile name string), paginated by + `offset`/`limit`. The return-type union of `read_blueprint` grows by a `str` + variant. The agent knows from its `section` input what item type to expect, + so the heterogeneous union is not ambiguous in practice. + +4. **Add the profiles-repo link to the `read_blueprint` docstring.** This is + where the agent encounters profile data — per-declaration `implements` + mappings on declarations (SPEC-005) and the full list via the `implements` + section. The link gives the external reference for resolving profile names + against the canonical definitions at + `https://github.com/Enapter/profiles`. + +5. **The section is additive.** Existing sections (telemetry, properties, + alerts, commands) and their return types are unchanged. An agent only calls + `section="implements"` when deciphering `device.implements()` in rule code. + +## Constraints + +- Do not add `implements` as a field on `domain.Device`, `mcp.models.Device`, + or `BlueprintSummary`. Profile membership is not part of search results — it + surfaces only when the agent explicitly reads the `implements` section. +- Do not add an `implements` filter to `search_devices`. Profile-name-based + device search is out of scope. +- Do not add a `search_profiles` tool or `Profile` domain/MCP model. +- Do not change existing `read_blueprint` sections or their return types. The + new `IMPLEMENTS` section is additive. +- Do not add PyYAML or any YAML-parsing dependency. + +## Acceptance Criteria + +1. `domain.DeviceManifest` has `implements: list[str]` (required). + +2. `EnapterDataMapper.to_device_manifest` maps `implements` from the manifest + dict's `implements` key, defaulting to `[]` when absent. + +3. `domain.BlueprintSection` has `IMPLEMENTS = "implements"`. + +4. `ApplicationServer.read_blueprint` with `section=IMPLEMENTS` returns + `list[str]` from `manifest.implements`, filtered by `name_regexp` + (matched against the profile name string) and paginated by `offset`/`limit`. + +5. The `read_blueprint` return-type union (both `core.ApplicationServer` and + `mcp.server.Server` layers) includes `str`. + +6. The `read_blueprint` tool docstring: + - Lists `"implements"` among the available sections. + - References `https://github.com/Enapter/profiles` and explains that it + documents the standardized profiles that blueprints implement. + +7. The `tests/integration/schemas/read_blueprint.json` snapshot is regenerated + and shows: + - `"implements"` in the `section` input enum. + - `string` as an `anyOf` variant in the output item schema. + +8. Unit tests cover: + - `to_device_manifest` maps `implements` (present → list; absent → `[]`). + - `ApplicationServer.read_blueprint` with `section=IMPLEMENTS` returns the + filtered, paginated profile name list (mocked manifest). + - The MCP-layer `read_blueprint` returns bare strings for the `IMPLEMENTS` + section and declarations for the other sections. + +9. `make check` passes. diff --git a/src/enapter_mcp_server/core/application_server.py b/src/enapter_mcp_server/core/application_server.py index cfeb05f..229299e 100644 --- a/src/enapter_mcp_server/core/application_server.py +++ b/src/enapter_mcp_server/core/application_server.py @@ -271,7 +271,8 @@ async def read_blueprint( offset: int, limit: int, ) -> list[ - domain.PropertyDeclaration + str + | domain.PropertyDeclaration | domain.TelemetryAttributeDeclaration | domain.AlertDeclaration | domain.CommandDeclaration @@ -283,13 +284,16 @@ async def read_blueprint( assert device_dto.manifest is not None entities: list[ - domain.PropertyDeclaration + str + | domain.PropertyDeclaration | domain.TelemetryAttributeDeclaration | domain.AlertDeclaration | domain.CommandDeclaration - ] + ] = [] match section: + case domain.BlueprintSection.IMPLEMENTS: + entities = list(device_dto.manifest.implements) case domain.BlueprintSection.PROPERTIES: entities = list(device_dto.manifest.properties.values()) case domain.BlueprintSection.TELEMETRY: @@ -301,8 +305,9 @@ async def read_blueprint( case _: raise NotImplementedError(section) - entities = [e for e in entities if name_pattern.search(e.name)] - entities.sort(key=lambda e: e.name) + key = lambda e: e if isinstance(e, str) else e.name + entities = [e for e in entities if name_pattern.search(key(e))] + entities.sort(key=key) return entities[offset : offset + limit] async def get_historical_telemetry( diff --git a/src/enapter_mcp_server/domain/blueprint_section.py b/src/enapter_mcp_server/domain/blueprint_section.py index 09f654a..b4c3878 100644 --- a/src/enapter_mcp_server/domain/blueprint_section.py +++ b/src/enapter_mcp_server/domain/blueprint_section.py @@ -6,3 +6,4 @@ class BlueprintSection(enum.Enum): PROPERTIES = "properties" ALERTS = "alerts" COMMANDS = "commands" + IMPLEMENTS = "implements" diff --git a/src/enapter_mcp_server/domain/device_manifest.py b/src/enapter_mcp_server/domain/device_manifest.py index 986e615..e3e587f 100644 --- a/src/enapter_mcp_server/domain/device_manifest.py +++ b/src/enapter_mcp_server/domain/device_manifest.py @@ -10,6 +10,7 @@ class DeviceManifest: description: str | None vendor: str | None + implements: list[str] properties: dict[str, PropertyDeclaration] telemetry: dict[str, TelemetryAttributeDeclaration] alerts: dict[str, AlertDeclaration] diff --git a/src/enapter_mcp_server/http/enapter_data_mapper.py b/src/enapter_mcp_server/http/enapter_data_mapper.py index 7edb1e4..b259a26 100644 --- a/src/enapter_mcp_server/http/enapter_data_mapper.py +++ b/src/enapter_mcp_server/http/enapter_data_mapper.py @@ -47,6 +47,7 @@ def to_device_manifest( return domain.DeviceManifest( description=manifest.get("description"), vendor=manifest.get("vendor"), + implements=list(manifest.get("implements") or []), properties={ name: self.to_property_declaration(name, dto) for name, dto in (manifest.get("properties") or {}).items() diff --git a/src/enapter_mcp_server/mcp/models/blueprint_section.py b/src/enapter_mcp_server/mcp/models/blueprint_section.py index c91b891..74c75d2 100644 --- a/src/enapter_mcp_server/mcp/models/blueprint_section.py +++ b/src/enapter_mcp_server/mcp/models/blueprint_section.py @@ -1,3 +1,5 @@ from typing import Literal -BlueprintSection = Literal["telemetry", "properties", "alerts", "commands"] +BlueprintSection = Literal[ + "telemetry", "properties", "alerts", "commands", "implements" +] diff --git a/src/enapter_mcp_server/mcp/server.py b/src/enapter_mcp_server/mcp/server.py index 22f8ad0..ff5c68c 100644 --- a/src/enapter_mcp_server/mcp/server.py +++ b/src/enapter_mcp_server/mcp/server.py @@ -286,24 +286,29 @@ async def read_blueprint( offset: int = 0, limit: int = 20, ) -> list[ - models.PropertyDeclaration + str + | models.PropertyDeclaration | models.TelemetryAttributeDeclaration | models.AlertDeclaration | models.CommandDeclaration ]: """Read the blueprint for a specific device. - This tool retrieves the schema defining the capabilities of a device, divided into sections: 'telemetry', 'alerts', 'commands', and 'properties'. + This tool retrieves the schema defining the capabilities of a device, divided into sections: 'telemetry', 'alerts', 'commands', 'properties', and 'implements'. Tips: - Use `section="alerts"` to get details about specific alerts. - Use `section="telemetry"` to discover the exact names and metadata of telemetry attributes. - Use `section="commands"` to see which actions can be executed on the device. + - Use `section="implements"` to list the standardized profiles that the device implements. - `name_regexp` accepts a Python-style regular expression. Related tools: - `get_historical_telemetry`: Pass the telemetry attributes discovered here to retrieve historical time-series data. - `search_command_executions`: Use the commands discovered here to audit their past executions. + + See also: + - https://github.com/Enapter/profiles — documentation on the standardized profiles that blueprints implement. """ auth = await self._get_auth_config() declarations = await self._app.read_blueprint( @@ -316,14 +321,17 @@ async def read_blueprint( ) models_list: list[ - models.PropertyDeclaration + str + | models.PropertyDeclaration | models.TelemetryAttributeDeclaration | models.AlertDeclaration | models.CommandDeclaration ] = [] for d in declarations: - if isinstance(d, domain.PropertyDeclaration): + if isinstance(d, str): + models_list.append(d) + elif isinstance(d, domain.PropertyDeclaration): models_list.append(models.PropertyDeclaration.from_domain(d)) elif isinstance(d, domain.TelemetryAttributeDeclaration): models_list.append(models.TelemetryAttributeDeclaration.from_domain(d)) diff --git a/tests/integration/schemas/read_blueprint.json b/tests/integration/schemas/read_blueprint.json index e84710a..42eecde 100644 --- a/tests/integration/schemas/read_blueprint.json +++ b/tests/integration/schemas/read_blueprint.json @@ -6,7 +6,7 @@ "readOnlyHint": true, "title": "Read Blueprint" }, - "description": "Read the blueprint for a specific device.\n\nThis tool retrieves the schema defining the capabilities of a device, divided into sections: 'telemetry', 'alerts', 'commands', and 'properties'.\n\nTips:\n- Use `section=\"alerts\"` to get details about specific alerts.\n- Use `section=\"telemetry\"` to discover the exact names and metadata of telemetry attributes.\n- Use `section=\"commands\"` to see which actions can be executed on the device.\n- `name_regexp` accepts a Python-style regular expression.\n\nRelated tools:\n- `get_historical_telemetry`: Pass the telemetry attributes discovered here to retrieve historical time-series data.\n- `search_command_executions`: Use the commands discovered here to audit their past executions.", + "description": "Read the blueprint for a specific device.\n\nThis tool retrieves the schema defining the capabilities of a device, divided into sections: 'telemetry', 'alerts', 'commands', 'properties', and 'implements'.\n\nTips:\n- Use `section=\"alerts\"` to get details about specific alerts.\n- Use `section=\"telemetry\"` to discover the exact names and metadata of telemetry attributes.\n- Use `section=\"commands\"` to see which actions can be executed on the device.\n- Use `section=\"implements\"` to list the standardized profiles that the device implements.\n- `name_regexp` accepts a Python-style regular expression.\n\nRelated tools:\n- `get_historical_telemetry`: Pass the telemetry attributes discovered here to retrieve historical time-series data.\n- `search_command_executions`: Use the commands discovered here to audit their past executions.\n\nSee also:\n- https://github.com/Enapter/profiles \u2014 documentation on the standardized profiles that blueprints implement.", "execution": null, "icons": null, "inputSchema": { @@ -32,7 +32,8 @@ "telemetry", "properties", "alerts", - "commands" + "commands", + "implements" ], "type": "string" } @@ -54,6 +55,9 @@ "result": { "items": { "anyOf": [ + { + "type": "string" + }, { "description": "Represents a property declaration.\n\nProperties are device metadata which do not change during normal device\noperation. Examples include \"firmware_version\", \"device_model\", and\n\"serial_number\".\n\nThe `access_level` field defines the minimum role required to read the\nproperty value. A user can read the property value only if their\n`authorized_role` for the device is at or after this `access_level`.", "properties": { diff --git a/tests/unit/core/test_application_server.py b/tests/unit/core/test_application_server.py index 68361ff..88dcbca 100644 --- a/tests/unit/core/test_application_server.py +++ b/tests/unit/core/test_application_server.py @@ -10,6 +10,7 @@ def make_device_manifest( *, description: str | None = None, vendor: str | None = None, + implements: list[str] | None = None, properties: dict[str, domain.PropertyDeclaration] | None = None, telemetry: dict[str, domain.TelemetryAttributeDeclaration] | None = None, alerts: dict[str, domain.AlertDeclaration] | None = None, @@ -18,6 +19,7 @@ def make_device_manifest( return domain.DeviceManifest( description=description, vendor=vendor, + implements=implements or [], properties=properties or {}, telemetry=telemetry or {}, alerts=alerts or {}, @@ -1019,6 +1021,40 @@ async def test_read_blueprint(self) -> None: assert commands[0].arguments[0].name == "a1" assert commands[0].arguments[0].data_type == domain.DataType.INTEGER + async def test_read_blueprint_implements(self) -> None: + manifest = make_device_manifest( + implements=["energy.battery", "energy.inverter"] + ) + device = core.DeviceDTO( + blueprint_id="bp-1", + id="dev-1", + name="Dev 1", + site_id="s1", + type=domain.DeviceType.NATIVE, + authorized_role=domain.AccessRole.OWNER, + manifest=manifest, + ) + api = MockEnapterAPI(devices=[device]) + app = core.ApplicationServer(api) + auth = core.AuthConfig(token="test") + + # Read implements + implements = await app.read_blueprint( + auth, "dev-1", domain.BlueprintSection.IMPLEMENTS, ".*", 0, 10 + ) + assert implements == ["energy.battery", "energy.inverter"] + + implements_filtered = await app.read_blueprint( + auth, "dev-1", domain.BlueprintSection.IMPLEMENTS, "battery", 0, 10 + ) + assert implements_filtered == ["energy.battery"] + + # Read implements with pagination + implements_paginated = await app.read_blueprint( + auth, "dev-1", domain.BlueprintSection.IMPLEMENTS, ".*", 1, 1 + ) + assert implements_paginated == ["energy.inverter"] + async def test_get_historical_telemetry(self) -> None: historical = domain.HistoricalTelemetry( timestamps=[datetime.datetime.now()], diff --git a/tests/unit/domain/test_blueprint_summary.py b/tests/unit/domain/test_blueprint_summary.py index f623bf9..2361cab 100644 --- a/tests/unit/domain/test_blueprint_summary.py +++ b/tests/unit/domain/test_blueprint_summary.py @@ -6,6 +6,7 @@ def test_from_device_manifest(self) -> None: manifest = domain.DeviceManifest( description="Electrolyzer device", vendor="Enapter", + implements=[], commands={ "c1": domain.CommandDeclaration( name="c1", @@ -98,6 +99,7 @@ def test_from_device_manifest_missing_sections(self) -> None: domain.DeviceManifest( description=None, vendor=None, + implements=[], commands={}, properties={}, telemetry={}, diff --git a/tests/unit/http/test_enapter_data_mapper.py b/tests/unit/http/test_enapter_data_mapper.py index 180cccd..17d4af9 100644 --- a/tests/unit/http/test_enapter_data_mapper.py +++ b/tests/unit/http/test_enapter_data_mapper.py @@ -58,17 +58,35 @@ def test_parse_device_manifest(self) -> None: assert manifest.commands["c1"].arguments[0].data_type == domain.DataType.INTEGER assert manifest.commands["c1"].access_level == domain.AccessRole.USER + def test_parse_device_manifest_implements_list(self) -> None: + manifest = http.EnapterDataMapper().to_device_manifest( + { + "description": "Electrolyzer device", + "vendor": "Enapter", + "implements": ["energy.battery", "energy.inverter"], + } + ) + + assert manifest is not None + assert manifest.implements == ["energy.battery", "energy.inverter"] + def test_parse_device_manifest_missing_sections(self) -> None: manifest = http.EnapterDataMapper().to_device_manifest({}) assert manifest is not None assert manifest.description is None assert manifest.vendor is None + assert manifest.implements == [] assert manifest.properties == {} assert manifest.telemetry == {} assert manifest.alerts == {} assert manifest.commands == {} + def test_parse_device_manifest_implements_null(self) -> None: + manifest = http.EnapterDataMapper().to_device_manifest({"implements": None}) + assert manifest is not None + assert manifest.implements == [] + def test_parse_device_manifest_raises_on_invalid_payload(self) -> None: try: http.EnapterDataMapper().to_device_manifest( diff --git a/tests/unit/mcp/test_server.py b/tests/unit/mcp/test_server.py new file mode 100644 index 0000000..91c45e5 --- /dev/null +++ b/tests/unit/mcp/test_server.py @@ -0,0 +1,169 @@ +import unittest.mock + +import pytest + +from enapter_mcp_server import core, domain, mcp + + +@pytest.mark.asyncio +class TestServer: + async def test_read_blueprint_implements_returns_strings(self) -> None: + app = unittest.mock.AsyncMock(spec=core.ApplicationServer) + app.read_blueprint.return_value = ["energy.battery", "energy.inverter"] + + config = mcp.ServerConfig(host="127.0.0.1", port=12345, enapter_http_api_url="") + server = mcp.Server(app=app, config=config) + # Mock auth config to avoid reading HTTP headers + server._get_auth_config = unittest.mock.AsyncMock( # type: ignore + return_value=core.AuthConfig(token="test") + ) + + result = await server.read_blueprint( + device_id="dev-1", + section="implements", + ) + + assert result == ["energy.battery", "energy.inverter"] + app.read_blueprint.assert_awaited_once_with( + auth=core.AuthConfig(token="test"), + device_id="dev-1", + section=domain.BlueprintSection.IMPLEMENTS, + name_regexp=".*", + offset=0, + limit=20, + ) + + async def test_read_blueprint_properties_returns_models(self) -> None: + app = unittest.mock.AsyncMock(spec=core.ApplicationServer) + app.read_blueprint.return_value = [ + domain.PropertyDeclaration( + name="p1", + display_name="P1", + data_type=domain.DataType.STRING, + access_level=domain.AccessRole.READONLY, + description=None, + enum=None, + unit=None, + ) + ] + + config = mcp.ServerConfig(host="127.0.0.1", port=12345, enapter_http_api_url="") + server = mcp.Server(app=app, config=config) + # Mock auth config to avoid reading HTTP headers + server._get_auth_config = unittest.mock.AsyncMock( # type: ignore + return_value=core.AuthConfig(token="test") + ) + + result = await server.read_blueprint( + device_id="dev-1", + section="properties", + ) + + assert len(result) == 1 + assert isinstance(result[0], mcp.models.PropertyDeclaration) + assert result[0].name == "p1" + + async def test_read_blueprint_telemetry_returns_models(self) -> None: + app = unittest.mock.AsyncMock(spec=core.ApplicationServer) + app.read_blueprint.return_value = [ + domain.TelemetryAttributeDeclaration( + name="t1", + display_name="T1", + data_type=domain.DataType.FLOAT, + access_level=domain.AccessRole.READONLY, + description=None, + enum=None, + unit="V", + ) + ] + + config = mcp.ServerConfig(host="127.0.0.1", port=12345, enapter_http_api_url="") + server = mcp.Server(app=app, config=config) + server._get_auth_config = unittest.mock.AsyncMock( # type: ignore + return_value=core.AuthConfig(token="test") + ) + + result = await server.read_blueprint( + device_id="dev-1", + section="telemetry", + ) + + assert len(result) == 1 + assert isinstance(result[0], mcp.models.TelemetryAttributeDeclaration) + assert result[0].name == "t1" + + async def test_read_blueprint_alerts_returns_models(self) -> None: + app = unittest.mock.AsyncMock(spec=core.ApplicationServer) + app.read_blueprint.return_value = [ + domain.AlertDeclaration( + name="a1", + display_name="A1", + severity=domain.AlertSeverity.WARNING, + description=None, + troubleshooting=None, + components=None, + conditions=None, + ) + ] + + config = mcp.ServerConfig(host="127.0.0.1", port=12345, enapter_http_api_url="") + server = mcp.Server(app=app, config=config) + server._get_auth_config = unittest.mock.AsyncMock( # type: ignore + return_value=core.AuthConfig(token="test") + ) + + result = await server.read_blueprint( + device_id="dev-1", + section="alerts", + ) + + assert len(result) == 1 + assert isinstance(result[0], mcp.models.AlertDeclaration) + assert result[0].name == "a1" + + async def test_read_blueprint_commands_returns_models(self) -> None: + app = unittest.mock.AsyncMock(spec=core.ApplicationServer) + app.read_blueprint.return_value = [ + domain.CommandDeclaration( + name="c1", + display_name="C1", + access_level=domain.AccessRole.USER, + description="D1", + arguments=[], + ) + ] + + config = mcp.ServerConfig(host="127.0.0.1", port=12345, enapter_http_api_url="") + server = mcp.Server(app=app, config=config) + server._get_auth_config = unittest.mock.AsyncMock( # type: ignore + return_value=core.AuthConfig(token="test") + ) + + result = await server.read_blueprint( + device_id="dev-1", + section="commands", + ) + + assert len(result) == 1 + assert isinstance(result[0], mcp.models.CommandDeclaration) + assert result[0].name == "c1" + + async def test_read_blueprint_not_implemented(self) -> None: + app = unittest.mock.AsyncMock(spec=core.ApplicationServer) + + class UnknownDeclaration: + pass + + app.read_blueprint.return_value = [UnknownDeclaration()] + + config = mcp.ServerConfig(host="127.0.0.1", port=12345, enapter_http_api_url="") + server = mcp.Server(app=app, config=config) + server._get_auth_config = unittest.mock.AsyncMock( # type: ignore + return_value=core.AuthConfig(token="test") + ) + + with pytest.raises(NotImplementedError): + await server.read_blueprint( + device_id="dev-1", + section="properties", + )