askell-mcp 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,7 +127,8 @@ Typical agent workflow:
127
127
 
128
128
  - **v1** — legacy paths like `/customers/`, `/subscriptions/` (no `/v2` prefix)
129
129
  - **v2** — current model: catalogs, quotes, checkouts, contracts, billing runs under `/v2/`
130
- - **v2 discounts** — coupons: `GET/POST /v2/subscription-contracts/{id}/discount|apply-code|remove-discount` (one active). Quotes take `promotion_code` and, for an existing buyer, `customer` (id) so combo discounts + promo restrictions apply. Totals already include both. Recurring `finalize` needs a verified payment method even when due-now is 0. Not the v1 `discount` 0–100 field.
130
+ - **v2 discounts** — coupons: `GET/POST /v2/subscription-contracts/{id}/discount|apply-code|remove-discount` (one active). Quotes take `promotion_code` and, for an existing buyer, `customer` (id) so combo discounts + promo restrictions apply. First-period totals already include coupon + combo; `quote.recurring_*` include combo but not the coupon (`discount.recurring_final_amount` while the coupon is active). Recurring `finalize` needs a verified payment method even when due-now is 0. Not the v1 `discount` 0–100 field.
131
+ - **v2 fulfilment** — British spelling: `GET /v2/fulfilment-orders/` (read-only). Path param is order `id`, not `number`. List `403` if shipping is off. Quote/checkout `shipping_fee` is already in totals.
131
132
  - Paths use **trailing slashes**
132
133
  - Prefer **v2** for new integrations; v1 remains for existing ones
133
134
  - Docs: [docs.askell.is](https://docs.askell.is/) · OpenAPI: [v1](https://askell.is/api/swagger/swagger.json) · [v2](https://askell.is/api/swagger/v2/swagger.json)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askell-mcp",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "MCP server for the Askell payment and subscription API (Bun + stdio)",
5
5
  "author": "Neschadin Oleksandr",
6
6
  "license": "MIT",
@@ -67,6 +67,6 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@modelcontextprotocol/server": "2.0.0",
70
- "zod": "4.6.1"
70
+ "zod": "4.6.5"
71
71
  }
72
72
  }
@@ -55,6 +55,10 @@
55
55
  {
56
56
  "name": "V2 Billing Runs",
57
57
  "description": "Subscription V2 billing-run read and retry APIs, requires secret api key."
58
+ },
59
+ {
60
+ "name": "V2 Fulfilment",
61
+ "description": "Subscription V2 fulfilment order read APIs for warehouse integrations, requires secret api key."
58
62
  }
59
63
  ],
60
64
  "paths": {
@@ -1672,6 +1676,119 @@
1672
1676
  }
1673
1677
  ]
1674
1678
  }
