All files / src/tools shipping.ts

100% Statements 41/41
83.33% Branches 15/18
100% Functions 4/4
100% Lines 40/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220            2x                         2x             2x               2x         2x                 2x                         2x           15x                                 4x 4x             3x         1x         15x                                                                                   5x 5x 5x   10x   5x                                                     5x   4x 5x 5x 5x   5x   5x 1x 1x 1x     3x 3x 3x   3x 1x 1x       4x 1x 1x 1x       4x         1x          
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import * as z from "zod/v4";
import type { PostNLClient } from "../postnl-client.js";
import { toTextResult, toErrorResult } from "../tool-result.js";
import type { PostNLShipmentBody, PostNLAddress, PostNLContact, PostNLDimension, PostNLProductOption, PostNLCustoms, PostNLCustomsContent } from "../types.js";
 
const addressSchema = z.object({
  AddressType: z.string().describe("Address type: 01 = receiver, 02 = sender, 09 = delivery address (for pickup points)"),
  City: z.string().describe("City name"),
  CompanyName: z.string().optional().describe("Company name"),
  Countrycode: z.string().length(2).describe("ISO 3166-1 alpha-2 country code (e.g. NL, BE, DE)"),
  HouseNr: z.string().describe("House number"),
  HouseNrExt: z.string().optional().describe("House number extension (e.g. 'A', 'bis')"),
  Street: z.string().describe("Street name"),
  Zipcode: z.string().describe("Postal code (Dutch format: 1234AB)"),
  FirstName: z.string().optional().describe("First name of the recipient"),
  Name: z.string().optional().describe("Last name or full name of the recipient"),
});
 
const contactSchema = z.object({
  ContactType: z.string().describe("Contact type: 01 = receiver"),
  Email: z.string().optional().describe("Email address for notifications"),
  SMSNr: z.string().optional().describe("Phone number for SMS notifications"),
  TelNr: z.string().optional().describe("Phone number"),
});
 
const dimensionSchema = z.object({
  Height: z.number().optional().describe("Height in mm"),
  Length: z.number().optional().describe("Length in mm"),
  Volume: z.number().optional().describe("Volume in cm3"),
  Weight: z.number().optional().describe("Weight in grams"),
  Width: z.number().optional().describe("Width in mm"),
});
 
const productOptionSchema = z.object({
  Characteristic: z.string().describe("Option characteristic code"),
  Option: z.string().describe("Option value code"),
});
 
const customsContentSchema = z.object({
  Description: z.string().describe("Description of the content"),
  Quantity: z.number().int().describe("Number of items"),
  Weight: z.number().describe("Weight in grams"),
  Value: z.number().describe("Value in cents"),
  HSTariffNr: z.string().optional().describe("HS tariff number"),
  CountryOfOrigin: z.string().optional().describe("Country of origin (ISO 3166-1 alpha-2)"),
});
 
const customsSchema = z.object({
  Certificate: z.boolean().optional().describe("Certificate enclosed"),
  CertificateNr: z.string().optional().describe("Certificate number"),
  Currency: z.string().optional().describe("Currency code (e.g. EUR)"),
  HandleAsNonDeliverable: z.boolean().optional().describe("Return to sender if undeliverable"),
  Invoice: z.boolean().optional().describe("Invoice enclosed"),
  InvoiceNr: z.string().optional().describe("Invoice number"),
  License: z.boolean().optional().describe("License enclosed"),
  LicenseNr: z.string().optional().describe("License number"),
  ShipmentType: z.string().optional().describe("Shipment type: Gift, Documents, Commercial Goods, Commercial Sample, Returned Goods"),
  Content: z.array(customsContentSchema).optional().describe("Customs content items"),
});
 
