Skip to content

[Proposal]: Non-codegen API boundary #26

@devdupont

Description

@devdupont

Summary

The codegen aspect of the SDK is a really good idea to keep up with the spec details. It works well with the files inside of ucp_sdk.models.schemas.shopping. However, the results in ucp, capability, payment_handler, and service end up being difficult to work with as a developer using and building the validated models.

While there are some different reasons for this, the main one appears to be an overuse of RootModel (and a reliance on redundant BaseModels). This could be improved by limiting RootModel usage with specific Pydantic features.

Motivation

As the SDK exists in v0.3.0 (2026-01-23), the models work with UcpMetadata.model_validate(data) and .model_dump when working with text io. However, the validated models end up constantly referring to my_model.root when used in the code. RootModel was not intended to be used as a JSON/BaseModel replacement. It’s specifically meant to model non-JSON data types when used as input/output models in applications like FastAPI. This isn't necessarily an issue on its own, but it get's combined with the constant type unions used to define the RootModels that Pydantic interprets in a first-come-first-served fashion.

If the codebase (like a FastAPI server) uses UcpMetadata as an input or output model, the dev is required to use isinstance checks on the validated model to see which internal model Pydantic assigned. Because these sub-models have identical key-value fields in the BaseModel spec (like payment_handler.BusinessSchema and business_handler.ResponseSchema), the dev could easily be in a position where a Response-based flow checks for isinstance(model.root.capabilities[0], ResponseSchema) and fail because Pydantic assigned it to the identical BusinessSchema instead.

There are smaller cases of RootModel usage that are less impactful but can still cause issues. For example, using RootModel for Version and ReverseDomainName can cause a FastAPI server to export:

{
    "version": "root='2026-01-23'",
    "services": {
        "root='dev.ucp.shopping'": [
            {
                "version": "root='2026-01-23'",
                "spec": "https://ucp.dev/2026-01-23/specification/service",

This is an example where RootModel would be the correct decision if Version and ReverseDomainName were meant to be used as FastAPI input/output models, but instead the SDK uses them as field validators causing this issue.

Goals

  • Prevent Pydantic validation from assigning to identical model definitions
  • Fix root= prefixes appearing in FastAPI output
  • Provide usage example(s) in library README
  • Initial integration test suite to validate across codegen runs

Non-Goals

No response

Detailed Design

First, RootModel should not be used in the context of this SDK. Since the models contained in the SDK are all JSON objects according to the spec, BaseModel is sufficient for modeling purposes. This on its own removes the .root and root= handling.

It could be as simple as replacing the RootModel.jinja2 template with one for BaseModel or to fold the definition into the existing BaseModel creation in the Python script.

Second, for items like Version and ReverseDomainName used as field validators, this can be implemented using Annotated fields instead.

from typing import Annotated
from pydantic import BaseModel, Field

# Can be defined once as annotated types and reused / imported elsewhere
Version = Annotated[str, Field(pattern="^\\d{4}-\\d{2}-\\d{2}$")]
ReverseDomainName = Annotated[str, Field(pattern="^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$")]

# Can be used just like the RootModel versions are now
class Metadata(BaseModel):
    version: Version
    services: dict[ReverseDomainName, list[dict]]

These type definitions could exist in their own file for import elsewhere. This is most notable for a field like Version which has multiple copies in each file the validator is found in. This redundant definition makes it harder to work with when client libraries create a high-level ...ucp.Version instance and have to keep redefining it for each submode’s unique definition of Version.

Third, the high level models like UcpMetadata (models which are much more likely to be accessed by end users developing UCP servers) should have explicit model definitions. For example, UCP v2026-01-11 had a model for UcpDiscoveryProfile. This approach made it much easier for a developer to implement with libraries like FastAPI because the UcpDiscoveryProfile only referenced the .capabilities model that the profile actually used instead of acting as a UCP meta catch-all for the other types like Checkout and Order responses.

This particular approach may benefit from explicit model definitions of templates to guarantee a solid and expected end-user API while letting the rest of the BaseModel generation handle submodes like all of the shopping stubs. The existing duplication of Base/Platform/Business models would be replaced by Base and child classes with a traditional class hierarchy.

One-hot model definitions like service.PlatformSchema (models 1 through 7 ???) would also improve from this touch as well. This is a much clearer model for development/implementation:

from typing import Literal
from pydantic import BaseModel, HttpUrl
from cup_sdk.models.schemas.types import SchemaUrl, Version

class ServiceBase(BaseModel):
    version: Version
    spec: HttpUrl
    schema_: SchemaUrlclass McpService(BaseService):
    transport: Literal[“mcp”]
    endpoint: HttpUrl

class EmbeddedService(BaseService):
    transport: Literal[“embedded”]

Risks and Mitigations

Since this change is focussed on providing a more consistent runtime model, it should be closing more potential security holes than it opens. In theory, there should be a slight improvement to performance by limiting the number of potential submodels that Pydantic needs to validate against before a potential match or throwing a ValidationError.

Backward compatibility should be straightforward since the 0.3.0 version of the library really only supports the .model_validate approach. That should be the initial test case to validate backwards compatibility (will list in test below).

However, this feature request does add some additional complexity to the current codegen-only approach. However, I think it’s best to add this kind of complexity (for a consistent API boundary) earlier on while the UCP spec is being developed.

Test Plan

Since there currently isn’t a test suite for the Python SDK (at least as shown in the code repo), starting with an e2e test against a known JSON payload and UcpMetadata.model_validate should be sufficient to start with for initial development of this feature.

I’d highly recommend adding additional e2e tests for other common dev patterns like FastAPI request/response models and native model construction. For example:

from fastapi import FastAPI
From ucp_sdk
from tests.util import make_ucp_manifest

app = FastAPI()

@app.get(“/.well-known/ucp”)
def ucp_manifest() -> UcpMetadata:  # UCP SDK type used in FastAPI type definition
    return make_ucp_manifest()

@pytest_asyncio.fixture()
async def client() -> AsyncIterator[AsyncClient]:
    """Async server client that handles lifespan and teardown."""
    async with LifespanManager(app):  # noqa SIM117
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as _client:
            try:
                yield _client
            finally:
                await clear(app)  # any necessary clean up between test cases

def test_ucp_manifest(client: httpx.AsyncClient):
    app_manifest: dict = await client.get("/.well-known/ucp”).json()
    test_manifest = make_ucp_manifest()
    # Test validated models
    assert UcpMetadata.model_validate(app_manifest) == test_manifest
    # Test JSON outputs
    assert app_manifest = test_manifest.model_dump()

Graduation Criteria

Working Draft → Candidate:

  • Schema merged and documented (with Working Draft disclaimer).
  • Unit and integration tests are passing.
  • Initial documentation is written.
  • TC majority vote to advance.

Candidate → Stable:

  • Adoption feedback has been collected and addressed.
  • Full documentation and migration guides are published.
  • TC majority vote to advance.

Implementation History

  • [YYYY-MM-DD]: Proposal submitted.
  • [YYYY-MM-DD]: TC approved "Provisional"; capability enters "Working Draft".
  • [YYYY-MM-DD]: TC approved advancement to "Candidate".
  • [YYYY-MM-DD]: TC approved "Implemented"; capability enters "Stable".

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions