rootly-mcp-server 2.2.22__tar.gz → 2.2.24__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/CHANGELOG.md +26 -0
  2. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/PKG-INFO +1 -1
  3. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/pyproject.toml +1 -1
  4. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/transport.py +92 -0
  5. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_server.py +13 -7
  6. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_transport_module.py +169 -0
  7. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/uv.lock +1 -1
  8. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.dockerignore +0 -0
  9. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.gitattributes +0 -0
  10. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.github/dependabot.yml +0 -0
  11. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.github/openapi-audit-remote-baseline.json +0 -0
  12. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.github/workflows/ci.yml +0 -0
  13. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.github/workflows/dependency-review.yml +0 -0
  14. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.github/workflows/openapi-audit.yml +0 -0
  15. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.github/workflows/pypi-release.yml +0 -0
  16. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.gitignore +0 -0
  17. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.semaphore/deploy-staging.yml +0 -0
  18. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.semaphore/deploy.yml +0 -0
  19. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.semaphore/semaphore.yml +0 -0
  20. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/.semaphore/update-task-definition.sh +0 -0
  21. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/CONTRIBUTING.md +0 -0
  22. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/Dockerfile +0 -0
  23. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/LICENSE +0 -0
  24. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/README.md +0 -0
  25. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/examples/skills/README.md +0 -0
  26. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/gemini-extension.json +0 -0
  27. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/rootly-mcp-server-demo.gif +0 -0
  28. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/rootly_openapi.json +0 -0
  29. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/scripts/audit_openapi.py +0 -0
  30. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/scripts/setup-hooks.sh +0 -0
  31. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/server.json +0 -0
  32. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/__init__.py +0 -0
  33. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/__main__.py +0 -0
  34. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/audit.py +0 -0
  35. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/client.py +0 -0
  36. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/code_mode.py +0 -0
  37. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/data/__init__.py +0 -0
  38. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/exceptions.py +0 -0
  39. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/legacy_server.py +0 -0
  40. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/mcp_error.py +0 -0
  41. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/monitoring.py +0 -0
  42. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/och_client.py +0 -0
  43. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/pagination.py +0 -0
  44. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/payload_stripping.py +0 -0
  45. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/security.py +0 -0
  46. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/server.py +0 -0
  47. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/server_defaults.py +0 -0
  48. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/smart_utils.py +0 -0
  49. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/spec_transform.py +0 -0
  50. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/tools/__init__.py +0 -0
  51. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/tools/alerts.py +0 -0
  52. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/tools/incidents.py +0 -0
  53. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/tools/oncall.py +0 -0
  54. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/tools/resources.py +0 -0
  55. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/utils.py +0 -0
  56. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/src/rootly_mcp_server/validators.py +0 -0
  57. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/README.md +0 -0
  58. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/conftest.py +0 -0
  59. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/integration/local/test_basic.py +0 -0
  60. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/integration/local/test_smart_tools.py +0 -0
  61. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/integration/local/test_tool_allowlists.py +0 -0
  62. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/integration/remote/test_essential.py +0 -0
  63. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/test_client.py +0 -0
  64. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_alert_stripping.py +0 -0
  65. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_authentication.py +0 -0
  66. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_code_mode_module.py +0 -0
  67. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_exceptions.py +0 -0
  68. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_http_headers.py +0 -0
  69. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_legacy_server_module.py +0 -0
  70. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_main_transport.py +0 -0
  71. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_mcp_error_module.py +0 -0
  72. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_och_client.py +0 -0
  73. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_oncall_handoff.py +0 -0
  74. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_oncall_metrics.py +0 -0
  75. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_oncall_new_tools.py +0 -0
  76. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_oncall_severity_normalization.py +0 -0
  77. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_payload_stripping_module.py +0 -0
  78. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_security.py +0 -0
  79. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_server_defaults_module.py +0 -0
  80. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_smart_utils.py +0 -0
  81. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_tools.py +0 -0
  82. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_utils.py +0 -0
  83. {rootly_mcp_server-2.2.22 → rootly_mcp_server-2.2.24}/tests/unit/test_validators.py +0 -0
