rootly-mcp-server 2.3.1__tar.gz → 2.3.3__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.3.1 → rootly_mcp_server-2.3.3}/CHANGELOG.md +10 -0
  2. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/PKG-INFO +1 -1
  3. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/pyproject.toml +1 -1
  4. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/server_defaults.py +12 -0
  5. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/spec_transform.py +37 -6
  6. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/transport.py +82 -0
  7. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_transport_module.py +71 -0
  8. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.dockerignore +0 -0
  9. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.gitattributes +0 -0
  10. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.github/dependabot.yml +0 -0
  11. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.github/openapi-audit-remote-baseline.json +0 -0
  12. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.github/workflows/ci.yml +0 -0
  13. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.github/workflows/dependency-review.yml +0 -0
  14. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.github/workflows/openapi-audit.yml +0 -0
  15. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.github/workflows/pypi-release.yml +0 -0
  16. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.gitignore +0 -0
  17. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.semaphore/deploy-staging.yml +0 -0
  18. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.semaphore/deploy.yml +0 -0
  19. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.semaphore/semaphore.yml +0 -0
  20. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/.semaphore/update-task-definition.sh +0 -0
  21. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/CONTRIBUTING.md +0 -0
  22. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/Dockerfile +0 -0
  23. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/LICENSE +0 -0
  24. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/README.md +0 -0
  25. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/examples/skills/README.md +0 -0
  26. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/gemini-extension.json +0 -0
  27. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/rootly-mcp-server-demo.gif +0 -0
  28. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/rootly_openapi.json +0 -0
  29. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/scripts/audit_openapi.py +0 -0
  30. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/scripts/setup-hooks.sh +0 -0
  31. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/server.json +0 -0
  32. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/__init__.py +0 -0
  33. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/__main__.py +0 -0
  34. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/audit.py +0 -0
  35. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/client.py +0 -0
  36. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/code_mode.py +0 -0
  37. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/data/__init__.py +0 -0
  38. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/exceptions.py +0 -0
  39. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/legacy_server.py +0 -0
  40. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/mcp_error.py +0 -0
  41. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/monitoring.py +0 -0
  42. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/och_client.py +0 -0
  43. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/pagination.py +0 -0
  44. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/payload_stripping.py +0 -0
  45. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/security.py +0 -0
  46. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/server.py +0 -0
  47. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/smart_utils.py +0 -0
  48. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/tools/__init__.py +0 -0
  49. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/tools/alerts.py +0 -0
  50. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/tools/incidents.py +0 -0
  51. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/tools/oncall.py +0 -0
  52. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/tools/resources.py +0 -0
  53. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/utils.py +0 -0
  54. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/src/rootly_mcp_server/validators.py +0 -0
  55. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/README.md +0 -0
  56. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/conftest.py +0 -0
  57. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/integration/local/test_basic.py +0 -0
  58. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/integration/local/test_smart_tools.py +0 -0
  59. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/integration/local/test_tool_allowlists.py +0 -0
  60. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/integration/remote/test_essential.py +0 -0
  61. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/test_client.py +0 -0
  62. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_alert_stripping.py +0 -0
  63. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_authentication.py +0 -0
  64. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_code_mode_module.py +0 -0
  65. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_exceptions.py +0 -0
  66. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_http_headers.py +0 -0
  67. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_legacy_server_module.py +0 -0
  68. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_main_transport.py +0 -0
  69. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_mcp_error_module.py +0 -0
  70. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_och_client.py +0 -0
  71. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_oncall_handoff.py +0 -0
  72. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_oncall_metrics.py +0 -0
  73. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_oncall_new_tools.py +0 -0
  74. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_oncall_severity_normalization.py +0 -0
  75. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_payload_stripping_module.py +0 -0
  76. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_security.py +0 -0
  77. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_server.py +0 -0
  78. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_server_defaults_module.py +0 -0
  79. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_smart_utils.py +0 -0
  80. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_tools.py +0 -0
  81. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_utils.py +0 -0
  82. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/tests/unit/test_validators.py +0 -0
  83. {rootly_mcp_server-2.3.1 → rootly_mcp_server-2.3.3}/uv.lock +0 -0
