Skip to content

Commit ff8366c

Browse files
committed
Merge main into develop: bring 1.4.0 + 1.4.1 fixes (1.4.2)
main has shipped two fixes that develop never received: - 1.4.0: MCP multi-account routing (silent matching[0] -> explicit error with candidate list; account_id/profile_id params on posts_create / posts_publish_now / posts_cross_post; accounts_get same fix) - 1.4.1: autogenerated MCP tools no longer collapse OpenAPI array/object schemas to str; $ref/allOf/single-type oneOf|anyOf wrappers are dereferenced before reading the type Without this merge, develop's next OpenAPI auto-regen would have overwritten generated_tools.py against a buggy generator, silently reintroducing the customer-facing bugs (Hein's Google Ads keywords 'Expected array, received string', and Pau's multi-account silent-routing). Conflict resolution: - generated SDK files (resources/_generated/*, generated_tools.py, uv.lock): kept develop's newer content - pyproject.toml: bumped to 1.4.2 so a future develop -> main merge publishes a sensible version - scripts/generate_mcp_tools.py: hand-merged develop's param + tool_name dedupe with main's _resolve_schema / list-dict typing / createPost-unblocked. Both fixes now coexist - mcp/server.py + mcp/tool_definitions.py + tests/test_resolve_account.py: took main's (multi-account fix) - CHANGELOG: added 1.4.2 entry above the existing 1.4.1 / 1.4.0 entries generated_tools.py was regenerated against develop's spec with the merged generator. ads_create_standalone_ad now correctly types all 12 complex fields (4 from the customer report + 8 newer ones from develop's spec). 166 tests pass.
2 parents 207ba2b + 2e6572a commit ff8366c

29 files changed

Lines changed: 2365 additions & 4019 deletions

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.4.2]
11+
12+
### Fixed
13+
- Merged the multi-account routing fix (1.4.0, originally landed on `main`) and the array/object schema fix (1.4.1, also `main`) into the `develop` branch so the next OpenAPI auto-regen doesn't reintroduce the bugs. Develop now has both fixes applied on top of its accumulated auto-regen content (new endpoints up through v1.3.120). No new behavioural changes beyond 1.4.1.
14+
- Generator now combines `develop`'s param/tool-name dedupe (prevents duplicate-kwarg SyntaxErrors and F811 lint failures) with `main`'s `$ref`/`allOf` schema resolution and `list[T]`/`dict[str, Any]` typing. With develop's newer OpenAPI spec, this means newer fields on `ads_create_standalone_ad` (`cities`, `regions`, `creatives`, `images`, `video`, `brand_identity`, `promoted_object`) are now properly typed alongside the original four (`keywords`, `countries`, `additional_headlines`, `additional_descriptions`).
15+
16+
## [1.4.1]
17+
18+
### Fixed
19+
- **Autogenerated MCP tools no longer collapse `array` and `object` schemas to `str`.** Fields like `keywords`, `countries`, `additional_headlines`, `additional_descriptions` on `ads_create_standalone_ad` (and the equivalents across every autogenerated tool) were declared as `str` and forwarded straight into the SDK, which then rejected the request with `Expected array, received string`. They now use the correct Python types (`list[str] | None`, `list[dict[str, Any]] | None`, `dict[str, Any] | None`) so FastMCP advertises the right shape to LLMs and the SDK receives native containers.
20+
- The generator now dereferences `$ref`, `allOf`, and same-type `oneOf`/`anyOf` wrappers when reading a schema's type. Without this, properties like `posts_create_post.tiktokSettings`, `posts_create_post.facebookSettings`, `posts_create_post.recycling`, and array-of-$ref fields like `createWhatsAppTemplate.components`, `createQueueSlot.slots`, `sendConversions.events`, `updateGoogleBusinessFoodMenus.menus`, and `sendPrivateReplyToComment.buttons` would still have leaked through as `str`.
21+
22+
## [1.4.0]
23+
24+
### Fixed
25+
- **MCP multi-account routing (silent-wrong-account bug).** `posts_create`, `posts_publish_now`, `posts_cross_post`, and `accounts_get` previously fell back to the first matching account (`matching[0]`) when the user had more than one account on the requested platform. Agency / multi-client setups could see posts land on the wrong account with no warning. These tools now return an explicit error listing the candidate account IDs when the selection is ambiguous, and require the caller to disambiguate.
26+
27+
### Added
28+
- `account_id` and `profile_id` parameters on `posts_create` and `posts_publish_now` for explicit target selection.
29+
- `account_ids` (parallel to `platforms`) and `profile_id` parameters on `posts_cross_post`. Repeating a platform with different IDs is supported (e.g. cross-posting to two Twitter accounts in one call).
30+
- `posts_create_post` exposed as an autogenerated tool alongside the simplified `posts_create`. It mirrors the full REST `createPost` surface (nested `platforms` array with `customContent`, `customMedia`, `scheduledFor`, `platformSpecificData`) for power users who need per-target customisation.
31+
- Top-level multi-account workflow guidance in the MCP server instructions so LLMs learn the disambiguation flow once globally.
32+
- Unit tests for the shared `_resolve_account` helper covering account-id, profile-id, ambiguous, and not-found paths.
33+
34+
## [Older Unreleased entries]
35+
1036
### Added
1137
- MCP HTTP server now exposes the modern **Streamable HTTP** transport at `POST /mcp` (in addition to the existing SSE transport at `GET /sse`). Streamable HTTP is recommended for new clients (Claude Code, `mcp-remote`, etc.) because it is not affected by the idle-connection drops that affect long-lived SSE connections behind proxies.
1238
- Streamable HTTP runs in stateless mode — each request is fully independent and authenticated separately via the `Authorization: Bearer` header.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "zernio-sdk"
3-
version = "1.3.120"
3+
version = "1.4.2"
44
description = "The official Python library for the Zernio API"
55
readme = "README.md"
66
requires-python = ">=3.10"