1679
+ },
1680
+ "/v2/fulfilment-orders/": {
1681
+ "get": {
1682
+ "tags": [
1683
+ "V2 Fulfilment"
1684
+ ],
1685
+ "summary": "List fulfilment orders",
1686
+ "description": "Returns the authenticated account's fulfilment orders, newest first, with the same body the `fulfilment_order.*` webhooks carry. Intended for backfill and for polling-based reconciliation after a missed webhook delivery. Read-only. Requires a secret key, that the account uses subscription contracts, and that shipping is enabled for the account.",
1687
+ "parameters": [
1688
+ {
1689
+ "in": "query",
1690
+ "name": "status",
1691
+ "schema": {
1692
+ "type": "string",
1693
+ "enum": [
1694
+ "open",
1695
+ "partially_fulfilled",
1696
+ "fulfilled",
1697
+ "cancelled"
1698
+ ]
1699
+ },
1700
+ "description": "Filter by fulfilment status."
1701
+ },
1702
+ {
1703
+ "in": "query",
1704
+ "name": "contract",
1705
+ "schema": {
1706
+ "type": "integer"
1707
+ },
1708
+ "description": "Filter by subscription contract id."
1709
+ },
1710
+ {
1711
+ "in": "query",
1712
+ "name": "customer",
1713
+ "schema": {
1714
+ "type": "integer"
1715
+ },
1716
+ "description": "Filter by customer id."
1717
+ },
1718
+ {
1719
+ "in": "query",
1720
+ "name": "customer_reference",
1721
+ "schema": {
1722
+ "type": "string"
1723
+ },
1724
+ "description": "Filter by customer reference."
1725
+ },
1726
+ {
1727
+ "in": "query",
1728
+ "name": "updated_since",
1729
+ "schema": {
1730
+ "type": "string",
1731
+ "format": "date-time"
1732
+ },
1733
+ "description": "Only orders changed at or after this ISO 8601 timestamp. A bare date means the start of that day. This is the reconciliation filter: poll it with the `updated_at` of the last order you processed."
1734
+ },
1735
+ {
1736
+ "in": "query",
1737
+ "name": "created_since",
1738
+ "schema": {
1739
+ "type": "string",
1740
+ "format": "date-time"
1741
+ },
1742
+ "description": "Only orders created at or after this ISO 8601 timestamp. A bare date means the start of that day."
1743
+ },
1744
+ {
1745
+ "$ref": "#/components/parameters/PageSizeFilter"
1746
+ }
1747
+ ],
1748
+ "responses": {
1749
+ "200": {
1750
+ "$ref": "#/components/responses/V2FulfilmentOrderList"
1751
+ },
1752
+ "403": {
1753
+ "$ref": "#/components/responses/V2PermissionDenied"
1754
+ }
1755
+ },
1756
+ "security": [
1757
+ {
1758
+ "Secret-Api-Key": []
1759
+ }
1760
+ ]
1761
+ }
1762
+ },
1763
+ "/v2/fulfilment-orders/{fulfilmentOrderId}/": {
1764
+ "get": {
1765
+ "tags": [
1766
+ "V2 Fulfilment"
1767
+ ],
1768
+ "summary": "Get a fulfilment order",
1769
+ "description": "Returns one fulfilment order belonging to the authenticated account, with its lines, delivery address, shipping selection and booked shipments. Orders belonging to another account return `404`. Read-only. Requires a secret key.",
1770
+ "parameters": [
1771
+ {
1772
+ "$ref": "#/components/parameters/V2FulfilmentOrderId"
1773
+ }
1774
+ ],
1775
+ "responses": {
1776
+ "200": {
1777
+ "$ref": "#/components/responses/V2FulfilmentOrder"
1778
+ },
1779
+ "403": {
1780
+ "$ref": "#/components/responses/V2PermissionDenied"
1781
+ },
1782
+ "404": {
1783
+ "$ref": "#/components/responses/V2NotFound"
1784
+ }
1785
+ },
1786
+ "security": [
1787
+ {
1788
+ "Secret-Api-Key": []
1789
+ }
1790
+ ]
1791
+ }
1675
1792
  }
1676
1793
  },
1677
1794
  "components": {
@@ -3088,6 +3205,10 @@
3088
3205
  "shipping": {
3089
3206
  "$ref": "#/components/schemas/V2ShippingSelectionInput",
3090
3207
  "description": "The shipping option the customer chose. Required when the offer contains physical products and the account has active shipping options."
3208
+ },
3209
+ "allowed_origin": {
3210
+ "type": "string",
3211
+ "description": "Origin allowed to embed the hosted card form for this checkout in an iframe, for example `https://shop.example`. A single http(s) origin with no path; http is accepted only for `localhost` and loopback addresses (`127.0.0.0/8`, `[::1]`). When set it replaces the account-level checkout origins in the hosted page's `frame-ancestors` policy. Not accepted on checkout-session endpoints, where the sales channel's allowed origins apply."
3091
3212
  }
3092
3213
  }
3093
3214
  }
@@ -3168,6 +3289,51 @@
3168
3289
  },
3169
3290
  "location_address": {
3170
3291
  "type": "string"
3292
+ },
3293
+ "location": {
3294
+ "$ref": "#/components/schemas/V2PickupLocation"
3295
+ }
3296
+ }
3297
+ },
3298
+ "V2PickupLocation": {
3299
+ "type": "object",
3300
+ "nullable": true,
3301
+ "description": "The chosen pickup location as a structured block an external fulfilment system can book against, or null when the selection has none (home delivery, freight, store pickup without an address). Derived from the shipping provider's own payload, with the flat `location_*` values as the fallback.",
3302
+ "properties": {
3303
+ "id": {
3304
+ "type": "string",
3305
+ "description": "The provider's id for the location."
3306
+ },
3307
+ "name": {
3308
+ "type": "string"
3309
+ },
3310
+ "address": {
3311
+ "type": "string",
3312
+ "description": "One-line address, composed from street, zip code and town when the provider does not supply one."
3313
+ },
3314
+ "street": {
3315
+ "type": "string"
3316
+ },
3317
+ "zip_code": {
3318
+ "type": "string"
3319
+ },
3320
+ "town": {
3321
+ "type": "string"
3322
+ },
3323
+ "external_id": {
3324
+ "type": "string",
3325
+ "nullable": true,
3326
+ "description": "The location's id in the provider's upstream system, when it exposes one."
3327
+ },
3328
+ "latitude": {
3329
+ "type": "number",
3330
+ "format": "double",
3331
+ "nullable": true
3332
+ },
3333
+ "longitude": {
3334
+ "type": "number",
3335
+ "format": "double",
3336
+ "nullable": true
3171
3337
  }
3172
3338
  }
3173
3339
  },
