Skip to content

Commit 15703c9

Browse files
kkorotkovclaude
andauthored
Fix Pricing Table / custom properties handling (#7)
* Support custom properties for PricingTable and other LM-specific parameters Enable passing LM-specific parameters like 'skudef' and 'skus' through a general custom_properties mechanism. This allows: - Creating ProductModules with PricingTable model and SKU definitions via skudef - Creating/updating LicenseTemplates with pricing plan configurations via skus - Passing any other custom properties as key-value pairs Updated tools: - netlicensing_create_product_module: added custom_properties parameter - netlicensing_update_product_module: added custom_properties parameter - netlicensing_create_license_template: added custom_properties parameter - netlicensing_update_license_template: added custom_properties parameter Updated helper functions: - product_modules.create_product_module() - product_modules.update_product_module() - license_templates.create_license_template() - license_templates.update_license_template() Fixes: NetLicensing-MCP refusing to create PricingTable entities due to missing support for LM-specific parameters. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * Clarify include_raw docstrings to refer to the API response * Ignore local claude config symlink (useful for quick dev testing) * Add uv.lock TODO: Review and update README.md and pyproject.toml to ensure the project is fully managed with uv and follows current uv project conventions. * Enable tests in VSCode * Extend test coverage * Address potential custom properties conflicts * Address misleading wording in docstrings for include_raw * Improve type-correctness --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent b8fd0ea commit 15703c9

8 files changed

Lines changed: 1682 additions & 51 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,6 @@ __marimo__/
213213
# graphify knowledge-graph output (local exploration aid, regenerable)
214214
graphify-out/cache
215215
graphify-out/cost.json
216+
217+
# symlink to claude-desktop config file (local, regenerable)
218+
claude_desktop_config.json

.vscode/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"python.testing.pytestArgs": [
3+
"tests"
4+
],
5+
"python.testing.unittestEnabled": false,
6+
"python.testing.pytestEnabled": true
7+
}

src/netlicensing_mcp/server.py

Lines changed: 65 additions & 50 deletions
Large diffs are not rendered by default.

src/netlicensing_mcp/tools/helpers.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,49 @@
1-
"""Shared helpers for MCP tool output post-processing."""
1+
"""Shared helpers for MCP tool input/output post-processing."""
22

33
from __future__ import annotations
44

55
from typing import Any
66

7+
from netlicensing_mcp.client import NetLicensingError
8+
79
# Fields that carry large binary/base64 blobs and should be stripped from all
810
# tool outputs to keep MCP responses concise. They are intentionally NOT sent
911
# in create/update requests either — omitting them preserves the existing value
1012
# on the NetLicensing server side.
1113
STRIP_OUTPUT_FIELDS: frozenset[str] = frozenset({"logo"})
1214

15+
# JSON scalar types MCP clients may send for a custom property value.
16+
CustomPropertyValue = str | int | float | bool
17+
18+
19+
def _coerce_form_value(value: CustomPropertyValue) -> str:
20+
"""Stringify *value* for a form-encoded request, matching the convention
21+
used elsewhere in the tools layer (lowercase ``"true"``/``"false"``)."""
22+
if isinstance(value, bool):
23+
return str(value).lower()
24+
return str(value)
25+
26+
27+
def merge_custom_properties(
28+
data: dict[str, str], custom_properties: dict[str, CustomPropertyValue] | None
29+
) -> None:
30+
"""Merge *custom_properties* into the request payload *data* in place.
31+
32+
Raises ``NetLicensingError`` instead of silently overwriting a reserved
33+
field already populated from an explicit named argument (e.g.
34+
``productNumber``, ``licensingModel``). Values are coerced to ``str``
35+
since requests are form-encoded.
36+
"""
37+
if not custom_properties:
38+
return
39+
conflicts = sorted(set(custom_properties) & set(data))
40+
if conflicts:
41+
raise NetLicensingError(
42+
400,
43+
f"custom_properties conflicts with reserved field(s): {', '.join(conflicts)}",
44+
)
45+
data.update({k: _coerce_form_value(v) for k, v in custom_properties.items()})
46+
1347