@@ -5,6 +5,16 @@ 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.3.2] - Released 2026-04-23
9
+
10
+ ### Fixed
11
+
12
+ - **Generated Write Payload Forwarding**: Normalized generated OpenAPI write-tool payloads to unwrap top-level `body` envelopes before forwarding to the Rootly API, fixing failures such as `createWorkflow` returning `Invalid payload ({data: "Expected hash"})`
13
+
14
+ ### Testing
15
+
16
+ - **Request-Path Regression Coverage**: Added transport regression tests that assert write requests forward unwrapped JSON payloads and preserve already-correct non-envelope payloads
17
+
8
18
  ## [2.3.1] - Released 2026-04-23
9
19
 
10
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rootly-mcp-server
3
- Version: 2.3.1
3
+ Version: 2.3.3
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.3.1"
3
+ version = "2.3.3"
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"
@@ -373,6 +373,7 @@ DEFAULT_WRITE_ALLOWED_PATHS = [
373
373
  "/workflows",
374
374
  "/workflows/{workflow_id}",
375
375
  "/workflow_groups",
376
+ "/workflow_groups/{id}",
376
377
  "/workflows/{workflow_id}/workflow_tasks",
377
378
  "/workflow_tasks/{id}",
378
379
  "/workflows/{workflow_id}/form_field_conditions",
@@ -394,6 +395,7 @@ DEFAULT_WRITE_ALLOWED_PATHS = [
394
395
  "/live_call_routers",
395
396
  "/live_call_routers/{id}",
396
397
  # Post-incident and retrospectives - create + update
398
+ "/post_incident_reviews",
397
399
  "/post_incident_reviews/{id}",
398
400
  "/retrospective_processes",
399
401
  "/retrospective_processes/{id}",
@@ -401,6 +403,7 @@ DEFAULT_WRITE_ALLOWED_PATHS = [
401
403
  "/retrospective_process_groups/{id}",
402
404
  "/retrospective_processes/{retrospective_process_id}/retrospective_steps",
403
405
  "/retrospective_steps/{id}",
406
+ "/postmortem_templates",
404
407
  "/postmortem_templates/{id}",
405
408
  # Communications - create + update
406
409
  "/communications_groups",
@@ -413,11 +416,15 @@ DEFAULT_WRITE_ALLOWED_PATHS = [
413
416
  "/communications_types/{id}",
414
417
  # Causes and catalog - create + update
415
418
  "/causes",
419
+ "/causes/{id}",
416
420
  "/catalogs",
421
+ "/catalogs/{id}",
417
422
  "/catalogs/{catalog_id}/entities",
423
+ "/catalog_entities/{id}",
418
424
  # Sub-statuses - create + update
419
425
  "/sub_statuses",
420
426
  "/sub_statuses/{id}",
427
+ "/incident_sub_statuses",
421
428
  "/incident_sub_statuses/{id}",
422
429
  # User notification preferences - create + update
423
430
  "/users/{user_id}/notification_rules",
@@ -435,13 +442,18 @@ DEFAULT_WRITE_ALLOWED_PATHS = [
435
442
  # Form and field management - create + update
436
443
  "/custom_fields",
437
444
  "/custom_fields/{id}",
445
+ "/custom_field_options",
438
446
  "/custom_field_options/{id}",
439
447
  "/form_sets",
440
448
  "/form_sets/{id}",
441
449
  "/form_field_placements/{id}",
442
450
  "/form_field_placement_conditions/{id}",
443
451
  "/form_set_conditions/{id}",
452
+ # Status pages - create + update
453
+ "/status-pages",
454
+ "/status-pages/{id}",
444
455
  # Status page templates
456
+ "/status_page_templates",
445
457
  "/status_page_templates/{id}",
446
458
  ]
447
459
 
@@ -493,22 +493,30 @@ def _filter_openapi_spec(
493
493
  }
494
494
  )
495
495
 
496
- # Also clean up any remaining broken references in components
496
+ # Also clean up any remaining broken references in components.
497
+ # Schemas with broken $refs are patched in-place (broken refs replaced with generic objects)
498
+ # rather than removed, so that write tools like createWorkflow retain their field structure.
497
499
  if "components" in filtered_spec and "schemas" in filtered_spec["components"]:
498
500
  schemas = filtered_spec["components"]["schemas"]
499
- # Remove or fix any schemas that reference missing components
500
501
  schemas_to_remove = []
501
502
  for schema_name, schema_def in schemas.items():
502
503
  if isinstance(schema_def, dict) and _has_broken_references(schema_def):
503
- schemas_to_remove.append(schema_name)
504
+ patched = _patch_broken_refs(schema_def)
505
+ if _has_broken_references(patched):
506
+ # Still broken after patching — remove it entirely
507
+ schemas_to_remove.append(schema_name)
508
+ else:
509
+ schemas[schema_name] = patched
510
+ logger.debug(f"Patched broken references in schema: {schema_name}")
504
511
 
505
512
  for schema_name in schemas_to_remove:
506
513
  logger.debug(f"Removing schema with broken references: {schema_name}")
507
514
  del schemas[schema_name]
508
515
 
509
- # Clean up any operation-level references to removed schemas
510
- removed_schemas = set()
516
+ # Clean up operation-level references to schemas that were fully removed (not just patched)
517
+ removed_schemas: set[str] = set()
511
518
  if "components" in filtered_spec and "schemas" in filtered_spec["components"]:
519
+ existing = set(filtered_spec["components"]["schemas"].keys())
512
520
  removed_schemas = {
513
521
  "new_workflow",
514
522
  "update_workflow",
@@ -520,7 +528,7 @@ def _filter_openapi_spec(
520
528
  "update_workflow_task",
521
529
  "workflow_task_response",
522
530
  "workflow_task_list",
523
- }
531
+ } - existing # Only replace refs to schemas that were actually removed
524
532
 
525
533
  for path, path_item in filtered_spec.get("paths", {}).items():
526
534
  for method, operation in path_item.items():
@@ -747,3 +755,26 @@ def _has_broken_references(schema_def: dict[str, Any]) -> bool:
747
755
  return True
748
756
 
749
757
  return False
758
+
759
+
760
+ def _patch_broken_refs(schema_def: dict[str, Any]) -> dict[str, Any]:
761
+ """Recursively replace broken $refs with a generic object schema.
762
+
763
+ This preserves the parent schema's field structure (e.g. new_workflow keeps
764
+ name/description/etc.) while neutralising unresolvable nested references like
765
+ incident_trigger_params.
766
+ """
767
+ if "$ref" in schema_def and _has_broken_references(schema_def):
768
+ return {"type": "object", "additionalProperties": True}
769
+
770
+ result: dict[str, Any] = {}
771
+ for key, value in schema_def.items():
772
+ if isinstance(value, dict):
773
+ result[key] = _patch_broken_refs(value)
774
+ elif isinstance(value, list):
775
+ result[key] = [
776
+ _patch_broken_refs(item) if isinstance(item, dict) else item for item in value
777
+ ]
778
+ else:
779
+ result[key] = value
780
+ return result
@@ -651,6 +651,8 @@ class AuthenticatedHTTPXClient:
651
651
  # Transform query parameters
652
652
  if "params" in kwargs:
653
653
  kwargs["params"] = self._transform_params(kwargs["params"])
654
+ if "json" in kwargs:
655
+ kwargs["json"] = self._normalize_request_json_payload(method, kwargs["json"])
654
656
 
655
657
  # Log incoming headers for debugging (before transformation)
656
658
  incoming_headers = kwargs.get("headers", {})
@@ -720,9 +722,89 @@ class AuthenticatedHTTPXClient:
720
722
  response = self._maybe_normalize_incident_form_field_selection_response(
721
723
  method, url, response
722
724
  )
725
+ response = self._maybe_annotate_404_response(method, url, response)
723
726
 
724
727
  return response
725
728
 
729
+ @staticmethod
730
+ def _normalize_request_json_payload(method: str, payload: Any) -> Any:
731
+ """Normalize JSON payloads forwarded by generated tools.
732
+
733
+ Some generated write tools expose a top-level `body` parameter, which can
734
+ result in JSON payloads of shape `{"body": {...}}`. Rootly API write
735
+ endpoints expect the inner object as the actual request body.
736
+ """
737
+ if method.upper() not in {"POST", "PUT", "PATCH"}:
738
+ return payload
739
+ if not isinstance(payload, dict):
740
+ return payload
741
+ body = payload.get("body")
742
+ if len(payload) == 1 and isinstance(body, dict):
743
+ return body
744
+ return payload
745
+
746
+ # Matches UUID v4 format: 8-4-4-4-12 lowercase hex chars
747
+ _UUID_RE = re.compile(
748
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
749
+ )
750
+
751
+ @staticmethod
752
+ def _path_has_id_segment(url: str) -> bool:
753
+ """Return True if the URL path ends with what looks like an ID segment.
754
+
755
+ Collection paths (/v1/heartbeats, /v1/status-pages) return False.
756
+ Individual-resource paths (/v1/heartbeats/123) return True.
757
+
758
+ Hyphenated resource names like 'status-pages' must not be confused with
759
+ UUID-style IDs — the UUID check requires strict hex-only segments.
760
+ """
761
+ path = AuthenticatedHTTPXClient._path_for_url(url)
762
+ last = path.rstrip("/").rsplit("/", 1)[-1]
763
+ return bool(last and (last.isdigit() or AuthenticatedHTTPXClient._UUID_RE.match(last)))
764
+
765
+ @staticmethod
766
+ def _maybe_annotate_404_response(
767
+ method: str, url: str, response: httpx.Response
768
+ ) -> httpx.Response:
769
+ """Append a plan-gating hint to 404 responses.
770
+
771
+ Rootly returns 404 for endpoints that are locked to a specific subscription
772
+ tier, even when the request is valid. The response body uses the generic
773
+ title "Not found or unauthorized" with no plan-specific discriminator.
774
+
775
+ Heuristic: a 404 on a collection path (no trailing ID) is almost certainly
776
+ plan gating regardless of method. A 404 on an ID path during a write is
777
+ ambiguous — the resource may simply not exist — so the hint is softened.
778
+ """
779
+ if response.status_code != 404:
780
+ return response
781
+ has_id = AuthenticatedHTTPXClient._path_has_id_segment(url)
782
+ is_write = method.upper() in {"POST", "PUT", "PATCH"}
783
+ # Skip GET on ID paths — those are ordinary "resource not found" responses
784
+ if has_id and not is_write:
785
+ return response
786
+ try:
787
+ body = response.json()
788
+ if not has_id:
789
+ hint = (
790
+ "This 404 most likely means the feature is not enabled on your Rootly plan. "
791
+ "Contact Rootly support to enable it for your organisation."
792
+ )
793
+ else:
794
+ hint = (
795
+ "This 404 may mean the resource does not exist, or that the feature is not "
796
+ "enabled on your Rootly plan. Contact Rootly support if the resource exists "
797
+ "and the error persists."
798
+ )
799
+ if isinstance(body, dict):
800
+ body.setdefault("_plan_gating_hint", hint)
801
+ else:
802
+ body = {"original_response": body, "_plan_gating_hint": hint}
803
+ response._content = json.dumps(body).encode() # noqa: SLF001
804
+ except Exception: # nosec B110 - Safe fallback; annotation is best-effort
805
+ pass
806
+ return response
807
+
726
808
  @staticmethod
727
809
  def _is_alert_endpoint(url: str) -> bool:
728
810
  """Check if a URL is an alert endpoint (but not alert sub-resources like events)."""
@@ -523,6 +523,54 @@ class TestTransportModule:
523
523
  assert error_context["upstream_path"] == "/v1/alerts"
524
524
  assert error_context["upstream_log_level"] == "error"
525
525
 
526
+ @pytest.mark.asyncio
527
+ async def test_authenticated_client_unwraps_body_envelope_on_write_requests(self):
528
+ response = httpx.Response(
529
+ 200,
530
+ request=httpx.Request("POST", "https://api.rootly.com/v1/workflows"),
531
+ content=b'{"data":{"id":"wf-1"}}',
532
+ )
533
+
534
+ with patch.object(
535
+ transport.AuthenticatedHTTPXClient, "_get_api_token", return_value="token"
536
+ ):
537
+ client = transport.AuthenticatedHTTPXClient(hosted=False, transport="stdio")
538
+ client.client.request = AsyncMock(return_value=response)
539
+
540
+ await client.request(
541
+ "POST",
542
+ "/v1/workflows",
543
+ json={"body": {"genius_workflow": {"name": "MCP verification workflow"}}},
544
+ )
545
+
546
+ _, kwargs = client.client.request.call_args
547
+ assert kwargs["json"] == {"genius_workflow": {"name": "MCP verification workflow"}}
548
+
549
+ @pytest.mark.asyncio
550
+ async def test_authenticated_client_preserves_non_envelope_payload_on_update_requests(self):
551
+ response = httpx.Response(
552
+ 200,
553
+ request=httpx.Request("PATCH", "https://api.rootly.com/v1/workflows/wf-1"),
554
+ content=b'{"data":{"id":"wf-1"}}',
555
+ )
556
+
557
+ with patch.object(
558
+ transport.AuthenticatedHTTPXClient, "_get_api_token", return_value="token"
559
+ ):
560
+ client = transport.AuthenticatedHTTPXClient(hosted=False, transport="stdio")
561
+ client.client.request = AsyncMock(return_value=response)
562
+
563
+ await client.request(
564
+ "PATCH",
565
+ "/v1/workflows/wf-1",
566
+ json={"data": {"type": "workflows", "attributes": {"name": "Updated"}}},
567
+ )
568
+
569
+ _, kwargs = client.client.request.call_args
570
+ assert kwargs["json"] == {
571
+ "data": {"type": "workflows", "attributes": {"name": "Updated"}}
572
+ }
573
+
526
574
  def test_sanitize_log_excerpt_redacts_tokens_and_paths(self):
527
575
  excerpt = transport._sanitize_log_excerpt(
528
576
  'Bearer rootly_1234567890 File "/Users/spencercheng/app.py" failed'
@@ -560,6 +608,29 @@ class TestTransportModule:
560
608
  assert result["data"][0]["relationships"]["alerts"] == {"count": 2}
561
609
  assert "included" not in result
562
610
 
611
+ def test_normalize_request_json_payload_unwraps_body_for_write_methods(self):
612
+ payload = {"body": {"genius_workflow": {"name": "workflow-name"}}}
613
+
614
+ result = transport.AuthenticatedHTTPXClient._normalize_request_json_payload("POST", payload)
615
+
616
+ assert result == {"genius_workflow": {"name": "workflow-name"}}
617
+
618
+ def test_normalize_request_json_payload_does_not_unwrap_for_get(self):
619
+ payload = {"body": {"query": "database timeout"}}
620
+
621
+ result = transport.AuthenticatedHTTPXClient._normalize_request_json_payload("GET", payload)
622
+
623
+ assert result == payload
624
+
625
+ def test_normalize_request_json_payload_keeps_non_envelope_dict(self):
626
+ payload = {"data": {"type": "incidents"}, "body": {"ignored": True}}
627
+
628
+ result = transport.AuthenticatedHTTPXClient._normalize_request_json_payload(
629
+ "PATCH", payload
630
+ )
631
+
632
+ assert result == payload
633
+
563
634
  def test_strip_heavy_user_data_keeps_profile_essentials(self):
564
635
  data = {
565
636
  "data": [