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: 1 addition & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6180,7 +6180,7 @@ components:
_id: { type: string }
platform:
type: string
enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, slack, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads, openaiads]
enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, slack, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads, openaiads, sms, phone, rcs]
profileId:
oneOf:
- type: string
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "zernio-sdk"
version = "1.4.507"
version = "1.4.508"
description = "The official Python library for the Zernio API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/late/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
Visibility,
)

__version__ = "1.4.507"
__version__ = "1.4.508"

__all__ = [
# Client
Expand Down
2 changes: 1 addition & 1 deletion src/late/mcp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Server information
SERVICE_NAME = "Zernio MCP Server"
SERVICE_VERSION = "1.2.0"
SERVICE_VERSION = "1.2.1"
# Streamable HTTP is the primary transport (single endpoint, request/response
# with optional chunked streaming — no long-idle connection for proxies or
# bridges like mcp-remote to kill). Legacy SSE is still served for backwards
Expand Down
5 changes: 4 additions & 1 deletion src/late/models/_generated/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2026-08-14T17:14:31+00:00
# timestamp: 2026-08-15T06:14:00+00:00

from __future__ import annotations

Expand Down Expand Up @@ -7071,6 +7071,9 @@ class Platform13(str, Enum):
XADS = "xads"
GOOGLEADS = "googleads"
OPENAIADS = "openaiads"
SMS = "sms"
PHONE = "phone"
RCS = "rcs"


class SocialAccount(BaseModel):
Expand Down
116 changes: 116 additions & 0 deletions tests/test_accounts_phone_platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
Regression tests for zernio-claude-plugin#3: accounts_list failed pydantic
validation whenever the connected-account list contained a `phone` account.

GET /v1/accounts returns every SocialAccount on the profile, including the
telephony platforms (`phone`, `sms`, `rcs`) that back the Phone Numbers
product. The generated SocialAccount platform enum only listed the social and
ads platforms, so `AccountsListResponse.model_validate` raised
`accounts.N.platform ... [type=enum, input_value=phone]` and the MCP core tool
errored while the raw passthrough tool (accounts_list_accounts, untyped dict)
kept working.

These tests drive the real typed SDK path (httpx.MockTransport, no network)
with a payload containing a phone account, exactly the shape that failed in
production.
"""

from __future__ import annotations

import json
from typing import Any

import httpx
import pytest

import late.client.base as client_base
from late import Late
from late.mcp import server as mcp_server
from late.models import AccountsListResponse

PHONE_ACCOUNT = {
"_id": "a-phone",
"platform": "phone",
"profileId": "prof_1",
"username": "+14155551234",
"displayName": "Support line",
"isActive": True,
}

ACCOUNTS_PAYLOAD = {
"accounts": [
{
"_id": "a-ig",
"platform": "instagram",
"profileId": "prof_1",
"username": "acme",
"isActive": True,
},
PHONE_ACCOUNT,
{
"_id": "a-sms",
"platform": "sms",
"profileId": "prof_1",
"username": "+14155550000",
"isActive": True,
},
],
"hasAnalyticsAccess": False,
}


@pytest.fixture()
def client(monkeypatch: pytest.MonkeyPatch) -> Late:
"""Route all SDK HTTP traffic to a fake accounts API and wire up MCP."""

def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET" and request.url.path == "/api/v1/accounts":
return httpx.Response(
200,
content=json.dumps(ACCOUNTS_PAYLOAD),
headers={"Content-Type": "application/json"},
)
return httpx.Response(404, json={"error": f"unexpected: {request.url.path}"})

real_client = httpx.Client

def patched_client(**kwargs: Any) -> httpx.Client:
kwargs["transport"] = httpx.MockTransport(handler)
return real_client(**kwargs)

monkeypatch.setattr(client_base.httpx, "Client", patched_client)
late = Late(api_key="test-key")
monkeypatch.setattr(mcp_server, "_get_client", lambda: late)
return late


@pytest.mark.parametrize("platform", ["phone", "sms", "rcs"])
def test_response_model_accepts_telephony_platforms(platform: str) -> None:
payload = {
"accounts": [{**PHONE_ACCOUNT, "platform": platform}],
"hasAnalyticsAccess": False,
}
response = AccountsListResponse.model_validate(payload)
assert response.accounts[0].platform.value == platform


def test_typed_list_preserves_phone_account_fields(client: Late) -> None:
response = client.accounts.list()

assert isinstance(response, AccountsListResponse)
assert len(response.accounts) == 3
phone = next(a for a in response.accounts if a.platform.value == "phone")
assert phone.field_id == "a-phone"
assert phone.username == "+14155551234"
assert phone.displayName == "Support line"
assert phone.isActive is True


@pytest.mark.usefixtures("client")
def test_mcp_accounts_list_returns_all_accounts() -> None:
result = mcp_server.accounts_list()

assert "validation error" not in result
assert "Found 3 connected account(s)" in result
assert "phone: +14155551234 (ID: a-phone)" in result
assert "instagram: acme (ID: a-ig)" in result
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading