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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **DELETE operations with a request body sent no body.** The generator emitted `_delete(path, params=...)` for every DELETE and dropped the body kwargs, so calls like `remove_campaign_assets(...)` reached the API empty and failed with `invalid_json_body`. DELETE methods now build the payload and send it as JSON (`_delete` / `_adelete` take `data`).
- **MCP tool parameters that take a list of objects (or an object) did not say which keys go inside.** `updates`, `campaigns` and similar were typed `list[dict[str, Any]]` with no keys, so agents had to guess. The parameter description now lists each key with its type, whether it is required and its description, one level of nested object keys included.
- **A POST that timed out client-side could be silently replayed, creating a duplicate live post.** `_request_with_retry` (and its async twin) retried every transient error identically, including `httpx.TimeoutException` on a POST. A timeout only means the client gave up waiting - the server may have finished the request anyway - so replaying it can create a second copy of whatever the first attempt already did. Hit in practice on a `publishNow` create: the server-side publish took 222s against `BaseClient.DEFAULT_TIMEOUT`'s 30s, httpx aborted while the server kept working, and the retry loop fired a second identical POST. The SDK sent no request id, so the server couldn't recognize the replay, and its content-hash dedup answered the replay with a 409 while the original request was still live - so the customer saw a failure for a post that had actually published, retried by hand with a one-character caption change to dodge the dedup, and ended up with two live posts. Two independent fixes: (1) every request now carries an `x-request-id` header, minted once per call and reused across retry attempts, so the server can recognize a replay when one does happen; (2) a POST that times out is no longer retried at all - it raises immediately with a message naming the duplicate-post risk, because the server's content-hash dedup can still race ahead of its idempotency check even with a matching request id. `publishNow` creates also get a much longer timeout (`publish_timeout`, default 300s, configurable on `Zernio(...)`) than the SDK default (`timeout`, default 30s), since a publish-now create runs the whole cross-platform publish synchronously inside the request. PUT, PATCH, and DELETE are unaffected - they're idempotent by contract and stay retryable on timeout. Known gaps, left alone here and tracked for follow-up: 5xx responses are never retried, and `PUT /v1/posts/{id}` with `publishNow` has the same synchronous-publish timeout exposure as create.
- **MCP `accounts_get_follower_stats` returned only the account name, dropping the follower count and daily series.** The shared `_format_response` helper pattern-matches on the response shape, and `FollowerStatsResponse` has an `accounts` attribute, so it fell into the generic account-list branch that prints only `- {platform}: {username}` and silently discarded `currentFollowers`, `growth`, and the daily `stats` series. Hit in practice by a developer pulling LinkedIn org follower stats (data was present server-side: latest count plus a week of daily snapshots), who saw only the account name come back through the tool. `_format_response` now checks for a `stats` attribute (unique to `FollowerStatsResponse` among all response models) BEFORE the generic `accounts` branch and returns the full `model_dump_json(by_alias=True, exclude_none=True)`, so the count, growth, and series reach the LLM losslessly. Fixed in both the emitted `generated_tools.py` and the `generate_mcp_tools.py` template so a future regen keeps it. Two regression tests added in `tests/test_integration.py`. (The related model gap, `FollowerStatsResponse` missing `stats`/`granularity`, was already corrected on `develop` by an earlier OpenAPI regen, so no model change was needed here.)