@@ -3638,6 +3804,10 @@
3638
3804
  },
3639
3805
  "metadata": {
3640
3806
  "$ref": "#/components/schemas/V2Metadata"
3807
+ },
3808
+ "allowed_origin": {
3809
+ "type": "string",
3810
+ "description": "Origin allowed to embed this hosted card form in an iframe, for example `https://shop.example`. A single http(s) origin with no path; http is accepted only for `localhost` and loopback addresses (`127.0.0.0/8`, `[::1]`). Defaults to the checkout's `allowed_origin`, and when set it replaces the account-level checkout origins in the hosted page's `frame-ancestors` policy. Not accepted on checkout-session endpoints, where the sales channel's allowed origins apply."
3641
3811
  }
3642
3812
  }
3643
3813
  },
@@ -3690,6 +3860,10 @@
3690
3860
  "metadata": {
3691
3861
  "$ref": "#/components/schemas/V2Metadata"
3692
3862
  },
3863
+ "allowed_origin": {
3864
+ "type": "string",
3865
+ "description": "Origin allowed to embed this hosted card form, or an empty string when the account-level checkout origins apply."
3866
+ },
3693
3867
  "error_message": {
3694
3868
  "type": "string"
3695
3869
  },
@@ -3753,6 +3927,25 @@
3753
3927
  "type": "string",
3754
3928
  "format": "decimal"
3755
3929
  },
3930
+ "shipping_fee": {
3931
+ "type": "object",
3932
+ "nullable": true,
3933
+ "description": "Sendingargjald sem er hluti af checkout-samtölunum, eða null.",
3934
+ "properties": {
3935
+ "amount": {
3936
+ "type": "string"
3937
+ },
3938
+ "subtotal_amount": {
3939
+ "type": "string"
3940
+ },
3941
+ "tax_amount": {
3942
+ "type": "string"
3943
+ },
3944
+ "total_amount": {
3945
+ "type": "string"
3946
+ }
3947
+ }
3948
+ },
3756
3949
  "contract_id": {
3757
3950
  "type": "integer",
3758
3951
  "nullable": true
@@ -3767,6 +3960,10 @@
3767
3960
  "metadata": {
3768
3961
  "$ref": "#/components/schemas/V2Metadata"
3769
3962
  },
3963
+ "allowed_origin": {
3964
+ "type": "string",
3965
+ "description": "Origin allowed to embed hosted pages for this checkout, or an empty string when the account-level checkout origins apply."
3966
+ },
3770
3967
  "created_at": {
3771
3968
  "type": "string",
3772
3969
  "format": "date-time"
@@ -3916,6 +4113,25 @@
3916
4113
  "type": "string",
3917
4114
  "format": "decimal"
3918
4115
  },
4116
+ "shipping_fee": {
4117
+ "type": "object",
4118
+ "nullable": true,
4119
+ "description": "Sendingargjald sem er hluti af tilboðinu (samtölur innihalda það), eða null.",
4120
+ "properties": {
4121
+ "amount": {
4122
+ "type": "string"
4123
+ },
4124
+ "subtotal_amount": {
4125
+ "type": "string"
4126
+ },
4127
+ "tax_amount": {
4128
+ "type": "string"
4129
+ },
4130
+ "total_amount": {
4131
+ "type": "string"
4132
+ }
4133
+ }
4134
+ },
3919
4135
  "first_period_recurring_subtotal_amount": {
3920
4136
  "type": "string",
3921
4137
  "format": "decimal"
@@ -3998,6 +4214,21 @@
3998
4214
  "original_total_amount": {
3999
4215
  "type": "string",
4000
4216
  "format": "decimal"
4217
+ },
4218
+ "recurring_original_amount": {
4219
+ "type": "string",
4220
+ "format": "decimal",
4221
+ "description": "What a renewal bills without the coupon. The recurring_* totals of the quote do not include the promotion code discount."
4222
+ },
4223
+ "recurring_discount_amount": {
4224
+ "type": "string",
4225
+ "format": "decimal",
4226
+ "description": "The coupon's discount applied to a renewal's recurring lines. Computed regardless of duration: a once coupon lapses after the first payment and a repeating coupon after duration_in_months, so only treat this as the renewal discount while the coupon is active."
4227
+ },
4228
+ "recurring_final_amount": {
4229
+ "type": "string",
4230
+ "format": "decimal",
4231
+ "description": "What a renewal bills with the coupon's discount applied. Only meaningful while the coupon is still active (see duration and duration_in_months)."
4001
4232
  }
4002
4233
  }
4003
4234
  },
@@ -4551,6 +4782,211 @@
4551
4782
  }
