Skip to content

Commit 499aade

Browse files
dougborgclaude
andcommitted
feat(client)!: live-verified spec drift batch + verification harness
Reusable harness (``scripts/spec_drift_verify.py`` + per-issue probe scripts) for running tagged POSTs against a live Katana tenant and cleaning up afterwards. Every test artifact gets an ``SDT-<date>`` prefix and is recorded in ``/tmp/spec-drift-ledger.jsonl``; a single ``cleanup`` subcommand walks the ledger in reverse and deletes everything (idempotent — already-deleted rows are skipped). Spec-drift findings encoded in ``docs/katana-openapi.yaml``: **#737 — CustomFieldDefinition** - ``id``: ``integer`` → ``string, format: uuid`` (per README example, inferred — couldn't live-verify because our API key lacks create permission on this endpoint, but README is authoritative and the shape is universal across Katana custom-field surfaces). - ``field_type``: ``string maxLength: 50`` → new ``CustomFieldType`` enum (``shortText`` / ``number`` / ``singleSelect`` / ``date`` / ``boolean`` / ``url``). Verified via live-API 422 introspection. - ``entity_type``: ``string maxLength: 50`` → new ``CustomFieldEntityType`` enum with **19 values** (vs README's documented 1). Verified via live-API 422 introspection — the enum spans SalesOrder, SalesOrderRow, ManufacturingOrder*, PurchaseOrder*, StockAdjustment*, Production*, Customer, ProductVariant, MaterialVariant, ServiceVariant. - ``CustomFieldDefinition`` no longer extends ``UpdatableEntity`` because the singleton's ``id`` is a UUID string, not the integer ``BaseEntity`` defines for the rest of the API surface. ``created_at`` / ``updated_at`` are inlined. **#736 — variant_bin_locations + CreateServiceVariant.sku** - ``POST /variant_bin_locations`` request body wrapped in array (``minItems: 1, maxItems: 500``). The endpoint description already said "accepts up to 500 variant storage bin objects" but the schema was a single object. Verified via live API: single-object body returns ``422 must be array``. - ``CreateServiceVariantRequest.sku`` is no longer required. The field is nullable. Verified by creating a service variant via the live API with ``sku`` omitted — server accepted it and returned ``sku: null``. **#738 — StockTransferRowRequest.quantity** - Changed from ``number`` to ``string``. Verified via live-API 422 introspection — sending a JSON number returns ``must be string``. Wire format is a fixed-precision decimal string (e.g. ``"1.0000000000"``). MCP boundary in ``stock_transfers._build_row_requests`` stringifies the pydantic ``float`` input at the call site. **#739 — SalesOrderFulfillment** - No spec change needed. Verified ``sales_order_fulfillment_rows`` IS required on create — local spec is correct, README is stale. Findings deferred to follow-ups: - **#734** ``custom_fields`` shape — live API rejects array-shaped custom_fields with ``must be object`` at both SO and SO-row create paths; dict-shape is accepted. Local READ schemas use structured-array shape, which contradicts this. Read-side verification requires a populated example (tenant has zero ``custom_field_definitions`` and our key can't create them). Sweeping all the READ schemas is broader than this PR. - **#736** ``CustomFieldDefinition`` ``options`` shape, ``id`` type via live API, ``UpdateCustomerRequest.default_billing_id``, ``Operator`` fields — all blocked by either authorization (custom_field_definitions endpoint) or absence of populated fixtures on this tenant. BREAKING CHANGE: ``CustomFieldDefinition.id`` becomes a UUID string (was integer); ``field_type`` / ``entity_type`` become enums; ``CreateServiceVariantRequest.sku`` is no longer required (callers omitting it will continue to work, but ``required`` removal is a schema-level breaking change); ``StockTransferRowRequest.quantity`` becomes a string. The MCP ``stock_transfers`` boundary already stringifies the input float so MCP callers don't have to. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa79747 commit 499aade

23 files changed

Lines changed: 1615 additions & 235 deletions

docs/katana-openapi.yaml

Lines changed: 197 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -7624,15 +7624,21 @@ components:
76247624
- field_name: Skill Level
76257625
field_value: Expert
76267626
CreateServiceVariantRequest:
7627-
description: Request payload for creating a service variant with pricing and custom fields
7627+
description: |
7628+
Request payload for creating a service variant with pricing and
7629+
custom fields. ``sku`` is optional — verified against live API:
7630+
omitting it returns a created variant with ``sku: null``.
76287631
type: object
76297632
additionalProperties: false
7630-
required:
7631-
- sku
76327633
properties:
76337634
sku:
7634-
type: string
7635-
description: A unique service code
7635+
type:
7636+
- string
7637+
- "null"
7638+
description: |
7639+
Optional unique service code. May be ``null`` when the
7640+
variant doesn't need a SKU (e.g., a generic labour
7641+
service).
76367642
sales_price:
76377643
type:
76387644
- number
@@ -10650,75 +10656,139 @@ components:
1065010656
- COD
1065110657
created_at: "2024-01-10T11:00:00Z"
1065210658
updated_at: "2024-01-14T09:15:00Z"
10659+
CustomFieldType:
10660+
type: string
10661+
enum:
10662+
- shortText
10663+
- number
10664+
- singleSelect
10665+
- date
10666+
- boolean
10667+
- url
10668+
description: |
10669+
Field input type for a ``CustomFieldDefinition``. Immutable after
10670+
creation — the value determines how Katana renders and validates
10671+
the field's stored values. Confirmed via live-API enum
10672+
introspection on ``POST /custom_field_definitions``.
10673+
10674+
CustomFieldEntityType:
10675+
type: string
10676+
enum:
10677+
- SalesOrder
10678+
- SalesOrderRow
10679+
- SalesRecipeRow
10680+
- SalesOrderFulfillmentRow
10681+
- ManufacturingOrder
10682+
- ManufacturingOrderRecipeRow
10683+
- PurchaseOrder
10684+
- PurchaseOrderRow
10685+
- PurchaseOrderRecipeRow
10686+
- OutsourcedPurchaseOrderRow
10687+
- StockAdjustment
10688+
- StockAdjustmentRow
10689+
- StockTransferRow
10690+
- Production
10691+
- ProductionIngredient
10692+
- Customer
10693+
- ProductVariant
10694+
- MaterialVariant
10695+
- ServiceVariant
10696+
description: |
10697+
Resource type a ``CustomFieldDefinition`` applies to. Immutable
10698+
after creation. Note: many resources accept custom fields even
10699+
though their request DTOs don't explicitly document the
10700+
``custom_fields`` property — the field is universally available
10701+
on the entities listed here. Verified against live-API enum
10702+
introspection.
10703+
1065310704
CustomFieldDefinition:
1065410705
description: |
1065510706
A configured custom field definition that callers can attach to a
1065610707
resource (sales order, service, product, etc.) via the resource's
1065710708
``custom_fields`` property. Definitions are scoped to a specific
1065810709
``entity_type`` and shape what values consumers can store.
10659-
allOf:
10660-
- type: object
10661-
properties:
10662-
id:
10663-
type: integer
10664-
description: Unique identifier for the custom field definition
10665-
label:
10666-
type: string
10667-
maxLength: 255
10668-
description: Display label shown in the Katana UI
10669-
field_type:
10670-
type: string
10671-
maxLength: 50
10672-
description: |
10673-
Field input type (e.g. ``text``, ``number``, ``date``,
10674-
``select``). Drives how Katana renders and validates the
10675-
field's values.
10676-
entity_type:
10677-
type: string
10678-
maxLength: 50
10679-
description: |
10680-
Resource type the definition applies to (matches the
10681-
resource's ``custom_fields`` API field — e.g.
10682-
``sales_order``, ``service``, ``product``).
10683-
source:
10684-
type: string
10685-
maxLength: 255
10686-
description: Origin / namespace of the definition (e.g. ``katana``, ``user``).
10687-
description:
10688-
type:
10689-
- string
10690-
- "null"
10691-
description: Optional long-form description of the field's purpose
10692-
options:
10693-
type:
10694-
- object
10695-
- "null"
10696-
additionalProperties: true
10697-
description: |
10698-
Free-form configuration object — shape varies per
10699-
``field_type`` (e.g., select fields carry the option list
10700-
here).
10701-
required:
10702-
- id
10703-
- label
10704-
- field_type
10705-
- entity_type
10706-
- source
10707-
- $ref: "#/components/schemas/UpdatableEntity"
10710+
10711+
Does **not** extend ``UpdatableEntity`` because the singleton's
10712+
``id`` is a UUID string (not the integer that ``BaseEntity``
10713+
defines for the rest of the API surface) — ``created_at`` /
10714+
``updated_at`` are inlined here instead.
10715+
type: object
10716+
additionalProperties: false
10717+
properties:
10718+
id:
10719+
type: string
10720+
format: uuid
10721+
description: |
10722+
Server-assigned identifier for the custom field
10723+
definition. Returned as a UUID string (e.g.
10724+
``"0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef"``).
10725+
label:
10726+
type: string
10727+
maxLength: 255
10728+
description: Display label shown in the Katana UI
10729+
field_type:
10730+
allOf:
10731+
- $ref: "#/components/schemas/CustomFieldType"
10732+
description: |
10733+
Field input type. Immutable after creation — see
10734+
:class:`CustomFieldType` for the allowed values and the
10735+
value Katana stores for each.
10736+
entity_type:
10737+
allOf:
10738+
- $ref: "#/components/schemas/CustomFieldEntityType"
10739+
description: |
10740+
Resource type the definition applies to. Immutable after
10741+
creation — see :class:`CustomFieldEntityType` for the
10742+
full list.
10743+
source:
10744+
type: string
10745+
maxLength: 255
10746+
description: Origin / namespace of the definition (e.g. ``katana``, ``user``).
10747+
description:
10748+
type:
10749+
- string
10750+
- "null"
10751+
description: Optional long-form description of the field's purpose
10752+
options:
10753+
type:
10754+
- object
10755+
- "null"
10756+
additionalProperties: true
10757+
description: |
10758+
Free-form configuration object — shape varies per
10759+
``field_type`` (e.g., select fields carry the option list
10760+
here).
10761+
created_at:
10762+
type: string
10763+
format: date-time
10764+
description: Timestamp when the definition was created
10765+
updated_at:
10766+
type: string
10767+
format: date-time
10768+
description: Timestamp when the definition was last updated
10769+
required:
10770+
- id
10771+
- label
10772+
- field_type
10773+
- entity_type
10774+
- source
1070810775
example:
10709-
id: 42
10710-
label: Quality Grade
10711-
field_type: select
10712-
entity_type: product
10713-
source: user
10776+
id: "0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef"
10777+
label: Channel
10778+
field_type: singleSelect
10779+
entity_type: SalesOrder
10780+
source: your-integration
1071410781
description: Customer-facing quality classification
1071510782
options:
10716-
values:
10717-
- A
10718-
- B
10719-
- C
10720-
created_at: "2024-01-08T10:00:00Z"
10721-
updated_at: "2024-01-12T15:30:00Z"
10783+
choices:
10784+
- id: 1
10785+
label: Online
10786+
- id: 2
10787+
label: Retail
10788+
- id: 3
10789+
label: Wholesale
10790+
created_at: "2026-05-14T10:00:00Z"
10791+
updated_at: "2026-05-14T10:00:00Z"
1072210792
CreateCustomFieldDefinitionRequest:
1072310793
description: Request payload for creating a new custom field definition.
1072410794
type: object
@@ -10734,17 +10804,25 @@ components:
1073410804
maxLength: 255
1073510805
description: Display label shown in the Katana UI
1073610806
field_type:
10737-
type: string
10738-
maxLength: 50
10739-
description: Field input type (text, number, date, select, etc.)
10807+
allOf:
10808+
- $ref: "#/components/schemas/CustomFieldType"
10809+
description: |
10810+
Field input type. Immutable after creation. The live API
10811+
enforces this enum at validation time (verified via 422
10812+
introspection).
1074010813
entity_type:
10741-
type: string
10742-
maxLength: 50
10743-
description: Resource type the definition applies to
10814+
allOf:
10815+
- $ref: "#/components/schemas/CustomFieldEntityType"
10816+
description: |
10817+
Resource type the definition applies to. Immutable after
10818+
creation. The live API enforces this enum at validation
10819+
time (verified via 422 introspection — 19 allowed values).
1074410820
source:
1074510821
type: string
1074610822
maxLength: 255
10747-
description: Origin / namespace of the definition
10823+
description: |
10824+
Caller-provided integration identifier — used to namespace
10825+
and audit field definitions.
1074810826
description:
1074910827
type:
1075010828
- string
@@ -10755,18 +10833,24 @@ components:
1075510833
- object
1075610834
- "null"
1075710835
additionalProperties: true
10758-
description: Free-form configuration object — shape varies per ``field_type``
10836+
description: |
10837+
Only meaningful when ``field_type`` is ``singleSelect``. Omit
10838+
(or send ``null``) for other types. On create, send choices
10839+
as ``{choices: [{label: ...}]}``; the server assigns each one
10840+
an integer ``id`` and returns the resolved array in the
10841+
response (use that ``id`` when setting a value on a sales
10842+
order).
1075910843
example:
10760-
label: Quality Grade
10761-
field_type: select
10762-
entity_type: product
10763-
source: user
10764-
description: Customer-facing quality classification
10844+
label: Channel
10845+
field_type: singleSelect
10846+
entity_type: SalesOrder
10847+
source: your-integration
10848+
description: Customer-facing sales channel classification
1076510849
options:
10766-
values:
10767-
- A
10768-
- B
10769-
- C
10850+
choices:
10851+
- label: Online
10852+
- label: Retail
10853+
- label: Wholesale
1077010854
UpdateCustomFieldDefinitionRequest:
1077110855
description: Request payload for updating an existing custom field definition.
1077210856
type: object
@@ -10801,19 +10885,22 @@ components:
1080110885
$ref: "#/components/schemas/CustomFieldDefinition"
1080210886
example:
1080310887
data:
10804-
- id: 42
10805-
label: Quality Grade
10806-
field_type: select
10807-
entity_type: product
10808-
source: user
10809-
description: Customer-facing quality classification
10888+
- id: "0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef"
10889+
label: Channel
10890+
field_type: singleSelect
10891+
entity_type: SalesOrder
10892+
source: your-integration
10893+
description: Customer-facing sales channel classification
1081010894
options:
10811-
values:
10812-
- A
10813-
- B
10814-
- C
10815-
created_at: "2024-01-08T10:00:00Z"
10816-
updated_at: "2024-01-12T15:30:00Z"
10895+
choices:
10896+
- id: 1
10897+
label: Online
10898+
- id: 2
10899+
label: Retail
10900+
- id: 3
10901+
label: Wholesale
10902+
created_at: "2026-05-14T10:00:00Z"
10903+
updated_at: "2026-05-14T10:00:00Z"
1081710904
Stocktake:
1081810905
description: Physical inventory count process for reconciling actual stock levels with system records
1081910906
allOf:
@@ -12739,8 +12826,12 @@ components:
1273912826
type: integer
1274012827
description: Product variant ID
1274112828
quantity:
12742-
type: number
12743-
description: Quantity to transfer
12829+
type: string
12830+
description: |
12831+
Quantity to transfer. Sent as a fixed-precision decimal
12832+
string on the wire (e.g. ``"1.0000000000"``). Verified via
12833+
422 introspection — sending a JSON number returns
12834+
``must be string``.
1274412835
CreateStockTransferRequest:
1274512836
description: Request payload for creating a new stock transfer
1274612837
type: object
@@ -17245,14 +17336,23 @@ paths:
1724517336
This endpoint can also be used for changing existing links of the variants to different storage bins.
1724617337