‎scripts/generate_mcp_tools.py‎

Lines changed: 134 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@
6060
"deleteUser",
6161
"deleteTeam",
6262
# Already have custom implementations
63-
"createPost",
63+
# Note: createPost is intentionally NOT skipped anymore. The simplified
64+
# posts_create wrapper in tool_definitions.py is friendlier for single-
65+
# account flows, but power users (agencies cross-posting with per-target
66+
# customContent / scheduledFor / platformSpecificData) need the full
67+
# nested-array form. Both are exposed; LLMs pick whichever fits.
6468
"retryPost",
6569
"generateMediaUploadToken",
6670
"checkMediaUploadToken",
@@ -75,11 +79,101 @@ def camel_to_snake(name: str) -> str:
7579
return name.lower()
7680

7781

78-
def get_python_type(schema: dict[str, Any], required: bool = True) -> tuple[str, str]:
79-
"""Convert OpenAPI schema to Python type and default value."""
82+
def _resolve_ref(ref: str, spec: dict[str, Any]) -> dict[str, Any]:
83+
"""Resolve a `#/components/schemas/Foo` style JSON-pointer against spec.
84+
85+
Returns an empty dict if any segment is missing, so callers can keep
86+
walking without raising on a malformed/external ref.
87+
"""
88+
parts = ref.lstrip("#/").split("/")
89+
node: Any = spec
90+
for p in parts:
91+
if not isinstance(node, dict):
92+
return {}
93+
node = node.get(p, {})
94+
return node if isinstance(node, dict) else {}
95+
96+
97+
def _resolve_schema(schema: dict[str, Any], spec: dict[str, Any] | None) -> dict[str, Any]:
98+
"""Dereference a property schema into something with a concrete `type`.
99+
100+
Handles three composite forms commonly used in our OpenAPI spec:
101+
102+
- `{"$ref": "..."}` -> look up the target schema.
103+
- `{"allOf": [{...}, ...]}` -> return the first branch with a concrete
104+
`type` (we mostly see single-element allOf as a "ref + description"
105+
pattern, but this generalises safely).
106+
- `{"oneOf"|"anyOf": [...]}` -> if every branch resolves to the same
107+
scalar/object/array `type`, return that branch; otherwise leave the
108+
schema alone so the caller falls back to `str`. A deliberately
109+
over-broad type is worse than no type for the LLM.
110+
111+
Without spec access (spec=None) the function is a no-op so unit tests
112+
that don't load the full spec still work.
113+
"""
114+
if not schema or not isinstance(schema, dict) or spec is None:
115+
return schema
116+
117+
if "$ref" in schema:
118+
return _resolve_schema(_resolve_ref(schema["$ref"], spec), spec)
119+
120+
if "allOf" in schema and isinstance(schema["allOf"], list):
121+
for branch in schema["allOf"]:
122+
resolved = _resolve_schema(branch, spec)
123+
if resolved.get("type") in ("string", "integer", "number", "boolean", "array", "object"):
124+
return resolved
125+
126+
for combinator in ("oneOf", "anyOf"):
127+
if combinator in schema and isinstance(schema[combinator], list):
128+
resolved_branches = [_resolve_schema(b, spec) for b in schema[combinator]]
129+
types = {b.get("type") for b in resolved_branches if isinstance(b, dict)}
130+
types.discard(None)
131+
if len(types) == 1:
132+
only = types.pop()
133+
for b in resolved_branches:
134+
if b.get("type") == only:
135+
return b
136+
137+
return schema
138+
139+
140+
def get_python_type(
141+
schema: dict[str, Any],
142+
required: bool = True,
143+
spec: dict[str, Any] | None = None,
144+
) -> tuple[str, str]:
145+
"""Convert OpenAPI schema to a Python type annotation and default literal.
146+
147+
For scalar types (string/integer/number/boolean) we map to the obvious
148+
Python type with a sensible default ('', 0, 0.0, False).
149+
150+
For complex types (array / object) we emit proper container types with a
151+
`None` default. Previously these were collapsed to `str = ""` with the
152+
intent that callers would pass comma-separated strings, but the
153+
generated wrapper never parsed them back - it forwarded the literal
154+
string straight into the SDK, which then sent it to the API expecting
155+
an array/object and got rejected with `Expected array, received string`.
156+
157+
Emitting the correct container type (e.g. `list[str] | None`) means:
158+
- FastMCP's JSON-schema introspection now declares the right shape
159+
to the LLM, so the model passes a real array.
160+
- The SDK's `_build_payload` already filters `None` values, so leaving
161+
an unused param at `None` is a no-op upstream.
162+
163+
Item types for arrays are inferred from `items.type`; $ref/allOf wrappers
164+
on either the top-level schema or the items schema are dereferenced via
165+
`_resolve_schema`. Unknown item types fall back to `Any`.
166+
"""
80167
if not schema:
81168
return "str", '""'
82169