4552
4783
  }
4553
4784
  },
4785
+ "V2FulfilmentAddress": {
4786
+ "type": "object",
4787
+ "nullable": true,
4788
+ "description": "The delivery address as it stood when the order was created. This is the order's own snapshot, so editing the contract's address afterwards does not change it.",
4789
+ "properties": {
4790
+ "id": {
4791
+ "type": "integer"
4792
+ },
4793
+ "delivery_name": {
4794
+ "type": "string",
4795
+ "nullable": true
4796
+ },
4797
+ "address_1": {
4798
+ "type": "string"
4799
+ },
4800
+ "address_2": {
4801
+ "type": "string",
4802
+ "nullable": true
4803
+ },
4804
+ "address_3": {
4805
+ "type": "string",
4806
+ "nullable": true
4807
+ },
4808
+ "zip_code": {
4809
+ "type": "string"
4810
+ },
4811
+ "city": {
4812
+ "type": "string",
4813
+ "nullable": true
4814
+ },
4815
+ "state": {
4816
+ "type": "string",
4817
+ "nullable": true
4818
+ },
4819
+ "country": {
4820
+ "type": "string",
4821
+ "description": "ISO 3166-1 alpha-2 country code."
4822
+ }
4823
+ }
4824
+ },
4825
+ "V2FulfilmentOrderLine": {
4826
+ "type": "object",
4827
+ "properties": {
4828
+ "id": {
4829
+ "type": "integer"
4830
+ },
4831
+ "product_id": {
4832
+ "type": "integer",
4833
+ "nullable": true
4834
+ },
4835
+ "product_reference": {
4836
+ "type": "string",
4837
+ "nullable": true,
4838
+ "description": "The seller's own product reference, i.e. the SKU a warehouse picks by. Null when the catalog product has been deleted."
4839
+ },
4840
+ "product_name": {
4841
+ "type": "string",
4842
+ "description": "Product name at the time the order was created."
4843
+ },
4844
+ "quantity": {
4845
+ "type": "integer"
4846
+ },
4847
+ "quantity_fulfilled": {
4848
+ "type": "integer",
4849
+ "description": "How many units have been included in a shipment."
4850
+ }
4851
+ }
4852
+ },
4853
+ "V2Fulfilment": {
4854
+ "type": "object",
4855
+ "description": "A shipment booked for (part of) an order.",
4856
+ "properties": {
4857
+ "id": {
4858
+ "type": "integer"
4859
+ },
4860
+ "order_id": {
4861
+ "type": "integer"
4862
+ },
4863
+ "handler": {
4864
+ "type": "string",
4865
+ "enum": [
4866
+ "dropp",
4867
+ "posturinn",
4868
+ "store_pickup"
4869
+ ]
4870
+ },
4871
+ "status": {
4872
+ "type": "string",
4873
+ "enum": [
4874
+ "pending",
4875
+ "booked",
4876
+ "failed",
4877
+ "shipped",
4878
+ "delivered",
4879
+ "cancelled"
4880
+ ]
4881
+ },
4882
+ "provider_order_id": {
4883
+ "type": "string",
4884
+ "description": "The provider's own identifier for the shipment, such as a Dropp order UUID or a Pósturinn shipment id."
4885
+ },
4886
+ "tracking_number": {
4887
+ "type": "string"
4888
+ },
4889
+ "weight_grams": {
4890
+ "type": "integer",
4891
+ "nullable": true,
4892
+ "description": "Package weight used for the booking."
4893
+ },
4894
+ "error": {
4895
+ "type": "string",
4896
+ "description": "The last booking error, empty when the booking succeeded."
4897
+ },
4898
+ "booked_at": {
4899
+ "type": "string",
4900
+ "format": "date-time",
4901
+ "nullable": true
4902
+ },
4903
+ "created_at": {
4904
+ "type": "string",
4905
+ "format": "date-time"
4906
+ },
4907
+ "updated_at": {
4908
+ "type": "string",
4909
+ "format": "date-time"
4910
+ }
4911
+ }
4912
+ },
4913
+ "V2FulfilmentOrder": {
4914
+ "type": "object",
4915
+ "description": "A physical order generated by a paid billing run. This is also the body of the `fulfilment_order.*` webhooks.",
4916
+ "properties": {
4917
+ "id": {
4918
+ "type": "integer"
4919
+ },
4920
+ "number": {
4921
+ "type": "integer",
4922
+ "description": "Per-account sequential order number."
4923
+ },
4924
+ "status": {
4925
+ "type": "string",
4926
+ "enum": [
4927
+ "open",
4928
+ "partially_fulfilled",
4929
+ "fulfilled",
4930
+ "cancelled"
4931
+ ]
4932
+ },
4933
+ "contract_id": {
4934
+ "type": "integer"
4935
+ },
4936
+ "billing_run_id": {
4937
+ "type": "integer",
4938
+ "description": "The successful billing run that generated this order. Each run generates at most one order."
4939
+ },
4940
+ "customer_id": {
4941
+ "type": "integer"
4942
+ },
4943
+ "customer_reference": {
4944
+ "type": "string"
4945
+ },
4946
+ "customer_name": {
4947
+ "type": "string"
4948
+ },
4949
+ "customer_email": {
4950
+ "type": "string",
4951
+ "nullable": true
4952
+ },
4953
+ "delivery_address": {
4954
+ "$ref": "#/components/schemas/V2FulfilmentAddress"
4955
+ },
4956
+ "shipping_selection": {
4957
+ "$ref": "#/components/schemas/V2ShippingSelection"
4958
+ },
4959
+ "lines": {
4960
+ "type": "array",
4961
+ "items": {
4962
+ "$ref": "#/components/schemas/V2FulfilmentOrderLine"
4963
+ }
4964
+ },
4965
+ "fulfilments": {
4966
+ "type": "array",
4967
+ "description": "Shipments booked for this order. Empty until one is booked, and always empty for an account with no shipping providers configured.",
4968
+ "items": {
4969
+ "$ref": "#/components/schemas/V2Fulfilment"
4970
+ }
4971
+ },
4972
+ "estimated_weight_grams": {
4973
+ "type": "integer",
4974
+ "nullable": true,
4975
+ "description": "Sum of the products' chargeable unit weights, or null when any line lacks weight data, so a partial estimate is never mistaken for a total."
4976
+ },
4977
+ "metadata": {
4978
+ "$ref": "#/components/schemas/V2Metadata"
4979
+ },
4980
+ "created_at": {
4981
+ "type": "string",
4982
+ "format": "date-time"
4983
+ },
4984
+ "updated_at": {
4985
+ "type": "string",
4986
+ "format": "date-time"
4987
+ }
4988
+ }
4989
+ },
4554
4990
  "V2SubscriptionContractCustomer": {
4555
4991
  "type": "object",
4556
4992
  "properties": {
@@ -5077,6 +5513,17 @@
5077
5513
  "type": "integer"
5078
5514
  }
5079
5515
  },
