Skip to content

Commit b8c2cfc

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 96d467e commit b8c2cfc

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
@@ -7632,15 +7632,21 @@ components:
76327632
- field_name: Skill Level
76337633
field_value: Expert
76347634
CreateServiceVariantRequest:
7635-
description: Request payload for creating a service variant with pricing and custom fields
7635+
description: |
7636+
Request payload for creating a service variant with pricing and
7637+
custom fields. ``sku`` is optional — verified against live API:
7638+
omitting it returns a created variant with ``sku: null``.
76367639
type: object
76377640
additionalProperties: false
7638-
required:
7639-
- sku
76407641
properties:
76417642
sku:
7642-
type: string
7643-
description: A unique service code
7643+
type:
7644+
- string
7645+
- "null"
7646+
description: |
7647+
Optional unique service code. May be ``null`` when the
7648+
variant doesn't need a SKU (e.g., a generic labour
7649+
service).
76447650
sales_price:
76457651
type:
76467652
- number
@@ -10658,75 +10664,139 @@ components:
1065810664
- COD
1065910665
created_at: "2024-01-10T11:00:00Z"
1066010666
updated_at: "2024-01-14T09:15:00Z"
10667+
CustomFieldType:
10668+
type: string
10669+
enum:
10670+
- shortText
10671+
- number
10672+
- singleSelect
10673+
- date
10674+
- boolean
10675+
- url
10676+
description: |
10677+
Field input type for a ``CustomFieldDefinition``. Immutable after
10678+
creation — the value determines how Katana renders and validates
10679+
the field's stored values. Confirmed via live-API enum
10680+
introspection on ``POST /custom_field_definitions``.
10681+
10682+
CustomFieldEntityType:
10683+
type: string
10684+
enum:
10685+
- SalesOrder
10686+
- SalesOrderRow
10687+
- SalesRecipeRow
10688+
- SalesOrderFulfillmentRow
10689+
- ManufacturingOrder
10690+
- ManufacturingOrderRecipeRow
10691+
- PurchaseOrder
10692+
- PurchaseOrderRow
10693+
- PurchaseOrderRecipeRow
10694+
- OutsourcedPurchaseOrderRow
10695+
- StockAdjustment
10696+
- StockAdjustmentRow
10697+
- StockTransferRow
10698+
- Production
10699+
- ProductionIngredient
10700+
- Customer
10701+
- ProductVariant
10702+
- MaterialVariant
10703+
- ServiceVariant
10704+
description: |
10705+
Resource type a ``CustomFieldDefinition`` applies to. Immutable
10706+
after creation. Note: many resources accept custom fields even
10707+
though their request DTOs don't explicitly document the
10708+
``custom_fields`` property — the field is universally available
10709+
on the entities listed here. Verified against live-API enum
10710+
introspection.
10711+
1066110712
CustomFieldDefinition:
1066210713
description: |
1066310714
A configured custom field definition that callers can attach to a
1066410715
resource (sales order, service, product, etc.) via the resource's
1066510716
``custom_fields`` property. Definitions are scoped to a specific
1066610717
``entity_type`` and shape what values consumers can store.
10667-
allOf:
10668-
- type: object
10669-
properties:
10670-
id:
10671-
type: integer
10672-
description: Unique identifier for the custom field definition
10673-
label:
10674-
type: string
10675-
maxLength: 255
10676-
description: Display label shown in the Katana UI
10677-
field_type:
10678-
type: string
10679-
maxLength: 50
10680-
description: |
10681-
Field input type (e.g. ``text``, ``number``, ``date``,
10682-
``select``). Drives how Katana renders and validates the
10683-
field's values.
10684-
entity_type:
10685-
type: string
10686-
maxLength: 50
10687-
description: |
10688-
Resource type the definition applies to (matches the
10689-
resource's ``custom_fields`` API field — e.g.
10690-
``sales_order``, ``service``, ``product``).
10691-
source:
10692-
type: string
10693-
maxLength: 255
10694-
description: Origin / namespace of the definition (e.g. ``katana``, ``user``).
10695-
description:
10696-
type:
10697-
- string
10698-
- "null"
10699-
description: Optional long-form description of the field's purpose
10700-
options:
10701-
type:
10702-
- object
10703-
- "null"
10704-
additionalProperties: true
10705-
description: |
10706-
Free-form configuration object — shape varies per
10707-
``field_type`` (e.g., select fields carry the option list
10708-
here).
10709-
required:
10710-
- id
10711-
- label
10712-
- field_type
10713-
- entity_type
10714-
- source
10715-
- $ref: "#/components/schemas/UpdatableEntity"
10718+
10719+
Does **not** extend ``UpdatableEntity`` because the singleton's
10720+
``id`` is a UUID string (not the integer that ``BaseEntity``
10721+
defines for the rest of the API surface) — ``created_at`` /
10722+
``updated_at`` are inlined here instead.
10723+
type: object
10724+
additionalProperties: false
10725+
properties:
10726+
id:
10727+
type: string
10728+
format: uuid
10729+
description: |
10730+
Server-assigned identifier for the custom field
10731+
definition. Returned as a UUID string (e.g.
10732+
``"0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef"``).
10733+
label:
10734+
type: string
10735+
maxLength: 255
10736+
description: Display label shown in the Katana UI
10737+
field_type:
10738+
allOf:
10739+
- $ref: "#/components/schemas/CustomFieldType"
10740+
description: |
10741+
Field input type. Immutable after creation — see
10742+
:class:`CustomFieldType` for the allowed values and the
10743+
value Katana stores for each.
10744+
entity_type:
10745+
allOf:
10746+
- $ref: "#/components/schemas/CustomFieldEntityType"
10747+
description: |
10748+
Resource type the definition applies to. Immutable after
10749+
creation — see :class:`CustomFieldEntityType` for the
10750+
full list.
10751+
source:
10752+
type: string
10753+
maxLength: 255
10754+
description: Origin / namespace of the definition (e.g. ``katana``, ``user``).
10755+
description:
10756+
type:
10757+
- string
10758+
- "null"
10759+
description: Optional long-form description of the field's purpose
10760+
options:
10761+
type:
10762+
- object
10763+
- "null"
10764+
additionalProperties: true
10765+
description: |
10766+
Free-form configuration object — shape varies per
10767+
``field_type`` (e.g., select fields carry the option list
10768+
here).
10769+
created_at:
10770+
type: string
10771+
format: date-time
10772+
description: Timestamp when the definition was created
10773+
updated_at:
10774+
type: string
10775+
format: date-time
10776+
description: Timestamp when the definition was last updated
10777+
required:
10778+
- id
10779+
- label
10780+
- field_type
10781+
- entity_type
10782+
- source
1071610783
example:
10717-
id: 42
10718-
label: Quality Grade
10719-
field_type: select
10720-
entity_type: product
10721-
source: user
10784+
id: "0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef"
10785+
label: Channel
10786+
field_type: singleSelect
10787+
entity_type: SalesOrder
10788+
source: your-integration
1072210789
description: Customer-facing quality classification
1072310790
options:
10724-
values:
10725-
- A
10726-
- B
10727-
- C
10728-
created_at: "2024-01-08T10:00:00Z"
10729-
updated_at: "2024-01-12T15:30:00Z"
10791+
choices:
10792+
- id: 1
10793+
label: Online
10794+
- id: 2
10795+
label: Retail
10796+
- id: 3
10797+
label: Wholesale
10798+
created_at: "2026-05-14T10:00:00Z"
10799+
updated_at: "2026-05-14T10:00:00Z"
1073010800
CreateCustomFieldDefinitionRequest:
1073110801
description: Request payload for creating a new custom field definition.
1073210802
type: object
@@ -10742,17 +10812,25 @@ components:
1074210812
maxLength: 255
1074310813
description: Display label shown in the Katana UI
1074410814
field_type:
10745-
type: string
10746-
maxLength: 50
10747-
description: Field input type (text, number, date, select, etc.)
10815+
allOf:
10816+
- $ref: "#/components/schemas/CustomFieldType"
10817+
description: |
10818+
Field input type. Immutable after creation. The live API
10819+
enforces this enum at validation time (verified via 422
10820+
introspection).
1074810821
entity_type:
10749-
type: string
10750-
maxLength: 50
10751-
description: Resource type the definition applies to
10822+
allOf:
10823+
- $ref: "#/components/schemas/CustomFieldEntityType"
10824+
description: |
10825+
Resource type the definition applies to. Immutable after
10826+
creation. The live API enforces this enum at validation
10827+
time (verified via 422 introspection — 19 allowed values).
1075210828
source:
1075310829
type: string
1075410830
maxLength: 255
10755-
description: Origin / namespace of the definition
10831+
description: |
10832+
Caller-provided integration identifier — used to namespace
10833+
and audit field definitions.
1075610834
description:
1075710835
type:
1075810836
- string
@@ -10763,18 +10841,24 @@ components:
1076310841
- object
1076410842
- "null"
1076510843
additionalProperties: true
10766-
description: Free-form configuration object — shape varies per ``field_type``
10844+
description: |
10845+
Only meaningful when ``field_type`` is ``singleSelect``. Omit
10846+
(or send ``null``) for other types. On create, send choices
10847+
as ``{choices: [{label: ...}]}``; the server assigns each one
10848+
an integer ``id`` and returns the resolved array in the
10849+
response (use that ``id`` when setting a value on a sales
10850+
order).
1076710851
example:
10768-
label: Quality Grade
10769-
field_type: select
10770-
entity_type: product
10771-
source: user
10772-
description: Customer-facing quality classification
10852+
label: Channel
10853+
field_type: singleSelect
10854+
entity_type: SalesOrder
10855+
source: your-integration
10856+
description: Customer-facing sales channel classification
1077310857
options:
10774-
values:
10775-
- A
10776-
- B
10777-
- C
10858+
choices:
10859+
- label: Online
10860+
- label: Retail
10861+
- label: Wholesale
1077810862
UpdateCustomFieldDefinitionRequest:
1077910863
description: Request payload for updating an existing custom field definition.
1078010864
type: object
@@ -10809,19 +10893,22 @@ components:
1080910893
$ref: "#/components/schemas/CustomFieldDefinition"
1081010894
example:
1081110895
data:
10812-
- id: 42
10813-
label: Quality Grade
10814-
field_type: select
10815-
entity_type: product
10816-
source: user
10817-
description: Customer-facing quality classification
10896+
- id: "0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef"
10897+
label: Channel
10898+
field_type: singleSelect
10899+
entity_type: SalesOrder
10900+
source: your-integration
10901+
description: Customer-facing sales channel classification
1081810902
options:
10819-
values:
10820-
- A
10821-
- B
10822-
- C
10823-
created_at: "2024-01-08T10:00:00Z"
10824-
updated_at: "2024-01-12T15:30:00Z"
10903+
choices:
10904+
- id: 1
10905+
label: Online
10906+
- id: 2
10907+
label: Retail
10908+
- id: 3
10909+
label: Wholesale
10910+
created_at: "2026-05-14T10:00:00Z"
10911+
updated_at: "2026-05-14T10:00:00Z"
1082510912
Stocktake:
1082610913
description: Physical inventory count process for reconciling actual stock levels with system records
1082710914
allOf:
@@ -12747,8 +12834,12 @@ components:
1274712834
type: integer
1274812835
description: Product variant ID
1274912836
quantity:
12750-
type: number
12751-
description: Quantity to transfer
12837+
type: string
12838+
description: |
12839+
Quantity to transfer. Sent as a fixed-precision decimal
12840+
string on the wire (e.g. ``"1.0000000000"``). Verified via
12841+
422 introspection — sending a JSON number returns
12842+
``must be string``.
1275212843
CreateStockTransferRequest:
1275312844
description: Request payload for creating a new stock transfer
1275412845
type: object
@@ -17253,14 +17344,23 @@ paths:
1725317344
This endpoint can also be used for changing existing links of the variants to different storage bins.
1725417345

1725517346
The endpoint accepts up to 500 variant storage bin objects.
17347+
17348+
**Request body shape:** the endpoint requires an array of link
17349+
objects, even when linking a single variant. Sending a single
17350+
object (without the array wrapper) returns
17351+
``422 must be array``. Verified via live-API shape probe.
1725617352
operationId: linkVariantDefaultStorageBins
1725717353
requestBody:
1725817354
description: Linked variant default storage bin details
1725917355
required: true
1726017356
content:
1726117357
application/json:
1726217358
schema:
17263-
$ref: "#/components/schemas/VariantDefaultStorageBinLink"
17359+
type: array
17360+
minItems: 1
17361+
maxItems: 500
17362+
items:
17363+
$ref: "#/components/schemas/VariantDefaultStorageBinLink"
1726417364
responses:
1726517365
"200":
1726617366
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)