170+
# Dereference $ref / allOf / single-type oneOf|anyOf so we can read the
171+
# underlying `type` consistently. Without this, properties like
172+
# `createPost.tiktokSettings` (a $ref to an object schema) would fall
173+
# through to the `str` branch and the wrapper would forward a string
174+
# where the API expects an object.
175+
schema = _resolve_schema(schema, spec)
176+
83177
schema_type = schema.get("type")
84178
default = schema.get("default")
85179

@@ -96,8 +190,30 @@ def get_python_type(schema: dict[str, Any], required: bool = True) -> tuple[str,
96190
type_str = "bool"
97191
default_str = str(default) if default is not None else "False"
98192
elif schema_type == "array":
99-
type_str = "str" # Accept comma-separated
100-
default_str = '""'
193+
# Inspect items.type so the LLM gets the right inner schema. Most
194+
# array fields in the spec hold strings (countries, keywords, etc.)
195+
# or objects (cities, interests, creatives, plus any $ref-wrapped
196+
# object). Items themselves may be $ref/allOf/oneOf so resolve
197+
# before reading the type.
198+
items_schema = _resolve_schema(schema.get("items", {}) or {}, spec)
199+
items_type = items_schema.get("type")
200+
if items_type == "string":
201+
inner = "str"
202+
elif items_type == "integer":
203+
inner = "int"
204+
elif items_type == "number":
205+
inner = "float"
206+
elif items_type == "boolean":
207+
inner = "bool"
208+
elif items_type == "object":
209+
inner = "dict[str, Any]"
210+
else:
211+
inner = "Any"
212+
type_str = f"list[{inner}] | None"
213+
default_str = "None"
214+
elif schema_type == "object":
215+
type_str = "dict[str, Any] | None"
216+
default_str = "None"
101217
else:
102218
type_str = "str"
103219
default_str = '""'
@@ -107,16 +223,22 @@ def get_python_type(schema: dict[str, Any], required: bool = True) -> tuple[str,
107223
return type_str, ""
108224

109225

110-
def extract_parameters(operation: dict[str, Any]) -> list[dict[str, Any]]:
226+
def extract_parameters(
227+
operation: dict[str, Any],
228+
spec: dict[str, Any] | None = None,
229+
) -> list[dict[str, Any]]:
111230
"""Extract parameters from operation.
112231
232+
`spec` is threaded through so `get_python_type` can dereference
233+
`$ref`/`allOf` schemas to read the underlying type.
234+
113235
Dedupes by Python name: when the same parameter appears in both the path
114236
and the request body (e.g. `accountId` as a path param and also in the
115237
body schema), Python can't accept duplicate kwargs in a function
116238
signature, so we keep the first occurrence (path > query > body) and
117239
drop subsequent ones. This prevents SyntaxErrors in generated_tools.py.
118240
"""
119-
params = []
241+
params: list[dict[str, Any]] = []
120242
seen_names: set[str] = set()
121243

122244
def add_param(entry: dict[str, Any]) -> None:
@@ -164,7 +286,8 @@ def add_param(entry: dict[str, Any]) -> None:
164286

165287
type_str, default_str = get_python_type(
166288
param.get("schema", {}),
167-
param.get("required", False)
289+
param.get("required", False),
290+
spec,
168291
)
169292

170293
add_param({
@@ -193,7 +316,7 @@ def add_param(entry: dict[str, Any]) -> None:
193316
if not py_name: # Was just "_"
194317
continue
195318
is_required = prop_name in required_props
196-
type_str, default_str = get_python_type(prop_schema, is_required)
319+
type_str, default_str = get_python_type(prop_schema, is_required, spec)
197320

198321
add_param({
199322
"name": py_name,
@@ -285,6 +408,7 @@ def main() -> int:
285408

286409
# Collect operations
287410
operations = []
411+
288412
# Track tool_names already emitted. Two different (path, method) pairs can
289413
# produce the same tool_name when their operationIds snake-case to the
290414
# same string, or when the spec has a duplicate operation. Either way,
@@ -324,7 +448,7 @@ def main() -> int:
324448
"resource": resource,
325449
"sdk_method": sdk_method,
326450
"summary": operation.get("summary", operation_id),
327-
"params": extract_parameters(operation),
451+
"params": extract_parameters(operation, spec),
328452
})
329453

330454
# Generate output file

‎scripts/generate_resources.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,11 +360,17 @@ def generate_resource_class(
360360
" self._client = client",
361361
"",
362362
" def _build_params(self, **kwargs: Any) -> dict[str, Any]:",
363-
' """Build query parameters, filtering None values."""',
363+
' """Build query parameters, filtering None and empty-string values.',
364+
"",
365+
" Empty strings are filtered because MCP tool wrappers pass ``\"\"`` as the",
366+
" default for optional string args, and the API rejects empty query",
367+
" values (e.g. ``platform=``) with a 400. Filtering here keeps both direct",
368+
" SDK callers and MCP tool callers safe.",
369+
' """',
364370
" def to_camel(s: str) -> str:",
365371
' parts = s.split("_")',
366372
' return parts[0] + "".join(p.title() for p in parts[1:])',
367-
" return {to_camel(k): v for k, v in kwargs.items() if v is not None}",
373+
' return {to_camel(k): v for k, v in kwargs.items() if v is not None and v != ""}',
368374
"",
369375
" def _build_payload(self, **kwargs: Any) -> dict[str, Any]:",
370376
' """Build request payload, filtering None values."""',

0 commit comments

Comments
 (0)