@sealab/mcp-server 1.0.0

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.
Files changed (48) hide show
  1. package/PROPOSED-CHANGES-INSERTION-POINTS.md +220 -0
  2. package/SEALAB_MCP_DOCUMENTATION.md +1136 -0
  3. package/dist/client/api-client.js +44 -0
  4. package/dist/index.js +42 -0
  5. package/dist/tools/canvas.js +446 -0
  6. package/dist/tools/catalog.js +95 -0
  7. package/dist/tools/configuration-info.js +299 -0
  8. package/dist/tools/configuration.js +32 -0
  9. package/dist/tools/orders.js +1267 -0
  10. package/dist/tools/saved-settings.js +271 -0
  11. package/package.json +32 -0
  12. package/resources/tooltips/backPanel.txt +17 -0
  13. package/resources/tooltips/backPanelMaterial.txt +29 -0
  14. package/resources/tooltips/caseEdge.txt +18 -0
  15. package/resources/tooltips/caseMaterial.txt +31 -0
  16. package/resources/tooltips/depth.txt +11 -0
  17. package/resources/tooltips/drawerType.txt +12 -0
  18. package/resources/tooltips/edgeBandingType.txt +18 -0
  19. package/resources/tooltips/excludeFronts.txt +5 -0
  20. package/resources/tooltips/frontEdge.txt +18 -0
  21. package/resources/tooltips/frontMaterial.txt +35 -0
  22. package/resources/tooltips/gapBottom.txt +2 -0
  23. package/resources/tooltips/gapCenter.txt +2 -0
  24. package/resources/tooltips/gapLeft.txt +15 -0
  25. package/resources/tooltips/gapRight.txt +15 -0
  26. package/resources/tooltips/gapTop.txt +2 -0
  27. package/resources/tooltips/height.txt +6 -0
  28. package/resources/tooltips/hingePlate.txt +11 -0
  29. package/resources/tooltips/includeLegLevelers.txt +8 -0
  30. package/resources/tooltips/jointMethod.txt +7 -0
  31. package/resources/tooltips/leftCornerWidth.txt +2 -0
  32. package/resources/tooltips/numOfShelves.txt +6 -0
  33. package/resources/tooltips/positionName.txt +3 -0
  34. package/resources/tooltips/rightCornerDepth.txt +2 -0
  35. package/resources/tooltips/topDrwrHeight.txt +8 -0
  36. package/resources/tooltips/width.txt +5 -0
  37. package/src/client/api-client.ts +37 -0
  38. package/src/index.ts +52 -0
  39. package/src/tools/canvas.ts +442 -0
  40. package/src/tools/catalog.test.ts +61 -0
  41. package/src/tools/catalog.ts +80 -0
  42. package/src/tools/configuration-info.ts +274 -0
  43. package/src/tools/configuration.test.ts +43 -0
  44. package/src/tools/configuration.ts +25 -0
  45. package/src/tools/orders.test.ts +260 -0
  46. package/src/tools/orders.ts +1229 -0
  47. package/src/tools/saved-settings.ts +241 -0
  48. package/tsconfig.json +15 -0
