lcp-sdk 1.0.0__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.
@@ -0,0 +1,16 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # Local tooling artifacts — never published
5
+ .gitnexus/
6
+ .claude/
7
+ graphify-out/
8
+ CLAUDE.md
9
+ .hermes.md
10
+ *.pyc
11
+ __pycache__/
12
+
13
+ # Local platform/runtime state
14
+ .venv/
15
+ data/
16
+ *.sqlite3
lcp_sdk-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: lcp-sdk
3
+ Version: 1.0.0
4
+ Summary: LCP (Lead Context Protocol) SDK for validation, signing, and HTTP integrations.
5
+ Project-URL: Documentation, https://github.com/SpearSystems/LCP
6
+ Project-URL: Source, https://github.com/SpearSystems/LCP
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: jsonschema>=4.20.0
11
+ Requires-Dist: referencing>=0.35.0
@@ -0,0 +1,149 @@
1
+ # LCP Python SDK
2
+
3
+ Tier 1 maintained reference SDK for LCP publishers, buyers, platforms, and
4
+ webhook receivers. It is independent of MCP and does not contain platform
5
+ routing or storage logic.
6
+
7
+ ## Install
8
+
9
+ From the repository while the package is unreleased:
10
+
11
+ ```bash
12
+ python3 -m pip install -e implementations/sdk/python
13
+ ```
14
+
15
+ When a release is published:
16
+
17
+ ```bash
18
+ python3 -m pip install lcp-sdk
19
+ ```
20
+
21
+ The package targets Python 3.10+ and bundles the canonical schemas in released
22
+ wheels. See the [SDK support policy](../../../docs/SDK-ROADMAP.md) for
23
+ compatibility and release gates.
24
+
25
+ ## Build and validate messages
26
+
27
+ ```python
28
+ from lcp_sdk import SchemaValidator, build_envelope
29
+
30
+ lead = build_envelope(
31
+ "lead",
32
+ sender_id="publisher_001",
33
+ receiver_id="platform_001",
34
+ payload={
35
+ "lead_id": "lead_123",
36
+ "status": "NEW",
37
+ "channel": "form",
38
+ "consumer": {"phone": "+61412345678"},
39
+ "location": {"country_code": "AU", "postal_code": "2000"},
40
+ "attributes": {"vertical": "mortgage", "schema_version": "1.0.0"},
41
+ },
42
+ )
43
+
44
+ SchemaValidator().require_valid_envelope(lead)
45
+ ```
46
+
47
+ `SchemaValidator` validates the envelope, message payload, offer documents,
48
+ and the ping-safe tags in vertical schemas. The released wheel bundles the
49
+ canonical Draft 2020-12 schemas and generated `TypedDict` models in
50
+ `lcp_sdk.generated.models`; set `LCP_SCHEMA_DIR` when using an external schema
51
+ release instead.
52
+
53
+ ## HTTP client
54
+
55
+ ```python
56
+ from lcp_sdk import LCPClient, SchemaValidator
57
+
58
+ client = LCPClient(
59
+ "https://platform.example",
60
+ sender_id="publisher_001",
61
+ hmac_secret="secret-from-your-secret-manager",
62
+ validator=SchemaValidator(),
63
+ max_retries=2,
64
+ )
65
+ ack = client.submit_lead(lead)
66
+ status = client.query_lead_status("lead_123")
67
+ offers = client.list_offers(vertical="mortgage")
68
+ capabilities = client.get_capabilities()
69
+ ```
70
+
71
+ The client provides:
72
+
73
+ | Method | Operation |
74
+ |---|---|
75
+ | `submit_lead` | Submit a lead envelope |
76
+ | `submit_call` | Submit a call envelope |
77
+ | `submit_bid` | Submit a bid envelope |
78
+ | `query_lead_status` | Query lead lifecycle/status |
79
+ | `get_schema` | Retrieve a core or vertical schema |
80
+ | `get_capabilities` | Discover endpoint capabilities |
81
+ | `list_offers` | Discover active offers |
82
+ | `request` | Make an authenticated LCP HTTP request |
83
+
84
+ `429`, `500`, `502`, `503`, `504`, and transport errors are retried with
85
+ exponential backoff. Mutating requests must use a stable envelope idempotency
86
+ key when retrying. Non-success responses raise `LCPHTTPError` with the status
87
+ code and parsed error body.
88
+
89
+ ## Authentication and webhook verification
90
+
91
+ HMAC requests use the approved canonical input:
92
+
93
+ ```text
94
+ <timestamp>\n<idempotency-key>\n<raw-request-body>
95
+ ```
96
+
97
+ The body must be the exact bytes sent on the wire. The SDK sends
98
+ `X-LCP-Timestamp`, `X-LCP-Idempotency-Key`, `X-LCP-Signature`, and
99
+ `X-LCP-Sender-Id`. Bearer authentication is also supported.
100
+
101
+ For a webhook or framework receiver, verify the raw body before JSON parsing:
102
+
103
+ ```python
104
+ from lcp_sdk import verify_http_request
105
+
106
+ headers = {
107
+ "X-LCP-Signature": request.headers["X-LCP-Signature"],
108
+ "X-LCP-Timestamp": request.headers["X-LCP-Timestamp"],
109
+ "X-LCP-Idempotency-Key": request.headers["X-LCP-Idempotency-Key"],
110
+ }
111
+ verify_http_request("partner-secret", headers, request.get_data())
112
+ ```
113
+
114
+ `verify_http_request` enforces the signature and replay window. Applications
115
+ must still perform durable idempotency claims, authorization, schema
116
+ validation, and PII-safe logging.
117
+
118
+ Low-level helpers are also available: `canonical_signing_bytes`, `sign_hmac`,
119
+ and `verify_hmac`.
120
+
121
+ ## Test mode
122
+
123
+ Pass `test=True` to mutating client methods when sending synthetic traffic.
124
+ The SDK emits `X-LCP-Test: true`; the envelope should also have `test=True`.
125
+ Production receivers should reject mismatched or unexpected test traffic.
126
+
127
+ ## Tests
128
+
129
+ ```bash
130
+ PYTHONPATH=implementations/sdk/python \
131
+ python3 -m unittest discover -s implementations/sdk/python/tests -v
132
+ ```
133
+
134
+ The SDK tests include the shared cross-language HMAC fixture in
135
+ `test-vectors/sdk/hmac.json`.
136
+
137
+ ## Publishing
138
+
139
+ The repository includes a GitHub Actions Trusted Publishing workflow for the
140
+ SDK, MCP adapter, and reference platform. Before tagging a release, configure
141
+ one PyPI Trusted Publisher for each package and keep package versions aligned
142
+ with the release process. The workflow publishes on `v*` tags; it does not
143
+ require a long-lived PyPI token in GitHub secrets.
144
+
145
+ ## Scope
146
+
147
+ The SDK intentionally does not implement buyer offer matching, auctions,
148
+ webhook queues, CRM handoff, or persistence. Those belong to the reference
149
+ platform or the operator's deployment.
@@ -0,0 +1,160 @@
1
+ """GENERATED FROM schemas/ — do not edit manually.
2
+
3
+ These TypedDicts are generated from the canonical schema set. Runtime
4
+ acceptance remains the JSON Schema validator's responsibility.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, NotRequired, TypedDict
9
+
10
+ JsonObject = dict[str, Any]
11
+
12
+
13
+ class MessageModel(TypedDict, total=False):
14
+ id: str
15
+ type: str
16
+ timestamp: str
17
+ sender_id: str
18
+ receiver_id: str
19
+ correlation_id: str | None
20
+ idempotency_key: str
21
+ test: NotRequired[bool]
22
+ security: NotRequired[JsonObject]
23
+
24
+
25
+ class EnvelopeModel(TypedDict):
26
+ lcp: dict[str, Any]
27
+
28
+
29
+ class LeadPayload(TypedDict, total=False):
30
+ lead_id: str
31
+ external_id: NotRequired[str]
32
+ submitted_at: NotRequired[str]
33
+ status: str
34
+ channel: str
35
+ consumer: JsonObject
36
+ location: JsonObject
37
+ compliance: NotRequired[JsonObject]
38
+ provenance: NotRequired[JsonObject]
39
+ attributes: JsonObject
40
+ exclusivity: NotRequired[JsonObject]
41
+ contact_window: NotRequired[JsonObject]
42
+ lead_quality: NotRequired[JsonObject]
43
+ expiry: NotRequired[JsonObject]
44
+ attachments: NotRequired[list[JsonObject]]
45
+
46
+
47
+ class CallPayload(TypedDict, total=False):
48
+ lead_id: str
49
+ external_id: NotRequired[str]
50
+ submitted_at: NotRequired[str]
51
+ status: str
52
+ channel: str
53
+ consumer: JsonObject
54
+ location: JsonObject
55
+ call: JsonObject
56
+ compliance: NotRequired[JsonObject]
57
+ provenance: NotRequired[JsonObject]
58
+ attributes: NotRequired[JsonObject]
59
+ expiry: NotRequired[JsonObject]
60
+ exclusivity: NotRequired[JsonObject]
61
+ attachments: NotRequired[list[JsonObject]]
62
+
63
+
64
+ class PingPayload(TypedDict, total=False):
65
+ ping_id: str
66
+ lead_reference: str
67
+ publisher_id: NotRequired[str]
68
+ offer_id: NotRequired[str]
69
+ phone_hash: str
70
+ email_hash: NotRequired[str]
71
+ country_code: str
72
+ state_region: NotRequired[str]
73
+ postal_code: NotRequired[str]
74
+ vertical: str
75
+ lead_age_minutes: NotRequired[int]
76
+ attributes: NotRequired[JsonObject]
77
+ compliance_flags: NotRequired[JsonObject]
78
+ quality_flags: NotRequired[JsonObject]
79
+ floor_price_cents: int
80
+ currency: str
81
+ exclusivity: NotRequired[str]
82
+ dedup_window_hours: NotRequired[int]
83
+ expires_at: NotRequired[str]
84
+
85
+
86
+ class PostPayload(TypedDict, total=False):
87
+ lead_id: str
88
+ delivered_at: str
89
+ submitted_at: NotRequired[str]
90
+ offer_id: NotRequired[str]
91
+ price_cents: int
92
+ currency: str
93
+ buyer_id: str
94
+ buyer_reference: NotRequired[str]
95
+ pricing: NotRequired[JsonObject]
96
+ matched_preferences: NotRequired[JsonObject]
97
+ consumer: JsonObject
98
+ location: JsonObject
99
+ compliance: NotRequired[JsonObject]
100
+ attributes: JsonObject
101
+ provenance: NotRequired[JsonObject]
102
+ exclusivity: NotRequired[JsonObject]
103
+ call: NotRequired[JsonObject]
104
+ attachments: NotRequired[list[JsonObject]]
105
+
106
+
107
+ class BidPayload(TypedDict, total=False):
108
+ ping_id: str
109
+ decision: str
110
+ bid_price_cents: int
111
+ currency: str
112
+ estimated_contact_seconds: NotRequired[int]
113
+ buyer_reference: NotRequired[str]
114
+ reject_reason: NotRequired[str]
115
+ capacity_remaining: NotRequired[int]
116
+
117
+
118
+ class AckPayload(TypedDict, total=False):
119
+ original_message_id: str
120
+ status: str
121
+ errors: NotRequired[list[JsonObject]]
122
+ lead_id: NotRequired[str]
123
+ request_id: NotRequired[str]
124
+ rejection_reason: NotRequired[str]
125
+
126
+
127
+ class EventPayload(TypedDict, total=False):
128
+ lead_id: str
129
+ event: str
130
+ timestamp: str
131
+ details: NotRequired[JsonObject]
132
+ external_reference: NotRequired[str]
133
+
134
+
135
+ class OfferModel(TypedDict, total=False):
136
+ offer_id: str
137
+ buyer_id: str
138
+ tenant_id: NotRequired[str]
139
+ active: NotRequired[bool]
140
+ routing_mode: NotRequired[str]
141
+ vertical: str
142
+ schema_version: NotRequired[str]
143
+ countries: list[str]
144
+ state_regions: NotRequired[list[str]]
145
+ postal_codes: NotRequired[list[str]]
146
+ channels: NotRequired[list[str]]
147
+ floor_price_cents: int
148
+ currency: str
149
+ extensions: NotRequired[JsonObject]
150
+ allowed_publisher_ids: NotRequired[list[str]]
151
+ allowed_brand_ids: NotRequired[list[str]]
152
+ attribute_equals: NotRequired[JsonObject]
153
+ attribute_in: NotRequired[JsonObject]
154
+ monthly_minimum_payable: NotRequired[int]
155
+ monthly_maximum_payable: NotRequired[int]
156
+ monthly_quota_timezone: NotRequired[str]
157
+ monthly_quota_policy: NotRequired[str]
158
+ payable_rules: NotRequired[JsonObject]
159
+ call_routing_mode: NotRequired[str]
160
+ connect_timeout_seconds: NotRequired[int]
@@ -0,0 +1,21 @@
1
+ {
2
+ "protocol_version": "1.0.0",
3
+ "schema_files": {
4
+ "schemas/ack.json": "7946d73afff4653c68ef4842986d9e5a4839ab2765346c706b8a60f46c060e4c",
5
+ "schemas/bid.json": "c7a1c901c562e303ec7cceb9418d5d40d33f47d1360779ea5b8ec80a9e6bce05",
6
+ "schemas/call.json": "142aa654c964493cd67721921f1614a9f8635807732b816671bf5d0053bf2fe2",
7
+ "schemas/core.json": "8385f2a7b02aa7c1e5bde67afa4247dfe8c6c1d2e5bdb5783f70546b01445cac",
8
+ "schemas/envelope.json": "69bde360f9a985662ffef8d0f63dc3acbcf8d773c3fa763b70cc9e75f69a2606",
9
+ "schemas/event.json": "e3c93ab2aa464a8401392ece2093abe207050bc372086763f373498048fd63fc",
10
+ "schemas/lead.json": "dd5989503d9d8d2cf65114578d06e6300a767d8f9ae5c14b43bab456bd2bf2e6",
11
+ "schemas/offer.json": "bfaf6a67d1fc00aff8d79965aad4456130d5960b56e8e011cba9a069ad0cb6e3",
12
+ "schemas/ping.json": "d4c12a61f534383744636756e0952a4afe092d2c8070602289bedc51356e7d16",
13
+ "schemas/post.json": "3fc42124c299df5534d0b46be14385e7dfec8771a804946e907c90577bf7bfdb",
14
+ "verticals/home_services.json": "1f022bef90a8f989157e77ac3cebd5d054285bf45d788147adcf398a4e8967ff",
15
+ "verticals/insurance.json": "ba9fd38fbad18b2016bffa54722ad71295de5d309397a84122aa7bb906a3f810",
16
+ "verticals/legal.json": "b4f428230cce6b8a008bca5c01f27069ed9bea37736f3d699194457b2e1f02cf",
17
+ "verticals/mortgage.json": "3570b113753453055af2d7a027e0a13afab8c2e3a054e347395c1900a99aa03c",
18
+ "verticals/mva.json": "07138d68241e9f1e96f531d62bfa53bdf0d5b5dc1a6153fa32e88a3616e927d3",
19
+ "verticals/solar.json": "a2c0079ead33360dd6c0449a4799b31913956cd2b25b69d952d4bf1f708744cb"
20
+ }
21
+ }
@@ -0,0 +1,24 @@
1
+ """Standalone LCP SDK for Python integrations."""
2
+
3
+ from .client import LCPClient, LCPHTTPError
4
+ from .envelope import build_envelope, message_metadata, utc_timestamp
5
+ from .signing import SignatureError, canonical_signing_bytes, sign_hmac, verify_hmac
6
+ from .validation import SchemaValidator, ValidationError
7
+ from .webhook import verify_http_request
8
+
9
+ __all__ = [
10
+ "LCPClient",
11
+ "LCPHTTPError",
12
+ "SchemaValidator",
13
+ "SignatureError",
14
+ "ValidationError",
15
+ "build_envelope",
16
+ "canonical_signing_bytes",
17
+ "message_metadata",
18
+ "sign_hmac",
19
+ "utc_timestamp",
20
+ "verify_hmac",
21
+ "verify_http_request",
22
+ ]
23
+
24
+ __version__ = "1.0.0"
@@ -0,0 +1,163 @@
1
+ """HTTP client for LCP-compatible endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ from .envelope import message_metadata, utc_timestamp
13
+ from .signing import sign_hmac
14
+ from .validation import SchemaValidator
15
+
16
+
17
+ class LCPHTTPError(RuntimeError):
18
+ """Raised when an LCP endpoint returns a non-success response."""
19
+
20
+ def __init__(self, status_code: int, body: Any):
21
+ self.status_code = status_code
22
+ self.body = body
23
+ super().__init__(f"LCP HTTP {status_code}: {body}")
24
+
25
+
26
+ class LCPClient:
27
+ """Small, dependency-light client for the LCP HTTP binding."""
28
+
29
+ def __init__(
30
+ self,
31
+ endpoint: str,
32
+ *,
33
+ sender_id: str | None = None,
34
+ api_key: str | None = None,
35
+ hmac_secret: str | None = None,
36
+ timeout: float = 30.0,
37
+ max_retries: int = 2,
38
+ validator: SchemaValidator | None = None,
39
+ ) -> None:
40
+ self.endpoint = endpoint.rstrip("/")
41
+ self.sender_id = sender_id
42
+ self.api_key = api_key
43
+ self.hmac_secret = hmac_secret
44
+ self.timeout = timeout
45
+ self.max_retries = max(0, max_retries)
46
+ self.validator = validator
47
+
48
+ def _headers(
49
+ self,
50
+ body: bytes,
51
+ *,
52
+ idempotency_key: str | None,
53
+ test: bool = False,
54
+ ) -> dict[str, str]:
55
+ headers = {
56
+ "Content-Type": "application/json",
57
+ "Accept": "application/json",
58
+ }
59
+ if self.sender_id:
60
+ headers["X-LCP-Sender-Id"] = self.sender_id
61
+ if self.api_key:
62
+ headers["Authorization"] = f"Bearer {self.api_key}"
63
+ if self.hmac_secret:
64
+ timestamp = utc_timestamp()
65
+ headers["X-LCP-Timestamp"] = timestamp
66
+ if idempotency_key:
67
+ headers["X-LCP-Idempotency-Key"] = idempotency_key
68
+ headers["X-LCP-Signature"] = sign_hmac(
69
+ self.hmac_secret, timestamp, idempotency_key, body
70
+ )
71
+ elif idempotency_key:
72
+ headers["X-LCP-Idempotency-Key"] = idempotency_key
73
+ if test:
74
+ headers["X-LCP-Test"] = "true"
75
+ return headers
76
+
77
+ def request(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ *,
82
+ payload: dict[str, Any] | None = None,
83
+ params: dict[str, Any] | None = None,
84
+ idempotency_key: str | None = None,
85
+ test: bool = False,
86
+ ) -> dict[str, Any]:
87
+ body = b""
88
+ if payload is not None:
89
+ body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
90
+ "utf-8"
91
+ )
92
+ if idempotency_key is None:
93
+ try:
94
+ idempotency_key = message_metadata(payload)["idempotency_key"]
95
+ except (KeyError, ValueError):
96
+ pass
97
+ headers = self._headers(body, idempotency_key=idempotency_key, test=test)
98
+ url = f"{self.endpoint}/{path.lstrip('/')}"
99
+ retryable = method.upper() in {"GET", "HEAD", "PUT", "DELETE", "POST"}
100
+ last_error: Exception | None = None
101
+
102
+ for attempt in range(self.max_retries + 1):
103
+ try:
104
+ with httpx.Client(timeout=self.timeout) as client:
105
+ response = client.request(
106
+ method,
107
+ url,
108
+ content=body or None,
109
+ headers=headers,
110
+ params=params,
111
+ )
112
+ except httpx.RequestError as exc:
113
+ last_error = exc
114
+ if not retryable or attempt >= self.max_retries:
115
+ raise
116
+ time.sleep(2**attempt)
117
+ continue
118
+
119
+ try:
120
+ data = response.json()
121
+ except ValueError:
122
+ data = {"raw": response.text}
123
+ if response.is_success:
124
+ return data
125
+ if response.status_code in {429, 500, 502, 503, 504} and attempt < self.max_retries:
126
+ retry_after = response.headers.get("Retry-After")
127
+ delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
128
+ time.sleep(delay)
129
+ continue
130
+ raise LCPHTTPError(response.status_code, data)
131
+
132
+ if last_error:
133
+ raise last_error
134
+ raise RuntimeError("LCP request failed without a response")
135
+
136
+ def submit_lead(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
137
+ self._validate(envelope)
138
+ return self.request("POST", "/v1/lcp/leads", payload=envelope, test=test)
139
+
140
+ def submit_call(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
141
+ self._validate(envelope)
142
+ return self.request("POST", "/v1/lcp/calls", payload=envelope, test=test)
143
+
144
+ def submit_bid(self, envelope: dict[str, Any], *, test: bool = False) -> dict[str, Any]:
145
+ self._validate(envelope)
146
+ return self.request("POST", "/v1/lcp/bids", payload=envelope, test=test)
147
+
148
+ def query_lead_status(self, lead_id: str) -> dict[str, Any]:
149
+ return self.request("GET", f"/v1/lcp/leads/{quote(lead_id, safe='')}")
150
+
151
+ def get_schema(self, name: str) -> dict[str, Any]:
152
+ return self.request("GET", f"/v1/lcp/schemas/{quote(name, safe='/')}")
153
+
154
+ def get_capabilities(self) -> dict[str, Any]:
155
+ return self.request("GET", "/v1/lcp/capabilities")
156
+
157
+ def list_offers(self, vertical: str | None = None) -> dict[str, Any]:
158
+ params = {"vertical": vertical} if vertical else None
159
+ return self.request("GET", "/v1/lcp/offers", params=params)
160
+
161
+ def _validate(self, envelope: dict[str, Any]) -> None:
162
+ if self.validator:
163
+ self.validator.require_valid_envelope(envelope)
@@ -0,0 +1,54 @@
1
+ """Helpers for constructing LCP envelopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+
10
+ def utc_timestamp() -> str:
11
+ """Return the current UTC time in the canonical LCP format."""
12
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
13
+
14
+
15
+ def build_envelope(
16
+ message_type: str,
17
+ sender_id: str,
18
+ receiver_id: str,
19
+ payload: dict[str, Any],
20
+ *,
21
+ version: str = "1.0.0",
22
+ message_id: str | None = None,
23
+ correlation_id: str | None = None,
24
+ idempotency_key: str | None = None,
25
+ timestamp: str | None = None,
26
+ test: bool = False,
27
+ ) -> dict[str, Any]:
28
+ """Build a complete LCP envelope around a message payload."""
29
+ message_id = message_id or str(uuid4())
30
+ idempotency_key = idempotency_key or f"{sender_id}-{message_type}-{uuid4().hex}"
31
+ return {
32
+ "lcp": {
33
+ "version": version,
34
+ "message": {
35
+ "id": message_id,
36
+ "type": message_type,
37
+ "timestamp": timestamp or utc_timestamp(),
38
+ "sender_id": sender_id,
39
+ "receiver_id": receiver_id,
40
+ "correlation_id": correlation_id,
41
+ "idempotency_key": idempotency_key,
42
+ "test": test,
43
+ },
44
+ "payload": payload,
45
+ }
46
+ }
47
+
48
+
49
+ def message_metadata(envelope: dict[str, Any]) -> dict[str, Any]:
50
+ """Return the message header or raise for a malformed envelope."""
51
+ try:
52
+ return envelope["lcp"]["message"]
53
+ except (KeyError, TypeError) as exc:
54
+ raise ValueError("Envelope must contain lcp.message") from exc