1724717338
The endpoint accepts up to 500 variant storage bin objects.
17339+
17340+
**Request body shape:** the endpoint requires an array of link
17341+
objects, even when linking a single variant. Sending a single
17342+
object (without the array wrapper) returns
17343+
``422 must be array``. Verified via live-API shape probe.
1724817344
operationId: linkVariantDefaultStorageBins
1724917345
requestBody:
1725017346
description: Linked variant default storage bin details
1725117347
required: true
1725217348
content:
1725317349
application/json:
1725417350
schema:
17255-
$ref: "#/components/schemas/VariantDefaultStorageBinLink"
17351+
type: array
17352+
minItems: 1
17353+
maxItems: 500
17354+
items:
17355+
$ref: "#/components/schemas/VariantDefaultStorageBinLink"
1725617356
responses:
1725717357
"200":
1725817358
description: Linked variant default storage bin order

katana_mcp_server/src/katana_mcp/tools/foundation/stock_transfers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,13 @@ def _build_row_requests(
289289
"""Convert pydantic row inputs to attrs request rows."""
290290
out: list[StockTransferRowRequest] = []
291291
for row in rows:
292+
# ``StockTransferRowRequest.quantity`` is a wire-format string
293+
# (fixed-precision decimal) per the spec (#738 verification);
294+
# the MCP input is a pydantic ``float`` for ergonomics, so
295+
# stringify at the boundary here.
292296
api_row = StockTransferRowRequest(
293297
variant_id=row.variant_id,
294-
quantity=row.quantity,
298+
quantity=str(row.quantity),
295299
)
296300
if row.batch_transactions:
297301
api_row.additional_properties = {

0 commit comments

Comments
 (0)