@@ -0,0 +1,80 @@
1
+ import { z } from 'zod';
2
+ import { client, handleAxiosError } from '../client/api-client';
3
+
4
+ // --- Schemas ---
5
+
6
+ const SearchSchema = z.object({
7
+ q: z.string().optional().describe('Keyword search query'),
8
+ tags: z.array(z.string()).optional().describe('Filter by tag (e.g. BASE, WALL, PANTRY)'),
9
+ minH: z.number().optional().describe('Minimum height in inches'),
10
+ maxH: z.number().optional().describe('Maximum height in inches'),
11
+ minW: z.number().optional().describe('Minimum width in inches'),
12
+ maxW: z.number().optional().describe('Maximum width in inches'),
13
+ minD: z.number().optional().describe('Minimum depth in inches'),
14
+ maxD: z.number().optional().describe('Maximum depth in inches'),
15
+ });
16
+
17
+ const GetArticleSchema = z.object({
18
+ serialNumber: z.string().describe('Article serial number, e.g. B30'),
19
+ });
20
+
21
+ const ListFilterTagsSchema = z.object({});
22
+
23
+ // --- Handlers ---
24
+
25
+ export async function searchCatalog(input: z.infer<typeof SearchSchema>): Promise<string> {
26
+ try {
27
+ const { data } = await client.get('/catalog/search', {
28
+ params: { ...input },
29
+ paramsSerializer: { indexes: null }, // produces tags=BASE&tags=WALL (not tags[]=)
30
+ });
31
+ if (!data || data.length === 0) return 'No articles found matching your search.';
32
+ return JSON.stringify(data, null, 2);
33
+ } catch (error) {
34
+ try { handleAxiosError(error); } catch (e: any) { return e.message; }
35
+ return 'Unexpected error searching catalog.';
36
+ }
37
+ }
38
+
39
+ export async function getArticle(input: z.infer<typeof GetArticleSchema>): Promise<string> {
40
+ try {
41
+ const { data } = await client.get(`/catalog/${input.serialNumber}`);
42
+ return JSON.stringify(data, null, 2);
43
+ } catch (error) {
44
+ try { handleAxiosError(error); } catch (e: any) { return e.message; }
45
+ return 'Unexpected error fetching article.';
46
+ }
47
+ }
48
+
49
+ export async function listFilterTags(_input: z.infer<typeof ListFilterTagsSchema>): Promise<string> {
50
+ try {
51
+ const { data } = await client.get('/catalog/filter-tags');
52
+ return (data as string[]).join('\n');
53
+ } catch (error) {
54
+ try { handleAxiosError(error); } catch (e: any) { return e.message; }
55
+ return 'Unexpected error fetching filter tags.';
56
+ }
57
+ }
58
+
59
+ // --- Tool definitions (consumed by index.ts) ---
60
+
61
+ export const catalogTools = [
62
+ {
63
+ name: 'search_catalog',
64
+ description: 'Search the Sealab cabinetry catalog by keyword, filter tags, or dimension ranges. Returns matching articles with serial numbers, dimensions, and feature flags.',
65
+ inputSchema: SearchSchema,
66
+ handler: searchCatalog,
67
+ },
68
+ {
69
+ name: 'get_article',
70
+ description: 'Get full details for a single cabinet article by its serial number.',
71
+ inputSchema: GetArticleSchema,
72
+ handler: getArticle,
73
+ },
74
+ {
75
+ name: 'list_filter_tags',
76
+ description: 'List all available filter tag categories in the catalog (e.g. BASE, WALL, PANTRY, TALL).',
77
+ inputSchema: ListFilterTagsSchema,
78
+ handler: listFilterTags,
79
+ },
80
+ ];
@@ -0,0 +1,274 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { client, handleAxiosError } from '../client/api-client';
5
+
6
+ // All fields with a corresponding .txt file in resources/tooltips/
7
+ const VALID_FIELDS = [
8
+ 'backPanel',
9
+ 'backPanelMaterial',
10
+ 'caseEdge',
11
+ 'caseMaterial',
12
+ 'depth',
13
+ 'drawerType',
14
+ 'edgeBandingType',
15
+ 'excludeFronts',
16
+ 'frontEdge',
17
+ 'frontMaterial',
18
+ 'gapBottom',
19
+ 'gapCenter',
20
+ 'gapLeft',
21
+ 'gapRight',
22
+ 'gapTop',
23
+ 'height',
24
+ 'hingePlate',
25
+ 'includeLegLevelers',
26
+ 'jointMethod',
27
+ 'leftCornerWidth',
28
+ 'numOfShelves',
29
+ 'positionName',
30
+ 'rightCornerDepth',
31
+ 'topDrwrHeight',
32
+ 'width',
33
+ ] as const;
34
+
35
+ type ValidField = typeof VALID_FIELDS[number];
36
+
37
+ const SerialNumberSchema = z.object({
38
+ serialNumber: z.string().describe('Article serial number'),
39
+ });
40
+
41
+ const GetArticleContextSchema = SerialNumberSchema;
42
+ const GetArticleOptionsSchema = SerialNumberSchema;
43
+
44
+ const GetConfigInfoSchema = z.object({
45
+ field: z.enum(VALID_FIELDS).describe('The configuration field name to get full detail for'),
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** Strip HTML tags and collapse whitespace for clean plain-text output */
53
+ function stripHtml(html: string): string {
54
+ return html
55
+ .replace(/<[^>]*>/g, ' ')
56
+ .replace(/&nbsp;/g, ' ')
57
+ .replace(/&amp;/g, '&')
58
+ .replace(/&lt;/g, '<')
59
+ .replace(/&gt;/g, '>')
60
+ .replace(/&quot;/g, '"')
61
+ .replace(/\s+/g, ' ')
62
+ .trim();
63
+ }
64
+
65
+ /** Read a tooltip file, returns null if missing */
66
+ function readTooltip(field: string): string | null {
67
+ const filePath = path.join(__dirname, '..', '..', 'resources', 'tooltips', `${field}.txt`);
68
+ try {
69
+ return stripHtml(fs.readFileSync(filePath, 'utf-8'));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Derive the list of applicable config fields from the configuration response */
76
+ function getApplicableFields(config: Record<string, any>): ValidField[] {
77
+ const fields: ValidField[] = ['positionName', 'height', 'width', 'depth'];
78
+
79
+ if (config.hasCase === true) fields.push('caseMaterial');
80
+ if (config.hasMatCaseThin === true) fields.push('caseMaterial'); // inner — same file
81
+ if (config.hasFront === true) fields.push('frontMaterial');
82
+ if (config.hasCase === true && config.hasFront === true) fields.push('excludeFronts');
83
+ if (config.matBack === true) fields.push('backPanelMaterial', 'backPanel');
84
+ if (config.caseEdge === true) fields.push('caseEdge');
85
+ if (config.frontEdge === true) fields.push('frontEdge');
86
+ if (config.doors === true) fields.push('hingePlate');
87
+ if (config.drawers === true) fields.push('drawerType');
88
+ if (config.topDrwrHeight === true) fields.push('topDrwrHeight');
89
+ if (config.adjShelves === true) fields.push('numOfShelves');
90
+ if (config.gapControl === true) fields.push('gapTop', 'gapBottom', 'gapLeft', 'gapRight', 'gapCenter');
91
+ if (config.jointControl === true) fields.push('jointMethod');
92
+ if (config.cornerVariables === true) fields.push('leftCornerWidth', 'rightCornerDepth');
93
+
94
+ // LP_SP / LP_GP series use edgeBandingType instead of standard edge fields
95
+ const sn: string = config.serialNumber ?? '';
96
+ if (sn.startsWith('LP_SP') || sn.startsWith('LP_GP')) fields.push('edgeBandingType');
97
+
98
+ // Leg levelers: filterTags-based (dimension check happens at order time)
99
+ const tags: string = config.filterTags ?? '';
100
+ if (tags.includes('Leg_Levelers')) fields.push('includeLegLevelers');
101
+
102
+ // Deduplicate while preserving order
103
+ return [...new Set(fields)] as ValidField[];
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Tool: get_article_context
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export async function getArticleContext(
111
+ input: z.infer<typeof GetArticleContextSchema>
112
+ ): Promise<string> {
113
+ try {
114
+ const { data: config } = await client.get(`/catalog/${input.serialNumber}/configuration`);
115
+
116
+ const applicable = getApplicableFields(config);
117
+
118
+ const lines: string[] = [];
119
+ lines.push(`=== CONFIGURATION CONTEXT: ${input.serialNumber} ===`);
120
+ lines.push('');
121
+ lines.push('DIMENSION RANGES:');
122
+ lines.push(` height: ${config.heightRange ?? 'fixed'}`);
123
+ lines.push(` width: ${config.widthRange ?? 'fixed'}`);
124
+ lines.push(` depth: ${config.depthRange ?? 'fixed'}`);
125
+ lines.push('');
126
+ lines.push(`APPLICABLE CONFIG OPTIONS (${applicable.length - 3} fields beyond dimensions):`);
127
+ lines.push('');
128
+
129
+ for (const field of applicable) {
130
+ const content = readTooltip(field);
131
+ if (content) {
132
+ lines.push(`[${field}]`);
133
+ lines.push(content);
134
+ lines.push('');
135
+ }
136
+ }
137
+
138
+ lines.push('---');
139
+ lines.push('INSTRUCTIONS:');
140
+ lines.push('1. Present a brief summary of each applicable option to the user.');
141
+ lines.push('2. For every option that applies to this article, ASK the user what they want — do NOT auto-select, guess, or assume any value.');
142
+ lines.push('3. Only move to create_order once the user has explicitly confirmed a value for every applicable field.');
143
+ lines.push('4. If the user provides a drawing or spec that does not specify a value (e.g. no material called out), treat it as missing and ask.');
144
+ lines.push('5. Only call get_configuration_info if the user asks for more detail on a specific field.');
145
+
146
+ return lines.join('\n');
147
+ } catch (error) {
148
+ try { handleAxiosError(error); } catch (e: any) { return e.message; }
149
+ return 'Unexpected error loading article context.';
150
+ }
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Tool: get_article_options
155
+ // ---------------------------------------------------------------------------
156
+
157
+ export async function getArticleOptions(
158
+ input: z.infer<typeof GetArticleOptionsSchema>
159
+ ): Promise<string> {
160
+ try {
161
+ const { data } = await client.get(`/catalog/${input.serialNumber}/options`);
162
+
163
+ if (!data || Object.keys(data).length === 0) {
164
+ return 'No selectable options found for this article.';
165
+ }
166
+
167
+ const lines: string[] = [];
168
+ lines.push(`=== SELECTABLE OPTIONS: ${input.serialNumber} ===`);
169
+ lines.push('');
170
+ lines.push('Use these exact strings when setting field values in create_order.');
171
+ lines.push('');
172
+
173
+ if (data.materials) {
174
+ lines.push('MATERIALS (caseMaterial / frontMaterial / backPanelMaterial / innerCaseMaterial):');
175
+ (data.materials as string[]).forEach((m: string) => lines.push(` - ${m}`));
176
+ lines.push('');
177
+ }
178
+
179
+ if (data.edgebanding) {
180
+ lines.push('EDGEBANDING (caseEdge / frontEdge):');
181
+ (data.edgebanding as string[]).forEach((e: string) => lines.push(` - ${e}`));
182
+ lines.push('');
183
+ }
184
+
185
+ if (data.jointMethod) {
186
+ lines.push('JOINT METHOD (jointMethod):');
187
+ (data.jointMethod as string[]).forEach((v: string) => lines.push(` - ${v}`));
188
+ lines.push('');
189
+ }
190
+
191
+ if (data.drawerType) {
192
+ lines.push('DRAWER TYPE (drawerType):');
193
+ (data.drawerType as string[]).forEach((v: string) => lines.push(` - ${v}`));
194
+ lines.push('');
195
+ }
196
+
197
+ if (data.hingePlate) {
198
+ lines.push('HINGE PLATE (hingePlate):');
199
+ (data.hingePlate as string[]).forEach((v: string) => lines.push(` - ${v}`));
200
+ lines.push('');
201
+ }
202
+
203
+ if (data.backPanel) {
204
+ lines.push('BACK PANEL METHOD (backPanel):');
205
+ (data.backPanel as string[]).forEach((v: string) => lines.push(` - ${v}`));
206
+ lines.push('');
207
+ }
208
+
209
+ if (data.numOfShelves) {
210
+ lines.push('NUMBER OF ADJUSTABLE SHELVES (numOfShelves):');
211
+ lines.push(` - ${(data.numOfShelves as string[]).join(', ')}`);
212
+ lines.push('');
213
+ }
214
+
215
+ return lines.join('\n');
216
+ } catch (error) {
217
+ try { handleAxiosError(error); } catch (e: any) { return e.message; }
218
+ return 'Unexpected error loading article options.';
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Tool: get_configuration_info (single-field deep dive)
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export async function getConfigurationInfo(
227
+ input: z.infer<typeof GetConfigInfoSchema>
228
+ ): Promise<string> {
229
+ const content = readTooltip(input.field);
230
+ return content ?? `No information available for field: ${input.field}`;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Exports
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export const configurationInfoTools = [
238
+ {
239
+ name: 'get_article_options',
240
+ description: `Fetch the valid selectable values for all applicable configuration fields for a cabinet article. Returns the exact strings to use when setting caseMaterial, frontMaterial, backPanelMaterial, innerCaseMaterial, caseEdge, frontEdge, jointMethod, drawerType, hingePlate, backPanel, and numOfShelves in create_order.
241
+
242
+ Call this immediately after get_article_context when a user selects an article. Only fields applicable to the article are returned (e.g. if the article has no doors, hingePlate options are not included).
243
+
244
+ The values returned are the exact strings the order system expects — do not use variations or paraphrased values.
245
+
246
+ CRITICAL RULE: Present these options to the user and ask them to choose. Do NOT pick a value on the user's behalf. If the user's source document (drawing, spec sheet, etc.) does not explicitly call out a value for a field, it is missing — ask the user before proceeding.`,
247
+ inputSchema: GetArticleOptionsSchema,
248
+ handler: getArticleOptions,
249
+ },
250
+ {
251
+ name: 'get_article_context',
252
+ description: `Load all configuration context for a cabinet article in a single call. Call this immediately when a user selects or expresses interest in an article — before asking them any configuration questions.
253
+
254
+ It returns:
255
+ - Dimension ranges (valid height/width/depth for this article)
256
+ - All applicable configuration options with full guidance (only fields that apply to this specific article based on its feature flags)
257
+
258
+ After calling this, immediately call get_article_options to load the valid selectable values for all applicable fields. Then present the user with a brief summary of all applicable options and their choices.
259
+
260
+ CRITICAL RULE: After presenting the options, you MUST ask the user to specify each value explicitly. Do NOT auto-select, guess, or default any configuration field — not material, not edgebanding, not joint method, not drawer type, nothing. If the user provides a drawing or document that does not call out a value, that value is missing and must be asked for. Only proceed to create_order once the user has confirmed every applicable field.
261
+
262
+ Do NOT call get_configuration_info in a loop — this tool already loads the guidance text at once.`,
263
+ inputSchema: GetArticleContextSchema,
264
+ handler: getArticleContext,
265
+ },
266
+ {
267
+ name: 'get_configuration_info',
268
+ description: `Get full detail for a single configuration field. Use this only when the user explicitly asks for more information about a specific option — not as part of the initial article selection flow (use get_article_context for that instead).
269
+
270
+ Available fields: ${VALID_FIELDS.join(', ')}`,
271
+ inputSchema: GetConfigInfoSchema,
272
+ handler: getConfigurationInfo,
273
+ },
274
+ ];
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as apiClientModule from '../client/api-client';
3
+
4
+ vi.mock('../client/api-client', () => ({
5
+ client: { get: vi.fn() },
6
+ handleAxiosError: vi.fn((e) => { throw e; }),
7
+ McpApiError: class McpApiError extends Error {
8
+ constructor(public status: number, message: string) { super(message); }
9
+ },
10
+ }));
11
+
12
+ import { getArticleConfiguration } from './configuration';
13
+
14
+ describe('getArticleConfiguration', () => {
15
+ beforeEach(() => vi.clearAllMocks());
16
+
17
+ it('returns configuration fields', async () => {
18
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({
19
+ data: {
20
+ serialNumber: 'B30',
21
+ heightRange: '700-900',
22
+ widthRange: '300-900',
23
+ depthRange: '560-560',
24
+ doors: true,
25
+ drawers: false,
26
+ },
27
+ });
28
+
29
+ const result = await getArticleConfiguration({ serialNumber: 'B30' });
30
+ expect(vi.mocked(apiClientModule.client.get)).toHaveBeenCalledWith('/catalog/B30/configuration');
31
+ expect(result).toContain('700-900');
32
+ expect(result).toContain('doors');
33
+ });
34
+
35
+ it('returns error message when article not found', async () => {
36
+ const err = new (apiClientModule.McpApiError as any)(404, 'Article UNKNOWN not found');
37
+ vi.mocked(apiClientModule.client.get).mockRejectedValue(err);
38
+ vi.mocked(apiClientModule.handleAxiosError).mockImplementation(() => { throw err; });
39
+
40
+ const result = await getArticleConfiguration({ serialNumber: 'UNKNOWN' });
41
+ expect(result).toContain('not found');
42
+ });
43
+ });
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ import { client, handleAxiosError } from '../client/api-client';
3
+
4
+ const GetConfigSchema = z.object({
5
+ serialNumber: z.string().describe('Article serial number'),
6
+ });
7
+
8
+ export async function getArticleConfiguration(input: z.infer<typeof GetConfigSchema>): Promise<string> {
9
+ try {
10
+ const { data } = await client.get(`/catalog/${input.serialNumber}/configuration`);
11
+ return JSON.stringify(data, null, 2);
12
+ } catch (error) {
13
+ try { handleAxiosError(error); } catch (e: any) { return e.message; }
14
+ return 'Unexpected error fetching configuration.';
15
+ }
16
+ }
17
+
18
+ export const configurationTools = [
19
+ {
20
+ name: 'get_article_configuration',
21
+ description: 'Get the raw feature flags and dimension ranges for a cabinet article. For guided user-facing configuration, use get_article_context instead — it loads all applicable options and guidance in one call. Use this tool only when you need the raw boolean flags without the guidance text.',
22
+ inputSchema: GetConfigSchema,
23
+ handler: getArticleConfiguration,
24
+ },
25
+ ];
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as apiClientModule from '../client/api-client';
3
+
4
+ vi.mock('../client/api-client', () => ({
5
+ client: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
6
+ handleAxiosError: vi.fn((e) => { throw e; }),
7
+ McpApiError: class McpApiError extends Error {
8
+ constructor(public status: number, message: string) { super(message); }
9
+ },
10
+ }));
11
+
12
+ import { listOrders, getOrder, createOrder, createSavedCart, getSavedCart, updateSavedCartArticles, deleteSavedCartArticles } from './orders';
13
+
14
+ describe('listOrders', () => {
15
+ beforeEach(() => vi.clearAllMocks());
16
+
17
+ it('calls /orders with limit and offset', async () => {
18
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({
19
+ data: [{ orderId: 'ORD-001', status: 'PROCESSING' }],
20
+ });
21
+
22
+ const result = await listOrders({ limit: 10, offset: 0 });
23
+ expect(vi.mocked(apiClientModule.client.get)).toHaveBeenCalledWith('/orders', {
24
+ params: { limit: 10, offset: 0 },
25
+ });
26
+ expect(result).toContain('ORD-001');
27
+ });
28
+
29
+ it('returns "No orders found." when result is empty', async () => {
30
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({ data: [] });
31
+ const result = await listOrders({ limit: 20, offset: 0 });
32
+ expect(result).toBe('No orders found.');
33
+ });
34
+ });
35
+
36
+ describe('getOrder', () => {
37
+ beforeEach(() => vi.clearAllMocks());
38
+
39
+ it('returns order details', async () => {
40
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({
41
+ data: { orderId: 'ORD-001', status: 'SHIPPED' },
42
+ });
43
+
44
+ const result = await getOrder({ orderId: 'ORD-001' });
45
+ expect(result).toContain('SHIPPED');
46
+ });
47
+ });
48
+
49
+ describe('createOrder', () => {
50
+ beforeEach(() => vi.clearAllMocks());
51
+
52
+ it('posts order and returns new orderId', async () => {
53
+ vi.mocked(apiClientModule.client.post).mockResolvedValue({
54
+ data: { orderId: 'ORD-NEW-001' },
55
+ });
56
+
57
+ const result = await createOrder({
58
+ articles: [{ serialNumber: 'B30', quantity: 1, height: 720, width: 600, depth: 560 }],
59
+ shippingAddress: { address1: '123 Main St', city: 'Brooklyn', state: 'NY', zipcode: '11201' },
60
+ });
61
+
62
+ expect(result).toContain('ORD-NEW-001');
63
+ });
64
+ });
65
+
66
+ describe('createSavedCart', () => {
67
+ beforeEach(() => vi.clearAllMocks());
68
+
69
+ const validInput = {
70
+ articles: [{
71
+ serialNumber: 'B30',
72
+ positionName: 'Base',
73
+ quantity: 1,
74
+ height: 720,
75
+ width: 600,
76
+ depth: 560,
77
+ }],
78
+ projectName: 'My Kitchen',
79
+ purchaseOrder: 'PO-001',
80
+ };
81
+
82
+ it('posts to /saved-carts and returns success message with tempOrderId', async () => {
83
+ vi.mocked(apiClientModule.client.post).mockResolvedValue({
84
+ data: { tempOrderId: 'SavedCart_1234567890' },
85
+ });
86
+
87
+ const result = await createSavedCart(validInput);
88
+
89
+ expect(vi.mocked(apiClientModule.client.post)).toHaveBeenCalledWith(
90
+ '/saved-carts',
91
+ expect.any(Object)
92
+ );
93
+ expect(result).toContain('SavedCart_1234567890');
94
+ expect(result).toContain('thesealab.com');
95
+ expect(result).toContain('Saved Carts');
96
+ });
97
+
98
+ it('returns error string for duplicate positionNames without calling API', async () => {
99
+ const input = {
100
+ ...validInput,
101
+ articles: [
102
+ { ...validInput.articles[0] },
103
+ { ...validInput.articles[0] }, // duplicate positionName 'Base'
104
+ ],
105
+ };
106
+
107
+ const result = await createSavedCart(input);
108
+
109
+ expect(result).toContain('duplicate positionName');
110
+ expect(vi.mocked(apiClientModule.client.post)).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('returns "Invalid API key" on 401', async () => {
114
+ const err = new (apiClientModule.McpApiError as any)(401, 'Invalid API key');
115
+ vi.mocked(apiClientModule.client.post).mockRejectedValue(err);
116
+ vi.mocked(apiClientModule.handleAxiosError).mockImplementation(() => { throw err; });
117
+
118
+ const result = await createSavedCart(validInput);
119
+ expect(result).toContain('Invalid API key');
120
+ });
121
+ });
122
+
123
+ describe('getSavedCart', () => {
124
+ beforeEach(() => vi.clearAllMocks());
125
+
126
+ const savedCartResponse = {
127
+ tempOrderId: 'SavedCart_123',
128
+ projectName: 'My Kitchen',
129
+ purchaseOrder: 'PO-001',
130
+ articles: [
131
+ {
132
+ positionName: 'Base',
133
+ serialNumber: 'B30',
134
+ displayName: 'Base Cabinet 30"',
135
+ height: 34.5,
136
+ width: 30,
137
+ depth: 24,
138
+ caseMaterial: 'Maple',
139
+ frontMaterial: null,
140
+ excludeFronts: false,
141
+ edgeBandingType: 'LP_SP',
142
+ includeLegLevelers: true,
143
+ },
144
+ ],
145
+ };
146
+
147
+ it('returns formatted string with cart and article details', async () => {
148
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({ data: savedCartResponse });
149
+
150
+ const result = await getSavedCart({ tempOrderId: 'SavedCart_123' });
151
+
152
+ expect(result).toContain('Saved cart: SavedCart_123');
153
+ expect(result).toContain('My Kitchen');
154
+ expect(result).toContain('Base');
155
+ expect(result).toContain('B30');
156
+ expect(result).toContain('LP_SP');
157
+ expect(result).toContain('Leg Levelers: true');
158
+ expect(result).toContain('1 total');
159
+ });
160
+
161
+ it('omits null fields and false booleans from the output', async () => {
162
+ vi.mocked(apiClientModule.client.get).mockResolvedValue({ data: savedCartResponse });
163
+
164
+ const result = await getSavedCart({ tempOrderId: 'SavedCart_123' });
165
+
166
+ // frontMaterial is null — should not appear
167
+ expect(result).not.toContain('Front:');
168
+ // excludeFronts is false — should not appear (only shown when true)
169
+ expect(result).not.toContain('Exclude Fronts');
170
+ });
171
+
172
+ it('propagates 401 error from handleAxiosError', async () => {
173
+ const err = new (apiClientModule.McpApiError as any)(401, 'Invalid API key');
174
+ vi.mocked(apiClientModule.client.get).mockRejectedValue(err);
175
+ vi.mocked(apiClientModule.handleAxiosError).mockImplementation(() => { throw err; });
176
+
177
+ const result = await getSavedCart({ tempOrderId: 'SavedCart_123' });
178
+ expect(result).toContain('Invalid API key');
179
+ });
180
+ });
181
+
182
+ describe('updateSavedCartArticles', () => {
183
+ beforeEach(() => vi.clearAllMocks());
184
+
185
+ it('patches articles and returns updated count string', async () => {
186
+ vi.mocked(apiClientModule.client.patch).mockResolvedValue({ data: { updated: 2 } });
187
+
188
+ const result = await updateSavedCartArticles({
189
+ tempOrderId: 'SavedCart_123',
190
+ articles: [
191
+ { positionName: 'Base', caseMaterial: 'Oak' },
192
+ { positionName: 'Upper', caseMaterial: 'Oak' },
193
+ ],
194
+ });
195
+
196
+ expect(vi.mocked(apiClientModule.client.patch)).toHaveBeenCalledWith(
197
+ '/saved-carts/SavedCart_123/articles',
198
+ { articles: expect.any(Array) }
199
+ );
200
+ expect(result).toBe('Updated 2 article(s) in saved cart SavedCart_123.');
201
+ });
202
+
203
+ it('returns error string for duplicate positionNames without calling API', async () => {
204
+ const result = await updateSavedCartArticles({
205
+ tempOrderId: 'SavedCart_123',
206
+ articles: [
207
+ { positionName: 'Base', caseMaterial: 'Oak' },
208
+ { positionName: 'Base', caseMaterial: 'Maple' },
209
+ ],
210
+ });
211
+
212
+ expect(result).toContain('duplicate positionName');
213
+ expect(vi.mocked(apiClientModule.client.patch)).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it('propagates 422 error from handleAxiosError', async () => {
217
+ const err = new (apiClientModule.McpApiError as any)(422, 'Article(s) not found: Base');
218
+ vi.mocked(apiClientModule.client.patch).mockRejectedValue(err);
219
+ vi.mocked(apiClientModule.handleAxiosError).mockImplementation(() => { throw err; });
220
+
221
+ const result = await updateSavedCartArticles({
222
+ tempOrderId: 'SavedCart_123',
223
+ articles: [{ positionName: 'Base', caseMaterial: 'Oak' }],
224
+ });
225
+
226
+ expect(result).toContain('Article(s) not found: Base');
227
+ });
228
+ });
229
+
230
+ describe('deleteSavedCartArticles', () => {
231
+ beforeEach(() => vi.clearAllMocks());
232
+
233
+ it('deletes articles and returns deleted count and names string', async () => {
234
+ vi.mocked(apiClientModule.client.delete).mockResolvedValue({ data: { deleted: 2 } });
235
+
236
+ const result = await deleteSavedCartArticles({
237
+ tempOrderId: 'SavedCart_123',
238
+ positionNames: ['Base', 'Upper'],
239
+ });
240
+
241
+ expect(vi.mocked(apiClientModule.client.delete)).toHaveBeenCalledWith(
242
+ '/saved-carts/SavedCart_123/articles',
243
+ { data: { positionNames: ['Base', 'Upper'] } }
244
+ );
245
+ expect(result).toBe('Deleted 2 article(s) from saved cart SavedCart_123: [Base, Upper].');
246
+ });
247
+
248
+ it('propagates 422 error from handleAxiosError', async () => {
249
+ const err = new (apiClientModule.McpApiError as any)(422, 'Article(s) not found: Base');
250
+ vi.mocked(apiClientModule.client.delete).mockRejectedValue(err);
251
+ vi.mocked(apiClientModule.handleAxiosError).mockImplementation(() => { throw err; });
252
+
253
+ const result = await deleteSavedCartArticles({
254
+ tempOrderId: 'SavedCart_123',
255
+ positionNames: ['Base'],
256
+ });
257
+
258
+ expect(result).toContain('Article(s) not found: Base');
259
+ });
260
+ });