@@ -5,6 +5,32 @@ All notable changes to the Rootly MCP Server will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.2.24] - Released 2026-04-22
9
+
10
+ ### Fixed
11
+
12
+ - **Incident Form Field Selection Responses**: Normalized text and textarea form field selection responses so MCP clients receive the primary `value` plus `selected_*_ids`, without the redundant `selected_*` value objects repeated across unrelated resource types
13
+
14
+ ### Testing
15
+
16
+ - **Response Normalization Coverage**: Added focused transport tests for single-item and list incident form field selection payloads, including a guard to leave select-style fields unchanged
17
+
18
+ ## [2.2.23] - Released 2026-04-22
19
+
20
+ ### Features
21
+
22
+ - **Self-Hosted Tool Allowlists**: Added `ROOTLY_MCP_ENABLED_TOOLS` and `--enabled-tools` so self-hosted deployments can expose only an exact allowlist of MCP tool names
23
+ - **Tool Discovery Command**: Added `--list-tools` so self-hosted users can print the effective tool names for their current configuration before narrowing the MCP surface
24
+ - **Code Mode Alignment**: Applied the same allowlist behavior to the self-hosted Code Mode surface so discovery and enforcement stay consistent
25
+
26
+ ### Testing
27
+
28
+ - **Live MCP Integration Coverage**: Added subprocess integration tests that boot the server, connect over streamable HTTP, call `tools/list`, and verify the live tool payload matches the configured allowlist
29
+
30
+ ### Documentation
31
+
32
+ - **Self-Hosted Setup Guidance**: Documented the new allowlist and discovery workflow in the README, including smoke-test examples for read-only and write-enabled self-hosted setups
33
+
8
34
  ## [2.2.20] - Released 2026-04-21
9
35
 
10
36
  ### Security
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rootly-mcp-server
3
- Version: 2.2.22
3
+ Version: 2.2.24
4
4
  Summary: Secure Model Context Protocol server for Rootly APIs with AI SRE capabilities, comprehensive error handling, and input validation
5
5
  Project-URL: Homepage, https://github.com/Rootly-AI-Labs/Rootly-MCP-server
6
6
  Project-URL: Issues, https://github.com/Rootly-AI-Labs/Rootly-MCP-server/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rootly-mcp-server"
3
- version = "2.2.22"
3
+ version = "2.2.24"
4
4
  description = "Secure Model Context Protocol server for Rootly APIs with AI SRE capabilities, comprehensive error handling, and input validation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -717,6 +717,9 @@ class AuthenticatedHTTPXClient:
717
717
  # there is no other interception point for auto-generated tools.
718
718
  response = self._maybe_strip_alert_response(method, url, response)
719
719
  response = self._maybe_strip_collection_response(method, url, response)
720
+ response = self._maybe_normalize_incident_form_field_selection_response(
721
+ method, url, response
722
+ )
720
723
 
721
724
  return response
722
725
 
@@ -739,6 +742,13 @@ class AuthenticatedHTTPXClient:
739
742
  except Exception:
740
743
  return str(url).split("?", 1)[0]
741
744
 
745
+ @staticmethod
746
+ def _is_incident_form_field_selection_endpoint(path: str) -> bool:
747
+ """Return whether a path targets incident form field selection resources."""
748
+ return path.startswith("/v1/incident_form_field_selections/") or (
749
+ path.startswith("/v1/incidents/") and path.endswith("/form_field_selections")
750
+ )
751
+
742
752
  @staticmethod
