Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,6 @@ __marimo__/
# graphify knowledge-graph output (local exploration aid, regenerable)
graphify-out/cache
graphify-out/cost.json

# symlink to claude-desktop config file (local, regenerable)
claude_desktop_config.json
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
115 changes: 65 additions & 50 deletions src/netlicensing_mcp/server.py

Large diffs are not rendered by default.

36 changes: 35 additions & 1 deletion src/netlicensing_mcp/tools/helpers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
"""Shared helpers for MCP tool output post-processing."""
"""Shared helpers for MCP tool input/output post-processing."""

from __future__ import annotations

from typing import Any

from netlicensing_mcp.client import NetLicensingError

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

# JSON scalar types MCP clients may send for a custom property value.
CustomPropertyValue = str | int | float | bool


def _coerce_form_value(value: CustomPropertyValue) -> str:
"""Stringify *value* for a form-encoded request, matching the convention
used elsewhere in the tools layer (lowercase ``"true"``/``"false"``)."""
if isinstance(value, bool):
return str(value).lower()
return str(value)


def merge_custom_properties(
data: dict[str, str], custom_properties: dict[str, CustomPropertyValue] | None
) -> None:
"""Merge *custom_properties* into the request payload *data* in place.

Raises ``NetLicensingError`` instead of silently overwriting a reserved
field already populated from an explicit named argument (e.g.
``productNumber``, ``licensingModel``). Values are coerced to ``str``
since requests are form-encoded.
"""
if not custom_properties:
return
conflicts = sorted(set(custom_properties) & set(data))
if conflicts:
raise NetLicensingError(
400,
f"custom_properties conflicts with reserved field(s): {', '.join(conflicts)}",
)
data.update({k: _coerce_form_value(v) for k, v in custom_properties.items()})