1448
def strip_output_fields(data: Any, fields: frozenset[str] = STRIP_OUTPUT_FIELDS) -> Any:
1549
"""Recursively remove large/binary fields from a NetLicensing API response.

src/netlicensing_mcp/tools/license_templates.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from netlicensing_mcp.client import nl_delete, nl_get, nl_post
6+
from netlicensing_mcp.tools.helpers import CustomPropertyValue, merge_custom_properties
67

78

89
async def list_license_templates(
@@ -56,6 +57,7 @@ async def create_license_template(
5657
max_sessions: int | None = None,
5758
quantity: int | None = None,
5859
grace_period: bool | None = None,
60+
custom_properties: dict[str, CustomPropertyValue] | None = None,
5961
) -> dict:
6062
"""
6163
Create a license template.
@@ -68,6 +70,8 @@ async def create_license_template(
6870
max_sessions: concurrent sessions allowed (FLOATING).
6971
quantity: usage quota (QUANTITY / PayPerUse).
7072
grace_period: allow grace period after expiry (Subscription).
73+
74+
custom_properties: Optional dict of additional properties (e.g., skus for PricingTable, description).
7175
"""
7276
data: dict[str, str] = {
7377
"productModuleNumber": module_number,
@@ -92,6 +96,7 @@ async def create_license_template(
9296
data["quantity"] = str(quantity)
9397
if grace_period is not None:
9498
data["gracePeriod"] = str(grace_period).lower()
99+
merge_custom_properties(data, custom_properties)
95100
return await nl_post("/licensetemplate", data)
96101

97102

@@ -109,6 +114,7 @@ async def update_license_template(
109114
max_sessions: int | None = None,
110115
quantity: int | None = None,
111116
grace_period: bool | None = None,
117+
custom_properties: dict[str, CustomPropertyValue] | None = None,
112118
) -> dict:
113119
"""Update a license template.
114120
@@ -118,6 +124,8 @@ async def update_license_template(
118124
max_sessions: concurrent sessions allowed (FLOATING).
119125
quantity: usage quota (QUANTITY / PayPerUse).
120126
grace_period: allow grace period after expiry (Subscription).
127+
128+
custom_properties: Optional dict of additional properties to set or update.
121129
"""
122130
data: dict[str, str] = {}
123131
if name is not None:
@@ -144,6 +152,7 @@ async def update_license_template(
144152
data["quantity"] = str(quantity)
145153
if grace_period is not None:
146154
data["gracePeriod"] = str(grace_period).lower()
155+
merge_custom_properties(data, custom_properties)
147156
return await nl_post(f"/licensetemplate/{template_number}", data)
148157

149158

src/netlicensing_mcp/tools/product_modules.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from netlicensing_mcp.client import nl_delete, nl_get, nl_post
6+
from netlicensing_mcp.tools.helpers import CustomPropertyValue, merge_custom_properties
67

78

89
async def list_product_modules(
@@ -50,6 +51,7 @@ async def create_product_module(
5051
yellow_threshold: int | None = None,
5152
red_threshold: int | None = None,
5253
node_secret_mode: str | None = None,
54+
custom_properties: dict[str, CustomPropertyValue] | None = None,
5355
) -> dict:
5456
"""
5557
Create a product module with the specified licensing model.
@@ -63,6 +65,8 @@ async def create_product_module(
6365
yellow_threshold: Remaining time volume for yellow level (Rental).
6466
red_threshold: Remaining time volume for red level (Rental).
6567
node_secret_mode: PREDEFINED | CLIENT (NodeLocked).
68+
69+
custom_properties: Optional dict of additional properties (e.g., skudef for PricingTable).
6670
"""
6771
data: dict[str, str] = {
6872
"productNumber": product_number,
@@ -79,6 +83,7 @@ async def create_product_module(
7983
data["redThreshold"] = str(red_threshold)
8084
if node_secret_mode:
8185
data["nodeSecretMode"] = node_secret_mode
86+
merge_custom_properties(data, custom_properties)
8287
return await nl_post("/productmodule", data)
8388

8489

@@ -90,6 +95,7 @@ async def update_product_module(
9095
yellow_threshold: int | None = None,
9196
red_threshold: int | None = None,
9297
node_secret_mode: str | None = None,
98+
custom_properties: dict[str, CustomPropertyValue] | None = None,
9399
) -> dict:
94100
"""Update a product module's properties.
95101
@@ -98,6 +104,8 @@ async def update_product_module(
98104
yellow_threshold: Remaining time volume for yellow level (Rental).
99105
red_threshold: Remaining time volume for red level (Rental).
100106
node_secret_mode: PREDEFINED | CLIENT (NodeLocked).
107+
108+
custom_properties: Optional dict of additional properties to set or update.
101109
"""
102110
data: dict[str, str] = {}
103111
if name is not None:
@@ -112,6 +120,7 @@ async def update_product_module(
112120
data["redThreshold"] = str(red_threshold)
113121
if node_secret_mode is not None:
114122
data["nodeSecretMode"] = node_secret_mode
123+
merge_custom_properties(data, custom_properties)
115124
return await nl_post(f"/productmodule/{module_number}", data)
116125

117126

tests/test_tools.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ async def test_create_product_module_all_fields(module_response):
494494
yellow_threshold=10,
495495
red_threshold=5,
496496
node_secret_mode="PREDEFINED",
497+
custom_properties={"skudef": "SKU-001"},
497498
)
498499
assert result["items"]["item"][0]["type"] == "ProductModule"
499500
call_data = mock_post.call_args[0][1]
@@ -504,6 +505,23 @@ async def test_create_product_module_all_fields(module_response):
504505
assert call_data["yellowThreshold"] == "10"
505506
assert call_data["redThreshold"] == "5"
506507
assert call_data["nodeSecretMode"] == "PREDEFINED"
508+
assert call_data["skudef"] == "SKU-001"
509+
510+
511+
@pytest.mark.asyncio
512+
async def test_create_product_module_custom_properties_conflict():
513+
from netlicensing_mcp.client import NetLicensingError
514+
from netlicensing_mcp.tools.product_modules import create_product_module
515+
516+
with pytest.raises(NetLicensingError) as exc_info:
517+
await create_product_module(
518+
"P001",
519+
"M01",
520+
"Subscription Module",
521+
"Subscription",
522+
custom_properties={"licensingModel": "Floating"},
523+
)
524+
assert "licensingModel" in str(exc_info.value)
507525

508526

509527
@pytest.mark.asyncio
@@ -532,6 +550,7 @@ async def test_update_product_module_all_fields(module_response):
532550
yellow_threshold=15,
533551
red_threshold=3,
534552
node_secret_mode="CLIENT",
553+
custom_properties={"skudef": "SKU-002"},
535554
)
536555
assert result["items"]["item"][0]["type"] == "ProductModule"
537556
call_data = mock_put.call_args[0][1]
@@ -541,6 +560,21 @@ async def test_update_product_module_all_fields(module_response):
541560
assert call_data["yellowThreshold"] == "15"
542561
assert call_data["redThreshold"] == "3"
543562
assert call_data["nodeSecretMode"] == "CLIENT"
563+
assert call_data["skudef"] == "SKU-002"
564+
565+
566+
@pytest.mark.asyncio
567+
async def test_update_product_module_custom_properties_conflict():
568+
from netlicensing_mcp.client import NetLicensingError
569+
from netlicensing_mcp.tools.product_modules import update_product_module
570+
571+
with pytest.raises(NetLicensingError) as exc_info:
572+
await update_product_module(
573+
"M01",
574+
name="Updated Module",
575+
custom_properties={"name": "Sneaky Override"},
576+
)
577+
assert "name" in str(exc_info.value)
544578

545579

546580
@pytest.mark.asyncio
@@ -641,6 +675,7 @@ async def test_create_license_template_all_fields(template_response):
641675
max_sessions=5,
642676
quantity=100,
643677
grace_period=True,
678+
custom_properties={"skus": "SKU-100"},
644679
)
645680
assert result["items"]["item"][0]["type"] == "LicenseTemplate"
646681
call_data = mock_post.call_args[0][1]
@@ -657,6 +692,23 @@ async def test_create_license_template_all_fields(template_response):
657692
assert call_data["maxSessions"] == "5"
658693
assert call_data["quantity"] == "100"
659694
assert call_data["gracePeriod"] == "true"
695+
assert call_data["skus"] == "SKU-100"
696+
697+
698+
@pytest.mark.asyncio
699+
async def test_create_license_template_custom_properties_conflict():
700+
from netlicensing_mcp.client import NetLicensingError
701+
from netlicensing_mcp.tools.license_templates import create_license_template
702+
703+
with pytest.raises(NetLicensingError) as exc_info:
704+
await create_license_template(
705+
"M01",
706+
"LT01",
707+
"Sub Template",
708+
"TIMEVOLUME",
709+
custom_properties={"licenseType": "QUANTITY"},
710+
)
711+
assert "licenseType" in str(exc_info.value)
660712

661713

662714
@pytest.mark.asyncio
@@ -691,6 +743,7 @@ async def test_update_license_template_all_fields(template_response):
691743
max_sessions=10,
692744
quantity=200,
693745
grace_period=False,
746+
custom_properties={"skus": "SKU-200"},
694747
)
695748
assert result["items"]["item"][0]["type"] == "LicenseTemplate"
696749
call_data = mock_put.call_args[0][1]
@@ -706,6 +759,21 @@ async def test_update_license_template_all_fields(template_response):
706759
assert call_data["maxSessions"] == "10"
707760
assert call_data["quantity"] == "200"
708761
assert call_data["gracePeriod"] == "false"
762+
assert call_data["skus"] == "SKU-200"
763+
764+
765+
@pytest.mark.asyncio
766+
async def test_update_license_template_custom_properties_conflict():
767+
from netlicensing_mcp.client import NetLicensingError
768+
from netlicensing_mcp.tools.license_templates import update_license_template
769+
770+
with pytest.raises(NetLicensingError) as exc_info:
771+
await update_license_template(
772+
"LT01",
773+
price=29.99,
774+
custom_properties={"price": "0.01"},
775+
)
776+
assert "price" in str(exc_info.value)
709777

710778

711779
@pytest.mark.asyncio
@@ -1365,6 +1433,38 @@ async def test_update_transaction_all_fields(transaction_response):
13651433
assert call_data["paymentMethod"] == "PM002"
13661434

13671435

1436+
# ── Helpers ───────────────────────────────────────────────────────────────────
1437+
1438+
1439+
def test_merge_custom_properties_coerces_values_to_str():
1440+
from netlicensing_mcp.tools.helpers import merge_custom_properties
1441+
1442+
data: dict[str, str] = {"number": "M01"}
1443+
merge_custom_properties(data, {"maxUsers": 5, "enabled": True})
1444+
assert data == {"number": "M01", "maxUsers": "5", "enabled": "true"}
1445+
1446+
1447+
def test_merge_custom_properties_noop_when_empty():
1448+
from netlicensing_mcp.tools.helpers import merge_custom_properties
1449+
1450+
data = {"number": "M01"}
1451+
merge_custom_properties(data, None)
1452+
merge_custom_properties(data, {})
1453+
assert data == {"number": "M01"}
1454+
1455+
1456+
def test_merge_custom_properties_rejects_conflict():
1457+
from netlicensing_mcp.client import NetLicensingError
1458+
from netlicensing_mcp.tools.helpers import merge_custom_properties
1459+
1460+
data = {"number": "M01", "active": "true"}
1461+
with pytest.raises(NetLicensingError) as exc_info:
1462+
merge_custom_properties(data, {"active": "false", "skudef": "SKU-1"})
1463+
assert "active" in str(exc_info.value)
1464+
# Reject before mutating, so unrelated keys aren't partially applied either.
1465+
assert data == {"number": "M01", "active": "true"}
1466+
1467+
13681468
# ── Payment Methods ───────────────────────────────────────────────────────────
13691469

13701470

0 commit comments

Comments
 (0)