743
753
  def _maybe_strip_alert_response(
744
754
  method: str, url: str, response: httpx.Response
@@ -783,6 +793,85 @@ class AuthenticatedHTTPXClient:
783
793
  logger.debug(f"Could not strip collection response for {url}", exc_info=True)
784
794
  return response
785
795
 
796
+ @staticmethod
797
+ def _normalize_incident_form_field_selection_item(item: Any) -> Any:
798
+ """Prune redundant selected_* objects for text-like form field selections."""
799
+ if not isinstance(item, dict):
800
+ return item
801
+
802
+ attributes = item.get("attributes")
803
+ if not isinstance(attributes, dict):
804
+ return item
805
+
806
+ form_field = attributes.get("form_field")
807
+ if not isinstance(form_field, dict):
808
+ return item
809
+
810
+ if form_field.get("input_kind") not in {"text", "textarea"}:
811
+ return item
812
+
813
+ normalized_attributes = dict(attributes)
814
+ for key in (
815
+ "selected_groups",
816
+ "selected_options",
817
+ "selected_services",
818
+ "selected_functionalities",
819
+ "selected_catalog_entities",
820
+ "selected_users",
821
+ "selected_environments",
822
+ "selected_causes",
823
+ "selected_incident_types",
824
+ ):
825
+ normalized_attributes.pop(key, None)
826
+
827
+ normalized_item = dict(item)
828
+ normalized_item["attributes"] = normalized_attributes
829
+ return normalized_item
830
+
831
+ @classmethod
832
+ def _normalize_incident_form_field_selection_payload(cls, payload: Any) -> Any:
833
+ """Normalize form field selection payloads that may contain one item or many."""
834
+ if not isinstance(payload, dict):
835
+ return payload
836
+
837
+ data = payload.get("data")
838
+ if isinstance(data, dict):
839
+ normalized_payload = dict(payload)
840
+ normalized_payload["data"] = cls._normalize_incident_form_field_selection_item(data)
841
+ return normalized_payload
842
+
843
+ if isinstance(data, list):
844
+ normalized_payload = dict(payload)
845
+ normalized_payload["data"] = [
846
+ cls._normalize_incident_form_field_selection_item(item) for item in data
847
+ ]
848
+ return normalized_payload
849
+
850
+ return payload
851
+
852
+ @classmethod
853
+ def _maybe_normalize_incident_form_field_selection_response(
854
+ cls, method: str, url: str, response: httpx.Response
855
+ ) -> httpx.Response:
856
+ """Normalize noisy incident form field selection responses."""
857
+ if method.upper() not in {"GET", "POST", "PUT", "PATCH"} or not response.is_success:
858
+ return response
859
+
860
+ path = cls._path_for_url(url)
861
+ if not cls._is_incident_form_field_selection_endpoint(path):
862
+ return response
863
+
864
+ try:
865
+ payload = response.json()
866
+ normalized = cls._normalize_incident_form_field_selection_payload(payload)
867
+ response._content = json.dumps(normalized).encode() # noqa: SLF001
868
+ except Exception:
869
+ logger.debug(
870
+ f"Could not normalize incident form field selection response for {url}",
871
+ exc_info=True,
872
+ )
873
+ return response
874
+
786
875
  async def get(self, url: str, **kwargs):
787
876
  """Proxy to request with GET method."""
788
877
  return await self.request("GET", url, **kwargs)
@@ -858,6 +947,9 @@ class AuthenticatedHTTPXClient:
858
947
 
859
948
  response = self._maybe_strip_alert_response(request.method, str(request.url), response)
860
949
  response = self._maybe_strip_collection_response(request.method, str(request.url), response)
950
+ response = self._maybe_normalize_incident_form_field_selection_response(
951
+ request.method, str(request.url), response
952
+ )
861
953
  return response
862
954
 
863
955
  async def __aenter__(self):
@@ -289,32 +289,38 @@ class TestServerCreation:
289
289
  class TestBundledIncidentFormFieldSelectionTools:
290
290
  """Verify the bundled swagger exposes the intended incident custom field tools."""
291
291
 
292
- async def test_default_server_hides_generated_write_tools(self, mock_environment_token):
292
+ async def test_default_server_shows_generated_write_tools(self, mock_environment_token):
293
293
  server = create_rootly_mcp_server(hosted=False)
294
294
 
295
295
  tools = await server.list_tools()
296
296
  tool_names = {tool.name for tool in tools}
297
297
 
298
+ # Read tools should be present
298
299
  assert "listIncidentActionItems" in tool_names
299
300
  assert "listIncidentFormFieldSelections" in tool_names
300
301
  assert "getIncidentFormFieldSelection" in tool_names
301
302
 
302
- assert "createIncidentActionItem" not in tool_names
303
- assert "createIncidentFormFieldSelection" not in tool_names
304
- assert "updateIncidentFormFieldSelection" not in tool_names
303
+ # Write tools should also be present by default
304
+ assert "createIncidentActionItem" in tool_names
305
+ assert "createIncidentFormFieldSelection" in tool_names
306
+ assert "updateIncidentFormFieldSelection" in tool_names
307
+ # Delete operations are still restricted
305
308
  assert "deleteIncidentFormFieldSelection" not in tool_names
306
309
 
307
- async def test_default_server_hides_workflow_write_tools(self, mock_environment_token):
310
+ async def test_default_server_shows_workflow_write_tools(self, mock_environment_token):
308
311
  server = create_rootly_mcp_server(hosted=False)
309
312
 
310
313
  tools = await server.list_tools()
311
314
  tool_names = {tool.name for tool in tools}
312
315
 
316
+ # Read tools should be present
313
317
  assert "listWorkflowTasks" in tool_names
314
318
  assert "getWorkflowTask" in tool_names
315
319
 
316
- assert "createWorkflowTask" not in tool_names
317
- assert "updateWorkflowTask" not in tool_names
320
+ # Write tools should also be present by default
321
+ assert "createWorkflowTask" in tool_names
322
+ assert "updateWorkflowTask" in tool_names
323
+ # Delete operations are still restricted
318
324
  assert "deleteWorkflowTask" not in tool_names
319
325
 
320
326
  async def test_enable_write_tools_exposes_curated_generated_write_tools(
@@ -267,6 +267,175 @@ class TestTransportModule:
267
267
  == ""
268
268
  )
269
269
 
270
+ def test_normalize_incident_form_field_selection_payload_textarea(self):
271
+ payload = {
272
+ "data": {
273
+ "id": "selection-1",
274
+ "type": "incident_form_field_selections",
275
+ "attributes": {
276
+ "value": "Decagon Chat API degraded",
277
+ "selected_group_ids": [],
278
+ "selected_option_ids": [],
279
+ "selected_service_ids": [],
280
+ "selected_functionality_ids": [],
281
+ "selected_catalog_entity_ids": [],
282
+ "selected_user_ids": [],
283
+ "selected_groups": {"id": None, "value": "Decagon Chat API degraded"},
284
+ "selected_options": {"id": None, "value": "Decagon Chat API degraded"},
285
+ "selected_services": {"id": None, "value": "Decagon Chat API degraded"},
286
+ "selected_functionalities": {
287
+ "id": None,
288
+ "value": "Decagon Chat API degraded",
289
+ },
290
+ "selected_catalog_entities": {
291
+ "id": None,
292
+ "value": "Decagon Chat API degraded",
293
+ },
294
+ "selected_users": {"id": None, "value": "Decagon Chat API degraded"},
295
+ "selected_environments": {
296
+ "id": None,
297
+ "value": "Decagon Chat API degraded",
298
+ },
299
+ "selected_causes": {"id": None, "value": "Decagon Chat API degraded"},
300
+ "selected_incident_types": {
301
+ "id": None,
302
+ "value": "Decagon Chat API degraded",
303
+ },
304
+ "form_field": {"input_kind": "textarea"},
305
+ },
306
+ }
307
+ }
308
+
309
+ normalized = (
310
+ transport.AuthenticatedHTTPXClient._normalize_incident_form_field_selection_payload(
311
+ payload
312
+ )
313
+ )
314
+ attributes = normalized["data"]["attributes"]
315
+
316
+ assert attributes["value"] == "Decagon Chat API degraded"
317
+ assert attributes["selected_group_ids"] == []
318
+ assert "selected_groups" not in attributes
319
+ assert "selected_options" not in attributes
320
+ assert "selected_services" not in attributes
321
+ assert "selected_functionalities" not in attributes
322
+ assert "selected_catalog_entities" not in attributes
323
+ assert "selected_users" not in attributes
324
+ assert "selected_environments" not in attributes
325
+ assert "selected_causes" not in attributes
326
+ assert "selected_incident_types" not in attributes
327
+
328
+ def test_normalize_incident_form_field_selection_payload_select_unchanged(self):
329
+ payload = {
330
+ "data": {
331
+ "id": "selection-2",
332
+ "type": "incident_form_field_selections",
333
+ "attributes": {
334
+ "selected_option_ids": ["opt-1"],
335
+ "selected_options": {"id": "opt-1", "value": "Database"},
336
+ "form_field": {"input_kind": "select"},
337
+ },
338
+ }
339
+ }
340
+
341
+ normalized = (
342
+ transport.AuthenticatedHTTPXClient._normalize_incident_form_field_selection_payload(
343
+ payload
344
+ )
345
+ )
346
+
347
+ assert normalized == payload
348
+
349
+ def test_normalize_incident_form_field_selection_list_payload(self):
350
+ payload = {
351
+ "data": [
352
+ {
353
+ "id": "selection-3",
354
+ "type": "incident_form_field_selections",
355
+ "attributes": {
356
+ "value": "External user impact only",
357
+ "selected_groups": {"id": None, "value": "External user impact only"},
358
+ "selected_group_ids": [],
359
+ "form_field": {"input_kind": "textarea"},
360
+ },
361
+ },
362
+ {
363
+ "id": "selection-4",
364
+ "type": "incident_form_field_selections",
365
+ "attributes": {
366
+ "selected_options": {"id": "opt-1", "value": "Database"},
367
+ "selected_option_ids": ["opt-1"],
368
+ "form_field": {"input_kind": "select"},
369
+ },
370
+ },
371
+ ]
372
+ }
373
+
374
+ normalized = (
375
+ transport.AuthenticatedHTTPXClient._normalize_incident_form_field_selection_payload(
376
+ payload
377
+ )
378
+ )
379
+
380
+ assert "selected_groups" not in normalized["data"][0]["attributes"]
381
+ assert normalized["data"][1]["attributes"]["selected_options"] == {
382
+ "id": "opt-1",
383
+ "value": "Database",
384
+ }
385
+
386
+ def test_maybe_normalize_incident_form_field_selection_response(self):
387
+ response = httpx.Response(
388
+ 200,
389
+ json={
390
+ "data": {
391
+ "id": "selection-5",
392
+ "type": "incident_form_field_selections",
393
+ "attributes": {
394
+ "value": "External user impact only",
395
+ "selected_groups": {"id": None, "value": "External user impact only"},
396
+ "selected_group_ids": [],
397
+ "form_field": {"input_kind": "textarea"},
398
+ },
399
+ }
400
+ },
401
+ )
402
+
403
+ result = transport.AuthenticatedHTTPXClient._maybe_normalize_incident_form_field_selection_response(
404
+ "PUT", "/v1/incident_form_field_selections/selection-5", response
405
+ )
406
+ parsed = result.json()
407
+
408
+ assert parsed["data"]["attributes"]["value"] == "External user impact only"
409
+ assert parsed["data"]["attributes"]["selected_group_ids"] == []
410
+ assert "selected_groups" not in parsed["data"]["attributes"]
411
+
412
+ def test_maybe_normalize_incident_form_field_selection_list_response(self):
413
+ response = httpx.Response(
414
+ 200,
415
+ json={
416
+ "data": [
417
+ {
418
+ "id": "selection-6",
419
+ "type": "incident_form_field_selections",
420
+ "attributes": {
421
+ "value": "External user impact only",
422
+ "selected_groups": {"id": None, "value": "External user impact only"},
423
+ "selected_group_ids": [],
424
+ "form_field": {"input_kind": "textarea"},
425
+ },
426
+ }
427
+ ]
428
+ },
429
+ )
430
+
431
+ result = transport.AuthenticatedHTTPXClient._maybe_normalize_incident_form_field_selection_response(
432
+ "GET", "/v1/incidents/inc-123/form_field_selections", response
433
+ )
434
+ parsed = result.json()
435
+
436
+ assert parsed["data"][0]["attributes"]["selected_group_ids"] == []
437
+ assert "selected_groups" not in parsed["data"][0]["attributes"]
438
+
270
439
  def test_authenticated_client_user_agent_contains_mode(self):
271
440
  with patch.object(
272
441
  transport.AuthenticatedHTTPXClient, "_get_api_token", return_value="token"
@@ -1715,7 +1715,7 @@ wheels = [
1715
1715
 
1716
1716
  [[package]]
1717
1717
  name = "rootly-mcp-server"
1718
- version = "2.2.20"
1718
+ version = "2.2.24"
1719
1719
  source = { editable = "." }
1720
1720
  dependencies = [
1721
1721
  { name = "authlib" },