Expand Down
76 changes: 74 additions & 2 deletions scripts/generate_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,78 @@ def get_python_type(
return type_str, ""


MAX_FIELD_DESCRIPTION_CHARS = 160


def _object_key_names(schema: dict[str, Any], spec: dict[str, Any]) -> str:
if schema.get("type") != "object" and "allOf" not in schema:
return ""
flattened = flatten_request_body_schema(schema, spec)
return ", ".join(flattened["properties"]) if flattened else ""


def _field_type_label(schema: dict[str, Any], spec: dict[str, Any]) -> str:
resolved = _resolve_schema(schema, spec) or {}
values = [v for v in resolved.get("enum") or [] if v is not None]
if values:
return f"one of: {', '.join(str(v) for v in values)}"
schema_type = resolved.get("type")
if schema_type == "array":
items = _resolve_schema(resolved.get("items") or {}, spec) or {}
keys = _object_key_names(items, spec)
return f"list of objects with keys {keys}" if keys else f"list of {items.get('type') or 'any'}"
if schema_type == "object":
keys = _object_key_names(resolved, spec)
return f"object with keys {keys}" if keys else "object"
if isinstance(schema_type, list):
return " or ".join(str(t) for t in schema_type)
return str(schema_type or "any")


def _describe_field(name: str, schema: dict[str, Any], required: bool, spec: dict[str, Any]) -> str:
label = _field_type_label(schema, spec)
if required:
# An enum label already holds commas, so it takes a semicolon.
label += "; required" if label.startswith("one of") else ", required"
description = " ".join(str(schema.get("description") or "").split())
if len(description) > MAX_FIELD_DESCRIPTION_CHARS:
description = description[:MAX_FIELD_DESCRIPTION_CHARS].rsplit(None, 1)[0] + " ..."
return f"{name} ({label})" + (f" - {description}" if description else "")


def describe_nested_fields(schema: dict[str, Any], spec: dict[str, Any]) -> str:
"""The keys an object, or each object in an array, takes. The parameter is
typed as a bare dict, so this text is the only place an agent learns them.
Keys are the wire (camelCase) names: dict values are sent verbatim."""
resolved = _resolve_schema(schema, spec) or {}
if resolved.get("type") == "array":
target = _resolve_schema(resolved.get("items") or {}, spec) or {}
prefix = "Each item is an object with keys"
else:
target = resolved
prefix = "Object with keys"
if target.get("type") != "object" and "allOf" not in target:
return ""
flattened = flatten_request_body_schema(target, spec)
if not flattened or not flattened["properties"]:
return ""
required = set(flattened["required"])
fields = [
_describe_field(name, prop, name in required, spec)
for name, prop in flattened["properties"].items()
if isinstance(prop, dict)
]
return f"{prefix}: {'; '.join(fields)}"


def _with_nested_fields(description: str, schema: dict[str, Any], spec: dict[str, Any] | None) -> str:
nested = _escape_docstring_text(describe_nested_fields(schema, spec or {}))
if not nested:
return description
base = " ".join(description.split())
return f"{base} {nested}" if base else nested


def extract_parameters(
operation: dict[str, Any],
spec: dict[str, Any] | None = None,
Expand Down Expand Up @@ -364,7 +436,7 @@ def add_param(entry: dict[str, Any]) -> None:
"type": type_str,
"required": param.get("required", False),
"default": default_str,
"description": param.get("description", ""),
"description": _with_nested_fields(param.get("description", ""), param.get("schema", {}), spec),
"sdk_name": sdk_name,
})

Expand Down Expand Up @@ -404,7 +476,7 @@ def add_param(entry: dict[str, Any]) -> None:
"type": type_str,
"required": is_required,
"default": default_str,
"description": prop_schema.get("description", ""),
"description": _with_nested_fields(prop_schema.get("description", ""), prop_schema, spec),
"sdk_name": py_name,
})

Expand Down
16 changes: 12 additions & 4 deletions scripts/generate_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ def generate_method_body(
# carry query params (e.g. GBP writes take the payload in the body plus a
# locationId query param). Both must be forwarded, not one or the other.
use_query_params = http_method.upper() in ("GET", "DELETE") and query_params
use_body_params = http_method.upper() in ("POST", "PUT", "PATCH") and body_params
use_body_params = http_method.upper() in ("POST", "PUT", "PATCH", "DELETE") and body_params
use_query_on_post = http_method.upper() in ("POST", "PUT", "PATCH") and query_params
# Only _get and _post accept a `headers` kwarg (see BaseClient); no
# operation on PUT/PATCH/DELETE currently has a header param.
Expand Down Expand Up @@ -352,10 +352,18 @@ def generate_method_body(
f" return {await_prefix}self._client.{client_method}({', '.join(call_args)})"
)
elif http_method.upper() == "DELETE":
# A DELETE may carry a JSON body (e.g. the ids to remove); dropping it
# sent the call with no input and the API answered invalid_json_body.
call_args = [path_expr]
if query_params:
lines.append(f" return {await_prefix}self._client.{client_method}({path_expr}, params=params)")
else:
lines.append(f" return {await_prefix}self._client.{client_method}({path_expr})")
call_args.append("params=params")
if body_params:
call_args.append("data=payload")
elif raw_body_params:
call_args.append(f"data={raw_body_params[0]['name']}")
lines.append(
f" return {await_prefix}self._client.{client_method}({', '.join(call_args)})"
)
else: # POST, PUT, PATCH
call_args = [path_expr]
if body_params:
Expand Down
16 changes: 12 additions & 4 deletions src/late/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,17 @@ def _delete(
self,
path: str,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make a sync DELETE request."""
"""Make a sync DELETE request.

``data`` is sent as the JSON body: some DELETE endpoints take their
input there (e.g. the list of ids to remove), not in the query string.
"""
with self._sync_client() as client:
return self._request_with_retry(client, "DELETE", path, params=params)
return self._request_with_retry(
client, "DELETE", path, params=params, json=data
)

# =========================================================================
# Async Client
Expand Down Expand Up @@ -522,9 +529,10 @@ async def _adelete(
self,
path: str,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make an async DELETE request."""
"""Make an async DELETE request (``data`` is the JSON body, see ``_delete``)."""
async with self._async_client() as client:
return await self._arequest_with_retry(
client, "DELETE", path, params=params
client, "DELETE", path, params=params, json=data
)
Loading
Loading