5516
+ "V2FulfilmentOrderId": {
5517
+ "name": "fulfilmentOrderId",
5518
+ "in": "path",
5519
+ "description": "Fulfilment order id. This is the order's `id`, not its per-account `number`.",
5520
+ "required": true,
5521
+ "style": "simple",
5522
+ "explode": false,
5523
+ "schema": {
5524
+ "type": "integer"
5525
+ }
5526
+ },
5080
5527
  "V2CustomerReference": {
5081
5528
  "name": "customerReference",
5082
5529
  "in": "path",
@@ -5439,6 +5886,70 @@
5439
5886
  }
5440
5887
  }
5441
5888
  },
5889
+ "V2PermissionDenied": {
5890
+ "description": "The account may not use this endpoint, for example because it does not use subscription contracts or does not have shipping enabled",
5891
+ "content": {
5892
+ "application/json": {
5893
+ "schema": {
5894
+ "type": "object",
5895
+ "properties": {
5896
+ "detail": {
5897
+ "type": "string"
5898
+ }
5899
+ }
5900
+ }
5901
+ }
5902
+ }
5903
+ },
5904
+ "V2FulfilmentOrder": {
5905
+ "description": "V2 fulfilment order",
5906
+ "content": {
5907
+ "application/json": {
5908
+ "schema": {
5909
+ "$ref": "#/components/schemas/V2FulfilmentOrder"
5910
+ }
5911
+ }
5912
+ }
5913
+ },
5914
+ "V2FulfilmentOrderList": {
5915
+ "description": "V2 fulfilment order list",
5916
+ "content": {
5917
+ "application/json": {
5918
+ "schema": {
5919
+ "anyOf": [
5920
+ {
5921
+ "type": "array",
5922
+ "items": {
5923
+ "$ref": "#/components/schemas/V2FulfilmentOrder"
5924
+ }
5925
+ },
5926
+ {
5927
+ "type": "object",
5928
+ "properties": {
5929
+ "count": {
5930
+ "type": "integer"
5931
+ },
5932
+ "next": {
5933
+ "type": "string",
5934
+ "nullable": true
5935
+ },
5936
+ "previous": {
5937
+ "type": "string",
5938
+ "nullable": true
5939
+ },
5940
+ "results": {
5941
+ "type": "array",
5942
+ "items": {
5943
+ "$ref": "#/components/schemas/V2FulfilmentOrder"
5944
+ }
5945
+ }
5946
+ }
5947
+ }
5948
+ ]
5949
+ }
5950
+ }
5951
+ }
5952
+ },
5442
5953
  "V2BillingRun": {
5443
5954
  "description": "V2 billing run",
5444
5955
  "content": {
@@ -1,6 +1,29 @@
1
+ /**
2
+ * Askell OpenAPI uses British "fulfilment"; agents (and Shopify/Stripe training)
3
+ * type American "fulfillment". Fold to the British form Askell actually serves.
4
+ */
5
+ export function foldFulfilmentSpelling(text: string): string {
6
+ return text.replace(/fulfillment/gi, (match) => {
7
+ const first = match[0];
8
+ if (match === match.toUpperCase()) {
9
+ return 'FULFILMENT';
10
+ }
11
+ if (first !== undefined && first === first.toUpperCase()) {
12
+ return 'Fulfilment';
13
+ }
14
+ return 'fulfilment';
15
+ });
16
+ }
17
+
18
+ /** Lowercased text with American/British fulfilment spelling folded together. */
19
+ export function canonicalSearchText(text: string): string {
20
+ return foldFulfilmentSpelling(text).toLowerCase();
21
+ }
22
+
1
23
  /** Normalize Askell API paths: leading slash + trailing slash (OpenAPI convention). */
2
24
  export function normalizeApiPath(path: string): string {
3
- let normalized = path.startsWith('/') ? path : `/${path}`;
25
+ let normalized = foldFulfilmentSpelling(path);
26
+ normalized = normalized.startsWith('/') ? normalized : `/${normalized}`;
4
27
 
5
28
  if (normalized !== '/' && !normalized.endsWith('/')) {
6
29
  normalized += '/';
@@ -1,6 +1,10 @@
1
1
  import v1Spec from '../../spec/openapi-v1.json';
2
2
  import v2Spec from '../../spec/openapi-v2.json';
3
3
 
4
+ import {
5
+ canonicalSearchText,
6
+ foldFulfilmentSpelling,
7
+ } from '../client/paths.ts';
4
8
  import type {
5
9
  ApiKeyKind,
6
10
  ApiOperation,
@@ -184,7 +188,10 @@ export class OperationRegistry {
184
188
  }
185
189
 
186
190
  getById(id: string): ApiOperation | undefined {
187
- return this.operations.find((operation) => operation.id === id);
191
+ const folded = foldFulfilmentSpelling(id);
192
+ return this.operations.find(
193
+ (operation) => operation.id === id || operation.id === folded,
194
+ );
188
195
  }
189
196
 
190
197
  find(filters: {
@@ -206,7 +213,13 @@ export class OperationRegistry {
206
213
  return false;
207
214
  }
208
215
 
209
- if (filters.tag && !operation.tags.includes(filters.tag)) {
216
+ const tagFilter = filters.tag;
217
+ if (
218
+ tagFilter &&
219
+ !operation.tags.some(
220
+ (tag) => canonicalSearchText(tag) === canonicalSearchText(tagFilter),
221
+ )
222
+ ) {
210
223
  return false;
211
224
  }
212
225
 
@@ -216,7 +229,9 @@ export class OperationRegistry {
216
229
 
217
230
  if (
218
231
  filters.pathPrefix &&
219
- !operation.path.startsWith(filters.pathPrefix)
232
+ !canonicalSearchText(operation.path).startsWith(
233
+ canonicalSearchText(filters.pathPrefix),
234
+ )
220
235
  ) {
221
236
  return false;
222
237
  }
@@ -226,17 +241,18 @@ export class OperationRegistry {
226
241
  }
227
242
 
228
243
  if (search) {
229
- const haystack = [
230
- operation.id,
231
- operation.path,
232
- operation.summary,
233
- operation.description ?? '',
234
- operation.tags.join(' '),
235
- ]
236
- .join(' ')
237
- .toLowerCase();
238
-
239
- if (!haystack.includes(search)) {
244
+ const needle = canonicalSearchText(search);
245
+ const haystack = canonicalSearchText(
246
+ [
247
+ operation.id,
248
+ operation.path,
249
+ operation.summary,
250
+ operation.description ?? '',
251
+ operation.tags.join(' '),
252
+ ].join(' '),
253
+ );
254
+
255
+ if (!haystack.includes(needle)) {
240
256
  return false;
241
257
  }
242
258
  }
@@ -9,13 +9,15 @@ Askell POSTs signed JSON to each URL you register. Verify \`Hook-HMAC\` before p
9
9
  Headers:
10
10
  - Hook-HMAC: base64 HMAC-SHA512 of the **raw body** (secret = \`hmac_secret\` from webhook create)
11
11
  - Hook-Event: event type (\`subscription.renewed\`, \`payment.changed\`, or a family wildcard \`subscription.*\`)
12
- - Hook-API-Version: \`v1\` for plan/subscription/customer/payment/checkout, \`v2\` for subscription_contract / billing_run
12
+ - Hook-API-Version: \`v1\` for plan/subscription/customer/payment/checkout, \`v2\` for subscription_contract / billing_run / fulfilment_order
13
13
 
14
- ## Body shape (not in OpenAPI)
14
+ ## Body shape
15
15
 
16
16
  JSON body **is the event object**. It is **not** \`{ event, data }\`.
17
17
 
18
- Upstream swagger used to document a dummy \`POST /your-webhook-url/\` with \`SubscriptionMultiLite\` (\`{ customer, subscriptions[] }\`). \`sync-specs\` strips that path. Inbound payloads are still undocumented in OpenAPI — this resource is the overlay.
18
+ Upstream swagger used to document a dummy \`POST /your-webhook-url/\` with \`SubscriptionMultiLite\` (\`{ customer, subscriptions[] }\`). \`sync-specs\` strips that path.
19
+
20
+ Most inbound payloads are still undocumented in OpenAPI — this resource is the overlay. Exception: \`fulfilment_order.*\` is \`V2FulfilmentOrder\` (same body as \`GET /v2/fulfilment-orders/\` / \`GET /v2/fulfilment-orders/{id}/\`). Live webhook docs omit this family; bundled spec is right. Spelling is British \`fulfilment_order.*\`, not American \`fulfillment_order.*\`. Specific verbs beyond the wildcard are not listed — do not invent \`created\`/\`shipped\`/….
19
21
 
20
22
  Rare historical payloads used \`{ event, data, ref?, sender? }\`. If both \`event\` and \`data\` are objects, use \`data\`.
21
23
 
@@ -59,6 +61,11 @@ V2 migration: \`subscription.*\` is **not** aliased onto the new contract (paylo
59
61
 
60
62
  ### checkout.* (v1)
61
63
  \`created\`, \`changed\` — \`token\`, \`checkout_url\`, \`status\`.
64
+
65
+ ### fulfilment_order.* (v2)
66
+ Family wildcard only in OpenAPI (\`fulfilment_order.*\`). Not American \`fulfillment_order.*\`. Live docs omit this family.
67
+
68
+ Body is \`V2FulfilmentOrder\`: \`id\` (path param; not per-account \`number\`), \`number\`, \`status\` (\`open\` | \`partially_fulfilled\` | \`fulfilled\` | \`cancelled\`), \`contract_id\`, \`billing_run_id\` (successful run that generated the order; at most one order per run), \`customer_id\`, \`customer_reference\`, \`customer_name\`, \`customer_email\`, \`delivery_address\` (snapshot; later contract address edits do not change it), \`shipping_selection\` (includes structured \`location\` plus flat \`location_*\` fallback), \`lines[]\` (\`product_id\`, \`product_reference\` SKU, \`product_name\`, \`quantity\`, \`quantity_fulfilled\`), \`fulfilments[]\` (booked shipments; empty until booked / if no shipping provider), \`estimated_weight_grams\` (null if any line lacks weight — never a partial total), \`metadata\`, \`created_at\`, \`updated_at\`.
62
69
  `;
63
70
 
64
71
  export function registerResources(server: McpServer): void {
package/src/server.ts CHANGED
@@ -47,15 +47,23 @@ API layout:
47
47
  - V2 list endpoints paginate only when page_size is provided (default 10, max 1000).
48
48
  - GET /v2/customer-entitlements/ requires customer_reference query param.
49
49
 
50
+ V2 fulfilment (British spelling on the wire — not American fulfillment):
51
+ - Read-only GET /v2/fulfilment-orders/ and GET /v2/fulfilment-orders/{fulfilmentOrderId}/. No POST/PATCH in OpenAPI. Path param is the order's id, not per-account number.
52
+ - List 403 = account does not use V2 contracts or shipping is disabled (not a bad API key). GET 404 = missing or other-account.
53
+ - Reconciliation: poll list with updated_since = updated_at of the last order you processed. Filter by contract, customer, customer_reference, status.
54
+ - Webhook family fulfilment_order.* (not fulfillment_order.*). Body is V2FulfilmentOrder, same as GET. Live webhook docs omit this family; bundled spec is right. Do not invent event verbs beyond the wildcard until Askell lists them.
55
+ - Quote and checkout shipping_fee is already in totals; do not add it again (askell_describe_operation omits response schemas). shipping_selection.location is the structured pickup block; flat location_* are fallback.
56
+
50
57
  V2 discounts — two systems, not v1 Subscription.discount (0-100 on a PlanVariant; never send that to v2):
51
58
  - Coupons: one active per contract. GET /v2/subscription-contracts/{id}/discount/ (also nested as contract.discount). Apply with POST .../apply-code/ {promotion_code}. Remove with POST .../remove-discount/.
52
- - Quotes (POST /v2/subscription-offer-quotes/): pass promotion_code for coupons. When quoting an existing customer, pass customer (numeric id) or combo discounts from their other active contracts and promo-code customer restrictions are skipped. Quoted totals already include coupon + combo; do not subtract again. combo_discounts[] is on the quote response (askell_describe_operation omits response schemas). Combo is automatic, not apply-code.
59
+ - Quotes (POST /v2/subscription-offer-quotes/): pass promotion_code for coupons. When quoting an existing customer, pass customer (numeric id) or combo discounts from their other active contracts and promo-code customer restrictions are skipped. First-period subtotal/tax/total already include coupon + combo. quote.recurring_* include combo, not the coupon — renewal-with-coupon is discount.recurring_final_amount, and only while duration still applies (once → after first payment use recurring_*). combo_discounts[] and discount.recurring_* are on the quote response (askell_describe_operation omits response schemas). Combo is automatic, not apply-code.
53
60
 
54
61
  V2 checkout notes:
55
62
  - checkout_url on V2 checkouts points to the API object URL, not a hosted payment page.
56
63
  - GET contract.subscriber_page is the customer-facing subscription management URL (readOnly, nullable). Not checkout_url, not v1 /public/payments/{id}/ (hosted signup). Do not send it on create/patch.
57
64
  - finalize: a recurring offer needs a verified payment method even when due-now/total is 0 (trial or fully discounted first period). Only a free one-time purchase finalizes without one. Live docs still say "unless 0 ISK" — ignore that; bundled OpenAPI is right.
58
65
  - Hosted POST /v2/checkouts/: shipping {option, location_id?} is required when the offer has physical products and the account has active shipping options. No shipping-options list in OpenAPI (ids are account config). Pickup options need location_id. Snapshot is contract.shipping_selection, not on V2Checkout.
66
+ - Hosted iframe (not askell.js): POST /v2/checkouts/ and POST .../payment-method-registrations/ take allowed_origin (one origin, no path; http only localhost/loopback). Replaces account-level frame-ancestors; GET empty string = account-level. Rejected on /v2/checkout-sessions/ (sales-channel allowed_origins[]).
59
67
  - Embedded checkout uses POST /v2/checkout-sessions/ plus browser session-token sub-paths (widget collects address/shipping; see docs, not all in OpenAPI).
60
68
 
61
69
  Auth:
@@ -70,7 +78,7 @@ Safety:
70
78
 
71
79
  Resources:
72
80
  - askell://spec/v1 and askell://spec/v2 — bundled OpenAPI
73
- - askell://docs/webhook-events — inbound webhook payloads (not in OpenAPI; dummy /your-webhook-url/ is stripped on sync), HMAC-SHA512, /webhooks/ hmac_secret`;
81
+ - askell://docs/webhook-events — inbound webhook payloads (most not in OpenAPI; fulfilment_order.* is V2FulfilmentOrder), HMAC-SHA512, /webhooks/ hmac_secret`;
74
82
  }
75
83
 
76
84
  export function createServer(config: AppConfig): McpServer {
@@ -141,7 +141,7 @@ export function registerAnalysisTools(
141
141
  {
142
142
  title: 'Subscription contract overview (v2)',
143
143
  description:
144
- 'Fetch a v2 subscription contract and recent billing runs filtered by contract id. The contract payload includes `discount` (active coupon) when one is applied, `shipping_selection` when shipping was chosen at checkout, and `subscriber_page` (customer-facing management URL, read-only).',
144
+ 'Fetch a v2 subscription contract and recent billing runs filtered by contract id. The contract payload includes `discount` (active coupon) when one is applied, `shipping_selection` when shipping was chosen at checkout, and `subscriber_page` (customer-facing management URL, read-only). Physical shipping: GET /v2/fulfilment-orders/?contract= (read-only; 403 if shipping is disabled — do not treat as a bad API key).',
145
145
  inputSchema: z.object({
146
146
  contractId: z
147
147
  .union([z.string().min(1), z.int()])
@@ -21,7 +21,7 @@ const listInputSchema = z.object({
21
21
  .string()
22
22
  .optional()
23
23
  .describe(
24
- 'Case-insensitive search in id, path, summary, description, tags',
24
+ 'Case-insensitive search in id, path, summary, description, tags. American "fulfillment" matches British "fulfilment" (Askell paths use the latter).',
25
25
  ),
26
26
  apiKeyKind: z
27
27
  .enum(['secret', 'public'])