def strip_output_fields(data: Any, fields: frozenset[str] = STRIP_OUTPUT_FIELDS) -> Any:
"""Recursively remove large/binary fields from a NetLicensing API response.
Expand Down
9 changes: 9 additions & 0 deletions src/netlicensing_mcp/tools/license_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from netlicensing_mcp.client import nl_delete, nl_get, nl_post
from netlicensing_mcp.tools.helpers import CustomPropertyValue, merge_custom_properties


async def list_license_templates(
Expand Down Expand Up @@ -56,6 +57,7 @@ async def create_license_template(
max_sessions: int | None = None,
quantity: int | None = None,
grace_period: bool | None = None,
custom_properties: dict[str, CustomPropertyValue] | None = None,
) -> dict:
"""
Create a license template.
Expand All @@ -68,6 +70,8 @@ async def create_license_template(
max_sessions: concurrent sessions allowed (FLOATING).
quantity: usage quota (QUANTITY / PayPerUse).
grace_period: allow grace period after expiry (Subscription).

custom_properties: Optional dict of additional properties (e.g., skus for PricingTable, description).
"""
data: dict[str, str] = {
"productModuleNumber": module_number,
Expand All @@ -92,6 +96,7 @@ async def create_license_template(
data["quantity"] = str(quantity)
if grace_period is not None:
data["gracePeriod"] = str(grace_period).lower()
merge_custom_properties(data, custom_properties)
return await nl_post("/licensetemplate", data)


Expand All @@ -109,6 +114,7 @@ async def update_license_template(
max_sessions: int | None = None,
quantity: int | None = None,
grace_period: bool | None = None,
custom_properties: dict[str, CustomPropertyValue] | None = None,
) -> dict:
"""Update a license template.

Expand All @@ -118,6 +124,8 @@ async def update_license_template(
max_sessions: concurrent sessions allowed (FLOATING).
quantity: usage quota (QUANTITY / PayPerUse).
grace_period: allow grace period after expiry (Subscription).

custom_properties: Optional dict of additional properties to set or update.
"""
data: dict[str, str] = {}
if name is not None:
Expand All @@ -144,6 +152,7 @@ async def update_license_template(
data["quantity"] = str(quantity)
if grace_period is not None:
data["gracePeriod"] = str(grace_period).lower()
merge_custom_properties(data, custom_properties)
return await nl_post(f"/licensetemplate/{template_number}", data)


Expand Down
9 changes: 9 additions & 0 deletions src/netlicensing_mcp/tools/product_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from netlicensing_mcp.client import nl_delete, nl_get, nl_post
from netlicensing_mcp.tools.helpers import CustomPropertyValue, merge_custom_properties
Comment on lines 5 to +6


async def list_product_modules(
Expand Down Expand Up @@ -50,6 +51,7 @@ async def create_product_module(
yellow_threshold: int | None = None,
red_threshold: int | None = None,
node_secret_mode: str | None = None,
custom_properties: dict[str, CustomPropertyValue] | None = None,
) -> dict:
"""
Create a product module with the specified licensing model.
Expand All @@ -63,6 +65,8 @@ async def create_product_module(
yellow_threshold: Remaining time volume for yellow level (Rental).
red_threshold: Remaining time volume for red level (Rental).
node_secret_mode: PREDEFINED | CLIENT (NodeLocked).

custom_properties: Optional dict of additional properties (e.g., skudef for PricingTable).
"""
data: dict[str, str] = {
"productNumber": product_number,
Expand All @@ -79,6 +83,7 @@ async def create_product_module(
data["redThreshold"] = str(red_threshold)
if node_secret_mode:
data["nodeSecretMode"] = node_secret_mode
merge_custom_properties(data, custom_properties)
return await nl_post("/productmodule", data)


Expand All @@ -90,6 +95,7 @@ async def update_product_module(
yellow_threshold: int | None = None,
red_threshold: int | None = None,
node_secret_mode: str | None = None,
custom_properties: dict[str, CustomPropertyValue] | None = None,
) -> dict:
"""Update a product module's properties.

Expand All @@ -98,6 +104,8 @@ async def update_product_module(
yellow_threshold: Remaining time volume for yellow level (Rental).
red_threshold: Remaining time volume for red level (Rental).
node_secret_mode: PREDEFINED | CLIENT (NodeLocked).

custom_properties: Optional dict of additional properties to set or update.
"""
data: dict[str, str] = {}
if name is not None:
Expand All @@ -112,6 +120,7 @@ async def update_product_module(
data["redThreshold"] = str(red_threshold)
if node_secret_mode is not None:
data["nodeSecretMode"] = node_secret_mode
merge_custom_properties(data, custom_properties)
return await nl_post(f"/productmodule/{module_number}", data)
Comment on lines 121 to 124


Expand Down
100 changes: 100 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ async def test_create_product_module_all_fields(module_response):
yellow_threshold=10,
red_threshold=5,
node_secret_mode="PREDEFINED",
custom_properties={"skudef": "SKU-001"},
)
assert result["items"]["item"][0]["type"] == "ProductModule"
call_data = mock_post.call_args[0][1]
Expand All @@ -504,6 +505,23 @@ async def test_create_product_module_all_fields(module_response):
assert call_data["yellowThreshold"] == "10"
assert call_data["redThreshold"] == "5"
assert call_data["nodeSecretMode"] == "PREDEFINED"
assert call_data["skudef"] == "SKU-001"


@pytest.mark.asyncio
async def test_create_product_module_custom_properties_conflict():
from netlicensing_mcp.client import NetLicensingError
from netlicensing_mcp.tools.product_modules import create_product_module

with pytest.raises(NetLicensingError) as exc_info:
await create_product_module(
"P001",
"M01",
"Subscription Module",
"Subscription",
custom_properties={"licensingModel": "Floating"},
)
assert "licensingModel" in str(exc_info.value)


@pytest.mark.asyncio
Expand Down Expand Up @@ -532,6 +550,7 @@ async def test_update_product_module_all_fields(module_response):
yellow_threshold=15,
red_threshold=3,
node_secret_mode="CLIENT",
custom_properties={"skudef": "SKU-002"},
)
assert result["items"]["item"][0]["type"] == "ProductModule"
call_data = mock_put.call_args[0][1]
Expand All @@ -541,6 +560,21 @@ async def test_update_product_module_all_fields(module_response):
assert call_data["yellowThreshold"] == "15"
assert call_data["redThreshold"] == "3"
assert call_data["nodeSecretMode"] == "CLIENT"
assert call_data["skudef"] == "SKU-002"


@pytest.mark.asyncio
async def test_update_product_module_custom_properties_conflict():
from netlicensing_mcp.client import NetLicensingError
from netlicensing_mcp.tools.product_modules import update_product_module

with pytest.raises(NetLicensingError) as exc_info:
await update_product_module(
"M01",
name="Updated Module",
custom_properties={"name": "Sneaky Override"},
)
assert "name" in str(exc_info.value)


@pytest.mark.asyncio
Expand Down Expand Up @@ -641,6 +675,7 @@ async def test_create_license_template_all_fields(template_response):
max_sessions=5,
quantity=100,
grace_period=True,
custom_properties={"skus": "SKU-100"},
)
assert result["items"]["item"][0]["type"] == "LicenseTemplate"
call_data = mock_post.call_args[0][1]
Expand All @@ -657,6 +692,23 @@ async def test_create_license_template_all_fields(template_response):
assert call_data["maxSessions"] == "5"
assert call_data["quantity"] == "100"
assert call_data["gracePeriod"] == "true"
assert call_data["skus"] == "SKU-100"


@pytest.mark.asyncio
async def test_create_license_template_custom_properties_conflict():
from netlicensing_mcp.client import NetLicensingError
from netlicensing_mcp.tools.license_templates import create_license_template

with pytest.raises(NetLicensingError) as exc_info:
await create_license_template(
"M01",
"LT01",
"Sub Template",
"TIMEVOLUME",
custom_properties={"licenseType": "QUANTITY"},
)
assert "licenseType" in str(exc_info.value)


@pytest.mark.asyncio
Expand Down Expand Up @@ -691,6 +743,7 @@ async def test_update_license_template_all_fields(template_response):
max_sessions=10,
quantity=200,
grace_period=False,
custom_properties={"skus": "SKU-200"},
)
assert result["items"]["item"][0]["type"] == "LicenseTemplate"
call_data = mock_put.call_args[0][1]
Expand All @@ -706,6 +759,21 @@ async def test_update_license_template_all_fields(template_response):
assert call_data["maxSessions"] == "10"
assert call_data["quantity"] == "200"
assert call_data["gracePeriod"] == "false"
assert call_data["skus"] == "SKU-200"


@pytest.mark.asyncio
async def test_update_license_template_custom_properties_conflict():
from netlicensing_mcp.client import NetLicensingError
from netlicensing_mcp.tools.license_templates import update_license_template

with pytest.raises(NetLicensingError) as exc_info:
await update_license_template(
"LT01",
price=29.99,
custom_properties={"price": "0.01"},
)
assert "price" in str(exc_info.value)
Comment on lines +765 to +776


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


# ── Helpers ───────────────────────────────────────────────────────────────────


def test_merge_custom_properties_coerces_values_to_str():
from netlicensing_mcp.tools.helpers import merge_custom_properties

data: dict[str, str] = {"number": "M01"}
merge_custom_properties(data, {"maxUsers": 5, "enabled": True})
assert data == {"number": "M01", "maxUsers": "5", "enabled": "true"}


def test_merge_custom_properties_noop_when_empty():
from netlicensing_mcp.tools.helpers import merge_custom_properties

data = {"number": "M01"}
merge_custom_properties(data, None)
merge_custom_properties(data, {})
assert data == {"number": "M01"}


def test_merge_custom_properties_rejects_conflict():
from netlicensing_mcp.client import NetLicensingError
from netlicensing_mcp.tools.helpers import merge_custom_properties

data = {"number": "M01", "active": "true"}
with pytest.raises(NetLicensingError) as exc_info:
merge_custom_properties(data, {"active": "false", "skudef": "SKU-1"})
assert "active" in str(exc_info.value)
# Reject before mutating, so unrelated keys aren't partially applied either.
assert data == {"number": "M01", "active": "true"}


# ── Payment Methods ───────────────────────────────────────────────────────────


Expand Down
Loading
Loading