export const registerShippingTools = (
  server: McpServer,
  client: PostNLClient,
  customerCode: string,
  customerNumber: string,
): void => {
  server.registerTool(
    "generate_barcode",
    {
      title: "Generate Barcode",
      description:
        "Generate a PostNL barcode for shipping. The barcode is required before creating a shipment. " +
        "Types: 2S (mailbox parcel), 3S (domestic NL parcels), CC/CP/CD/CF (EU parcels), " +
        "LA (EU letter), RI (registered international), UE (non-EU parcels).",
      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
 
      inputSchema: z.object({
        type: z.enum(["2S", "3S", "CC", "CP", "CD", "CF", "LA", "RI", "UE"]).default("3S").describe("Barcode type. 2S = mailbox parcel, 3S = domestic, CC = EU consumer parcel, CP = EU compact, CD = EU parcel, CF = EU bulk, LA = EU letter, RI = registered international, UE = non-EU"),
        serie: z.string().default("000000000-999999999").describe("Barcode serie range"),
        range: z.string().optional().describe("Customer code override (defaults to env POSTNL_CUSTOMER_CODE)"),
      }),
    },
    async ({ type, serie, range }) => {
      try {
        const result = await client.generateBarcode(
          range ?? customerCode,
          customerNumber,
          type,
          serie,
        );
 
        return toTextResult(
          `Generated barcode: ${result.Barcode}`,
          { barcode: result.Barcode },
        );
      } catch (error) {
        return toErrorResult(error);
      }
    },
  );
 
  server.registerTool(
    "create_shipment",
    {
      title: "Create Shipment",
      description:
        "Create a PostNL shipment and generate a shipping label. Requires a barcode (use generate_barcode first), " +
        "sender address (AddressType 02), and receiver address (AddressType 01). Returns a PDF label as base64. " +
        "Common product codes: 3085 (standard), 3385 (stated address only), 3090 (neighbour + return), " +
        "3087 (extra cover), 3089 (signature on delivery), 3533/3534 (pickup point). " +
        "Evening delivery: use product option {Characteristic: '118', Option: '006'} with a DeliveryDate. " +
        "WARNING: Creating a shipment incurs shipping costs. Always confirm with the user before calling this tool.",
      annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
 
      inputSchema: z.object({
        barcode: z.string().describe("The barcode for this shipment (use generate_barcode to create one)"),
        addresses: z.array(addressSchema).min(1).describe("Array of addresses. Must include sender (AddressType 02) and receiver (AddressType 01)"),
        productCodeDelivery: z.string().default("3085").describe("Product code: 3085 (standard), 3385 (evening), 3090 (pickup), 3087 (extra@home), 3089 (signature)"),
        contacts: z.array(contactSchema).optional().describe("Contact information for notifications"),
        dimension: dimensionSchema.optional().describe("Package dimensions"),
        productOptions: z.array(productOptionSchema).optional().describe("Additional product options"),
        reference: z.string().optional().describe("Your reference for this shipment"),
        remark: z.string().optional().describe("Remark for the shipment"),
        deliveryDate: z.string().optional().describe("Requested delivery date (format: dd-MM-yyyy HH:mm:ss)"),
        customs: customsSchema.optional().describe("Customs information (required for international shipments)"),
        printerType: z.enum(["GraphicFile|PDF", "GraphicFile|GIF", "GraphicFile|JPG", "GraphicFile|ZPL"]).default("GraphicFile|PDF").describe("Label output format"),
        confirm: z.boolean().default(true).describe("Confirm the shipment immediately (true) or save as concept (false)"),
      }),
    },
    async ({
      barcode,
      addresses,
      productCodeDelivery,
      contacts,
      dimension,
      productOptions,
      reference,
      remark,
      deliveryDate,
      customs,
      printerType,
      confirm,
    }) => {
      try {
        const now = new Date();
        const messageTimestamp = `${String(now.getDate()).padStart(2, "0")}-${String(now.getMonth() + 1).padStart(2, "0")}-${now.getFullYear()} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
 
        const senderAddress = addresses.find((a) => a.AddressType === "02");
 
        const body: PostNLShipmentBody = {
          Customer: {
            Address: senderAddress as PostNLAddress | undefined,
            CustomerCode: customerCode,
            CustomerNumber: customerNumber,
          },
          Message: {
            MessageID: crypto.randomUUID(),
            MessageTimeStamp: messageTimestamp,
            Printertype: printerType,
          },
          Shipments: [
            {
              Addresses: addresses as PostNLAddress[],
              Barcode: barcode,
              Contacts: contacts as PostNLContact[] | undefined,
              Dimension: dimension as PostNLDimension | undefined,
              ProductCodeDelivery: productCodeDelivery,
              ProductOptions: productOptions as PostNLProductOption[] | undefined,
              Reference: reference,
              Remark: remark,
              DeliveryDate: deliveryDate,
              Customs: customs as PostNLCustoms | undefined,
            },
          ],
        };
 
        const result = await client.createShipment(body, confirm);
 
        const shipment = result.ResponseShipments?.[0];
        const errors = shipment?.Errors ?? [];
        const warnings = shipment?.Warnings ?? [];
        const hasLabel = (shipment?.Labels?.length ?? 0) > 0;
 
        const lines: string[] = [];
 
        if (errors.length > 0) {
          lines.push("Errors:");
          for (const err of errors) {
            lines.push(`  - [${err.Code}] ${err.Description}`);
          }
        } else {
          lines.push(`Shipment created successfully`);
          lines.push(`Barcode: ${shipment?.Barcode ?? barcode}`);
          lines.push(`Product: ${shipment?.ProductCodeDelivery ?? productCodeDelivery}`);
 
          if (hasLabel) {
            lines.push(`Label format: ${printerType}`);
            lines.push(`Label content included as base64 in structured data`);
          }
        }
 
        if (warnings.length > 0) {
          lines.push("Warnings:");
          for (const warn of warnings) {
            lines.push(`  - [${warn.Code}] ${warn.Description}`);
          }
        }
 
        return toTextResult(
          lines.join("\n"),
          result as unknown as Record<string, unknown>,
        );
      } catch (error) {
        return toErrorResult(error);
      }
    },
  );
};