@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.
- package/PROPOSED-CHANGES-INSERTION-POINTS.md +220 -0
- package/SEALAB_MCP_DOCUMENTATION.md +1136 -0
- package/dist/client/api-client.js +44 -0
- package/dist/index.js +42 -0
- package/dist/tools/canvas.js +446 -0
- package/dist/tools/catalog.js +95 -0
- package/dist/tools/configuration-info.js +299 -0
- package/dist/tools/configuration.js +32 -0
- package/dist/tools/orders.js +1267 -0
- package/dist/tools/saved-settings.js +271 -0
- package/package.json +32 -0
- package/resources/tooltips/backPanel.txt +17 -0
- package/resources/tooltips/backPanelMaterial.txt +29 -0
- package/resources/tooltips/caseEdge.txt +18 -0
- package/resources/tooltips/caseMaterial.txt +31 -0
- package/resources/tooltips/depth.txt +11 -0
- package/resources/tooltips/drawerType.txt +12 -0
- package/resources/tooltips/edgeBandingType.txt +18 -0
- package/resources/tooltips/excludeFronts.txt +5 -0
- package/resources/tooltips/frontEdge.txt +18 -0
- package/resources/tooltips/frontMaterial.txt +35 -0
- package/resources/tooltips/gapBottom.txt +2 -0
- package/resources/tooltips/gapCenter.txt +2 -0
- package/resources/tooltips/gapLeft.txt +15 -0
- package/resources/tooltips/gapRight.txt +15 -0
- package/resources/tooltips/gapTop.txt +2 -0
- package/resources/tooltips/height.txt +6 -0
- package/resources/tooltips/hingePlate.txt +11 -0
- package/resources/tooltips/includeLegLevelers.txt +8 -0
- package/resources/tooltips/jointMethod.txt +7 -0
- package/resources/tooltips/leftCornerWidth.txt +2 -0
- package/resources/tooltips/numOfShelves.txt +6 -0
- package/resources/tooltips/positionName.txt +3 -0
- package/resources/tooltips/rightCornerDepth.txt +2 -0
- package/resources/tooltips/topDrwrHeight.txt +8 -0
- package/resources/tooltips/width.txt +5 -0
- package/src/client/api-client.ts +37 -0
- package/src/index.ts +52 -0
- package/src/tools/canvas.ts +442 -0
- package/src/tools/catalog.test.ts +61 -0
- package/src/tools/catalog.ts +80 -0
- package/src/tools/configuration-info.ts +274 -0
- package/src/tools/configuration.test.ts +43 -0
- package/src/tools/configuration.ts +25 -0
- package/src/tools/orders.test.ts +260 -0
- package/src/tools/orders.ts +1229 -0
- package/src/tools/saved-settings.ts +241 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<h1>Cabinet Width</h1>
|
|
2
|
+
|
|
3
|
+
<p>The <strong>width</strong> represents the horizontal dimension of the cabinet, measured from the left side to the right side of the carcass. This measurement does not include any additional elements like side panels, fillers, or countertops-it only refers to the case of the cabinet.</p>
|
|
4
|
+
|
|
5
|
+
<p><strong>Note:</strong> The width of the cabinet may differ from the width of the fronts. For more information on front sizing, refer to the <strong>GAP SETTINGS</strong> page.</p>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import axios, { AxiosInstance, AxiosError } from 'axios';
|
|
2
|
+
|
|
3
|
+
const API_KEY = process.env.SEALAB_API_KEY;
|
|
4
|
+
const API_URL = process.env.SEALAB_API_URL ?? 'https://thesealab.com';
|
|
5
|
+
|
|
6
|
+
if (!API_KEY) {
|
|
7
|
+
throw new Error('SEALAB_API_KEY environment variable is required');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const client: AxiosInstance = axios.create({
|
|
11
|
+
baseURL: `${API_URL}/api/mcp/v1`,
|
|
12
|
+
headers: {
|
|
13
|
+
'X-API-Key': API_KEY,
|
|
14
|
+
},
|
|
15
|
+
timeout: 10000,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export class McpApiError extends Error {
|
|
19
|
+
constructor(public readonly status: number, message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'McpApiError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function handleAxiosError(error: unknown): never {
|
|
26
|
+
if (axios.isAxiosError(error)) {
|
|
27
|
+
const axiosErr = error as AxiosError<{ error?: string }>;
|
|
28
|
+
const status = axiosErr.response?.status ?? 0;
|
|
29
|
+
const message = axiosErr.response?.data?.error ?? axiosErr.message;
|
|
30
|
+
|
|
31
|
+
if (status === 401) throw new McpApiError(401, 'Invalid API key');
|
|
32
|
+
if (status === 404) throw new McpApiError(404, message);
|
|
33
|
+
if (status === 422) throw new McpApiError(422, message);
|
|
34
|
+
throw new McpApiError(status, `API error: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
throw new McpApiError(0, 'Unable to reach Sealab API — check SEALAB_API_URL');
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
6
|
+
|
|
7
|
+
import { catalogTools } from './tools/catalog';
|
|
8
|
+
import { orderTools } from './tools/orders';
|
|
9
|
+
import { configurationTools } from './tools/configuration';
|
|
10
|
+
import { configurationInfoTools } from './tools/configuration-info';
|
|
11
|
+
import { savedSettingsTools } from './tools/saved-settings';
|
|
12
|
+
import { canvasTools } from './tools/canvas';
|
|
13
|
+
|
|
14
|
+
const allTools = [...catalogTools, ...orderTools, ...configurationTools, ...configurationInfoTools, ...savedSettingsTools, ...canvasTools];
|
|
15
|
+
|
|
16
|
+
const server = new Server(
|
|
17
|
+
{ name: 'sealab', version: '1.0.0' },
|
|
18
|
+
{ capabilities: { tools: {} } }
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
22
|
+
tools: allTools.map((t) => ({
|
|
23
|
+
name: t.name,
|
|
24
|
+
description: t.description,
|
|
25
|
+
inputSchema: zodToJsonSchema(t.inputSchema as any, { target: 'openApi3' }),
|
|
26
|
+
})),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
30
|
+
const tool = allTools.find((t) => t.name === request.params.name);
|
|
31
|
+
if (!tool) {
|
|
32
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parsed = tool.inputSchema.safeParse(request.params.arguments ?? {});
|
|
36
|
+
if (!parsed.success) {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: 'text', text: `Invalid input: ${parsed.error.message}` }],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const result = await (tool.handler as (input: any) => Promise<string>)(parsed.data);
|
|
44
|
+
return { content: [{ type: 'text', text: result }] };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const transport = new StdioServerTransport();
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { client, handleAxiosError } from '../client/api-client';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import FormData from 'form-data';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Schemas
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const GetDrawingToolStateSchema = z.object({
|
|
12
|
+
orderId: z.string().describe('Order ID to retrieve full drawing tool state for (metadata + images + coordinates).'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const GetCanvasMetadataSchema = z.object({
|
|
16
|
+
orderId: z.string().describe('Order ID to retrieve canvas metadata for.'),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const GetCanvasImagesSchema = z.object({
|
|
20
|
+
orderId: z.string().describe('Order ID to retrieve canvas image records for.'),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const GetCabinetCoordinatesSchema = z.object({
|
|
24
|
+
orderId: z.string().describe('Order ID to retrieve cabinet coordinates for.'),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const CoordinateItemSchema = z.object({
|
|
28
|
+
positionName: z.string().describe(
|
|
29
|
+
'Position name of the cabinet as stored in the order (e.g. "Base_01", "Upper_01"). ' +
|
|
30
|
+
'Must match the positionName used in the order articles exactly. ' +
|
|
31
|
+
'MULTI-QUANTITY: if a cabinet was added with quantity > 1, the server expanded it into individual ' +
|
|
32
|
+
'articles with incrementing suffixes (e.g. "DR_B2_01" qty=3 → "DR_B2_01", "DR_B2_02", "DR_B2_03"). ' +
|
|
33
|
+
'You must include a separate coordinate entry for EACH expanded name — one entry for "DR_B2_01" ' +
|
|
34
|
+
'alone does not set coordinates for "DR_B2_02" or "DR_B2_03".'
|
|
35
|
+
),
|
|
36
|
+
x: z.number().describe(
|
|
37
|
+
'Center X coordinate in decimal inches. Absolute room coordinate: 0 = west wall face, room_width = east wall face.\n' +
|
|
38
|
+
'North/South wall (rotation=0/180): variable x — position along the wall.\n' +
|
|
39
|
+
' x = D_adj_west + (sum of widths of cabinets to the LEFT on this wall + any filler) + this_cabinet_width / 2.\n' +
|
|
40
|
+
' D_adj_west = depth of west-wall cabinets if west wall cabinets exist in this layout, else 0.\n' +
|
|
41
|
+
' CORNER CLEARANCE: north/south wall cabinets must start at x=D_adj_west, NOT x=0.\n' +
|
|
42
|
+
' SCOPE: D_adj_west applies ONLY to north/south wall x — it does NOT affect east/west wall x.\n' +
|
|
43
|
+
'West wall (rotation=270): back-edge based — x is the BACK-EDGE coordinate (distance from west wall to cabinet BACK).\n' +
|
|
44
|
+
' Base cabinet: x = 0 (back edge against west wall).\n' +
|
|
45
|
+
' Shallower cabinet (e.g. upper) front-flush with base: x = base_depth - upper_depth.\n' +
|
|
46
|
+
' Example: 24" base + 15" upper → x = 24 - 15 = 9".\n' +
|
|
47
|
+
' Verification: base front edge at 0+24=24", upper front edge at 9+15=24" ✅ MATCH.\n' +
|
|
48
|
+
'East wall (rotation=90): back-edge based — x is the BACK-EDGE coordinate (distance from west wall to cabinet BACK).\n' +
|
|
49
|
+
' Base cabinet: x = room_width (back edge against east wall). Requires room_width from drawing.\n' +
|
|
50
|
+
' Shallower cabinet front-flush: x = base_x - (base_depth - upper_depth).\n' +
|
|
51
|
+
' Example: base x=120 (24" deep) + 15" upper → upper x = 120 - 9 = 111".\n' +
|
|
52
|
+
' Verification: base front at 120-24=96", upper front at 111-15=96" ✅ MATCH.\n' +
|
|
53
|
+
' CRITICAL: Upper x = base_x - (base_depth - upper_depth). Just offset from base — no room dimension math.\n' +
|
|
54
|
+
' IF room_width IS UNKNOWN: ask the user for room dimensions from the drawing to set base x.\n' +
|
|
55
|
+
'Values from get_cabinet_coordinates are already center X; pass directly for round-trip operations.'
|
|
56
|
+
),
|
|
57
|
+
y: z.number().describe(
|
|
58
|
+
'Back-edge Y coordinate in decimal inches. Absolute room coordinate: 0 = south wall face, room_depth = north wall face.\n' +
|
|
59
|
+
'CRITICAL: Y is the BACK-EDGE coordinate (distance from south wall face to cabinet BACK), NOT center.\n' +
|
|
60
|
+
'South wall (rotation=180): back-edge based — y is distance from south wall face to cabinet BACK.\n' +
|
|
61
|
+
' Base cabinet: y = 0 (back edge against south wall).\n' +
|
|
62
|
+
' Shallower cabinet (e.g. upper) front-flush with base: y = base_depth - upper_depth.\n' +
|
|
63
|
+
' Example: 24" base + 15" upper → y = 24 - 15 = 9". Front edges align at 24".\n' +
|
|
64
|
+
'North wall (rotation=0): back-edge based — y is distance from south wall face to cabinet BACK.\n' +
|
|
65
|
+
' Base cabinet: y = room_depth (back edge against north wall). Requires room_depth from drawing.\n' +
|
|
66
|
+
' Shallower cabinet front-flush: y = base_y - (base_depth - upper_depth).\n' +
|
|
67
|
+
' Example: base y=120 (24" deep) + 15" upper → upper y = 120 - 9 = 111".\n' +
|
|
68
|
+
' Verification: base front at 120-24=96", upper front at 111-15=96" ✅ MATCH.\n' +
|
|
69
|
+
' CRITICAL: Upper y = base_y - (base_depth - upper_depth). Just offset from base — no room dimension math.\n' +
|
|
70
|
+
' IF room_depth IS UNKNOWN: ask the user for room dimensions from the drawing to set base y.\n' +
|
|
71
|
+
'East wall (rotation=90): variable y — position along the wall (north-south).\n' +
|
|
72
|
+
' "Width" = cabinet\'s own width dimension, running north-south after rotation.\n' +
|
|
73
|
+
' East/west wall cabinets run from the north wall SOUTHWARD (decreasing y). List them north-to-south.\n' +
|
|
74
|
+
' D_adj_north = the catalog DEPTH of the north-wall BASE cabinet (the "depth" used in north wall y = room_depth).\n' +
|
|
75
|
+
' Example: north wall has 24"-deep base cabinets → D_adj_north = 24.\n' +
|
|
76
|
+
' NEVER set D_adj_north = 0 when north wall cabinets exist — that places east/west cabinet inside the north wall footprint.\n' +
|
|
77
|
+
' Northernmost cabinet (1st in list): y = room_depth - D_adj_north - this_width / 2.\n' +
|
|
78
|
+
' Each next cabinet going south: subtract all preceding widths.\n' +
|
|
79
|
+
' General: y = room_depth - D_adj_north - (sum of widths of all cabinets listed before this one) - this_width / 2.\n' +
|
|
80
|
+
' y DECREASES with each cabinet going south. NEVER add widths — adding goes north (wrong direction).\n' +
|
|
81
|
+
'West wall (rotation=270): y = same formula as east wall.\n' +
|
|
82
|
+
'EAST/WEST UPPER CABINETS: DO NOT compute y for uppers — copy the base cabinet y value exactly. No formula. No D_adj_north.\n' +
|
|
83
|
+
'Front-flush on east/west walls is achieved by changing X only — y is unchanged from the base.\n' +
|
|
84
|
+
'Do NOT apply the north/south front-flush y formulas to east/west wall cabinets.'
|
|
85
|
+
),
|
|
86
|
+
z: z.number().describe(
|
|
87
|
+
'Height from floor in decimal inches. ' +
|
|
88
|
+
'0 for base cabinets sitting on the floor. ' +
|
|
89
|
+
'Stacked units: sum of the heights of all cabinets below this one.'
|
|
90
|
+
),
|
|
91
|
+
rotation: z.number().describe(
|
|
92
|
+
'Identifies which wall this cabinet is on and its orientation in CAD. ' +
|
|
93
|
+
'0 = north wall, 90 = east wall, 180 = south wall, 270 = west wall. ' +
|
|
94
|
+
'If the drawing has no compass rose, assume top = north, bottom = south, left = west, right = east. ' +
|
|
95
|
+
'All cabinets on the same wall share the same rotation value. ' +
|
|
96
|
+
'Default 0 for single-wall or north-wall installations.'
|
|
97
|
+
),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const UpdateCabinetCoordinatesSchema = z.object({
|
|
101
|
+
orderId: z.string().describe(
|
|
102
|
+
'Order ID or saved cart tempOrderId whose coordinates will be replaced. ' +
|
|
103
|
+
'Accepts both placed order IDs (e.g. "307104_v2") and saved cart reference IDs (e.g. "623265"). ' +
|
|
104
|
+
'Use the saved cart tempOrderId to set coordinates on a cart BEFORE submitting — ' +
|
|
105
|
+
'this is the correct workflow so coordinates are passed directly to the XML at submit time.'
|
|
106
|
+
),
|
|
107
|
+
coordinates: z.array(CoordinateItemSchema).describe(
|
|
108
|
+
'Full replacement list of cabinet coordinates. ALL existing coordinates for this order or cart are deleted ' +
|
|
109
|
+
'and replaced with this list. Omit a cabinet from this list to remove its stored coordinates.'
|
|
110
|
+
),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const PatchCabinetCoordinatesSchema = z.object({
|
|
114
|
+
orderId: z.string().describe(
|
|
115
|
+
'Order ID or saved cart tempOrderId whose coordinates will be partially updated. ' +
|
|
116
|
+
'Accepts both placed order IDs (e.g. "307104_v2") and saved cart reference IDs (e.g. "623265").'
|
|
117
|
+
),
|
|
118
|
+
coordinates: z.array(CoordinateItemSchema).min(1).describe(
|
|
119
|
+
'Partial list of cabinet coordinates to update. Only the positionNames included here are affected — ' +
|
|
120
|
+
'all other cabinets keep their existing coordinates unchanged. ' +
|
|
121
|
+
'Use this tool instead of update_cabinet_coordinates when you need to move or reposition one or a few ' +
|
|
122
|
+
'cabinets without disturbing the rest of the layout.'
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
const UploadCanvasBackgroundSchema = z.object({
|
|
128
|
+
orderId: z.string().describe('Order ID whose canvas background image will be replaced.'),
|
|
129
|
+
canvasType: z.enum(['plan', 'north', 'south', 'east', 'west']).describe(
|
|
130
|
+
'Canvas type to upload the background image for. ' +
|
|
131
|
+
'"plan" is the floor plan view; "north", "south", "east", "west" are elevation views.'
|
|
132
|
+
),
|
|
133
|
+
imagePath: z.string().describe(
|
|
134
|
+
'Absolute path to the image file on the local filesystem. ' +
|
|
135
|
+
'Supported formats: .png, .jpg / .jpeg, .webp. ' +
|
|
136
|
+
'The MCP server reads and encodes the file — do not convert it yourself. ' +
|
|
137
|
+
'The existing background image for this canvas type is automatically deleted from S3 and replaced.'
|
|
138
|
+
),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const CanvasMetadataFieldsSchema = z.object({
|
|
142
|
+
orderId: z.string().describe('Order ID whose canvas metadata will be replaced.'),
|
|
143
|
+
canvasMeta: z.record(z.unknown()).optional().describe(
|
|
144
|
+
'Per-canvas display metadata keyed by canvas type (plan, north, south, east, west). ' +
|
|
145
|
+
'Each entry contains scale (ratio, unit), position (x, y), rotation, zoom, and backgroundImage (S3 URL).'
|
|
146
|
+
),
|
|
147
|
+
z0References: z.record(z.unknown()).optional().describe(
|
|
148
|
+
'Floor reference line calibration per elevation canvas. ' +
|
|
149
|
+
'Each entry contains imagePercentFromBottom, originalImageDimensions, realWorldHeight, and timestamp.'
|
|
150
|
+
),
|
|
151
|
+
verticalCalibration: z.record(z.unknown()).optional().describe(
|
|
152
|
+
'Vertical height mapping calibration per elevation canvas. ' +
|
|
153
|
+
'Each entry contains scale, offset, unit, and timestamp.'
|
|
154
|
+
),
|
|
155
|
+
elevationVisibility: z.record(z.unknown()).optional().describe(
|
|
156
|
+
'Item visibility per elevation, keyed by itemId. ' +
|
|
157
|
+
'Each entry is a map of elevation name to boolean (e.g. {"north": true, "south": false}).'
|
|
158
|
+
),
|
|
159
|
+
calibrationOffsets: z.record(z.unknown()).optional().describe(
|
|
160
|
+
'Coordinate positioning offsets per canvas type. ' +
|
|
161
|
+
'Each entry contains x and y offset values in pixels.'
|
|
162
|
+
),
|
|
163
|
+
itemRotations: z.record(z.unknown()).optional().describe(
|
|
164
|
+
'Item-specific rotation values keyed by itemId. Values are rotation in degrees.'
|
|
165
|
+
),
|
|
166
|
+
activeCanvases: z.array(z.string()).optional().describe(
|
|
167
|
+
'List of active canvas types. Valid values: "plan", "north", "south", "east", "west".'
|
|
168
|
+
),
|
|
169
|
+
fixedElevationPositions: z.record(z.unknown()).optional().describe(
|
|
170
|
+
'Pinned cabinet positions after global calibration, keyed by elevation then positionName. ' +
|
|
171
|
+
'Each position entry contains x and y pixel coordinates.'
|
|
172
|
+
),
|
|
173
|
+
elevationCalibrated: z.record(z.unknown()).optional().describe(
|
|
174
|
+
'Global calibration status per elevation. Keys are elevation names, values are booleans.'
|
|
175
|
+
),
|
|
176
|
+
globalElevationOffsets: z.record(z.unknown()).optional().describe(
|
|
177
|
+
'Coordinate system transformations per elevation (maps plan coordinates to elevation coordinates). ' +
|
|
178
|
+
'Each entry contains x and y offset values in inches.'
|
|
179
|
+
),
|
|
180
|
+
calibratedCabinets: z.record(z.unknown()).optional().describe(
|
|
181
|
+
'Which cabinets have been globally calibrated per elevation. ' +
|
|
182
|
+
'Keyed by elevation then positionName, values are booleans.'
|
|
183
|
+
),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Handlers
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
async function getDrawingToolState(input: z.infer<typeof GetDrawingToolStateSchema>): Promise<string> {
|
|
191
|
+
try {
|
|
192
|
+
const { data } = await client.get(`/canvas/${input.orderId}`);
|
|
193
|
+
return JSON.stringify(data, null, 2);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
196
|
+
return 'Unexpected error fetching drawing tool state.';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function getCanvasMetadata(input: z.infer<typeof GetCanvasMetadataSchema>): Promise<string> {
|
|
201
|
+
try {
|
|
202
|
+
const { data } = await client.get(`/canvas/${input.orderId}/metadata`);
|
|
203
|
+
return JSON.stringify(data, null, 2);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
206
|
+
return 'Unexpected error fetching canvas metadata.';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function updateCanvasMetadata(input: z.infer<typeof CanvasMetadataFieldsSchema>): Promise<string> {
|
|
211
|
+
const { orderId, ...fields } = input;
|
|
212
|
+
try {
|
|
213
|
+
const { data } = await client.put(`/canvas/${orderId}/metadata`, fields);
|
|
214
|
+
return JSON.stringify(data, null, 2);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
217
|
+
return 'Unexpected error updating canvas metadata.';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getCanvasImages(input: z.infer<typeof GetCanvasImagesSchema>): Promise<string> {
|
|
222
|
+
try {
|
|
223
|
+
const { data } = await client.get(`/canvas/${input.orderId}/images`);
|
|
224
|
+
return JSON.stringify(data, null, 2);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
227
|
+
return 'Unexpected error fetching canvas images.';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function getCabinetCoordinates(input: z.infer<typeof GetCabinetCoordinatesSchema>): Promise<string> {
|
|
232
|
+
try {
|
|
233
|
+
const { data } = await client.get(`/canvas/${input.orderId}/coordinates`);
|
|
234
|
+
return JSON.stringify(data, null, 2);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
237
|
+
return 'Unexpected error fetching cabinet coordinates.';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function updateCabinetCoordinates(input: z.infer<typeof UpdateCabinetCoordinatesSchema>): Promise<string> {
|
|
242
|
+
try {
|
|
243
|
+
const { data } = await client.put(`/canvas/${input.orderId}/coordinates`, input.coordinates);
|
|
244
|
+
return JSON.stringify(data, null, 2);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
247
|
+
return 'Unexpected error updating cabinet coordinates.';
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function patchCabinetCoordinates(input: z.infer<typeof PatchCabinetCoordinatesSchema>): Promise<string> {
|
|
252
|
+
try {
|
|
253
|
+
const { data } = await client.patch(`/canvas/${input.orderId}/coordinates`, input.coordinates);
|
|
254
|
+
return JSON.stringify(data, null, 2);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
257
|
+
return 'Unexpected error patching cabinet coordinates.';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
const MIME_TYPES: Record<string, string> = {
|
|
263
|
+
'.png': 'image/png',
|
|
264
|
+
'.jpg': 'image/jpeg',
|
|
265
|
+
'.jpeg': 'image/jpeg',
|
|
266
|
+
'.webp': 'image/webp',
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
async function uploadCanvasBackground(input: z.infer<typeof UploadCanvasBackgroundSchema>): Promise<string> {
|
|
270
|
+
const ext = path.extname(input.imagePath).toLowerCase();
|
|
271
|
+
const mimeType = MIME_TYPES[ext];
|
|
272
|
+
if (!mimeType) {
|
|
273
|
+
return `Unsupported file extension "${ext}". Supported: .png, .jpg, .jpeg, .webp`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!fs.existsSync(input.imagePath)) {
|
|
277
|
+
return `File not found at "${input.imagePath}"`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const form = new FormData();
|
|
281
|
+
form.append('file', fs.createReadStream(input.imagePath), {
|
|
282
|
+
filename: path.basename(input.imagePath),
|
|
283
|
+
contentType: mimeType,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const { data } = await client.post(
|
|
288
|
+
`/canvas/${input.orderId}/images/${input.canvasType}/background`,
|
|
289
|
+
form,
|
|
290
|
+
{ headers: form.getHeaders() }
|
|
291
|
+
);
|
|
292
|
+
return JSON.stringify(data, null, 2);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
try { handleAxiosError(error); } catch (e: any) { return e.message; }
|
|
295
|
+
return 'Unexpected error uploading canvas background image.';
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Exports
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
export const canvasTools = [
|
|
304
|
+
{
|
|
305
|
+
name: 'get_drawing_tool_state',
|
|
306
|
+
description:
|
|
307
|
+
'Retrieve the full drawing tool state for an order in a single call. ' +
|
|
308
|
+
'Returns three sections: ' +
|
|
309
|
+
'(1) metadata — canvas scale, calibration, Z0 floor references, elevation visibility, active canvases, and all other drawing tool state; ' +
|
|
310
|
+
'(2) images — S3 keys for background and snapshot images per canvas type (plan, north, south, east, west); ' +
|
|
311
|
+
'(3) coordinates — all cabinet positions (center X, back-edge Y, Z height, rotation) keyed by positionName. ' +
|
|
312
|
+
'Returns 404 if no drawing tool state exists for this order.',
|
|
313
|
+
inputSchema: GetDrawingToolStateSchema,
|
|
314
|
+
handler: getDrawingToolState,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'get_canvas_metadata',
|
|
318
|
+
description:
|
|
319
|
+
'Retrieve canvas metadata for an order. ' +
|
|
320
|
+
'Includes: activeCanvases, canvasMeta (scale/position/zoom per canvas), z0References (floor calibration per elevation), ' +
|
|
321
|
+
'verticalCalibration, elevationVisibility (which cabinets are visible on which elevations), ' +
|
|
322
|
+
'calibrationOffsets, itemRotations, fixedElevationPositions, elevationCalibrated, ' +
|
|
323
|
+
'globalElevationOffsets, and calibratedCabinets. ' +
|
|
324
|
+
'Use get_drawing_tool_state if you also need images and coordinates.',
|
|
325
|
+
inputSchema: GetCanvasMetadataSchema,
|
|
326
|
+
handler: getCanvasMetadata,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: 'update_canvas_metadata',
|
|
330
|
+
description:
|
|
331
|
+
'Replace all canvas metadata fields for an order. ' +
|
|
332
|
+
'All existing metadata is overwritten with the provided values. ' +
|
|
333
|
+
'Fields not included in the request are set to null — provide all fields you want to preserve. ' +
|
|
334
|
+
'Use get_canvas_metadata first to read the current state before making targeted updates.',
|
|
335
|
+
inputSchema: CanvasMetadataFieldsSchema,
|
|
336
|
+
handler: updateCanvasMetadata,
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: 'get_canvas_images',
|
|
340
|
+
description:
|
|
341
|
+
'Retrieve canvas image records for an order. ' +
|
|
342
|
+
'Returns a list of entries each containing canvasType (plan/north/south/east/west), ' +
|
|
343
|
+
'imageType (background/snapshot), and s3Key (the S3 path to the image file). ' +
|
|
344
|
+
'Background images are the floor plan or elevation photos used for cabinet placement. ' +
|
|
345
|
+
'Snapshot images are rendered exports of the canvas used in order submission.',
|
|
346
|
+
inputSchema: GetCanvasImagesSchema,
|
|
347
|
+
handler: getCanvasImages,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
name: 'get_cabinet_coordinates',
|
|
351
|
+
description:
|
|
352
|
+
'Retrieve all stored cabinet coordinates for an order. ' +
|
|
353
|
+
'Each entry contains positionName, x (center X in inches), y (back-edge Y / depth-direction in inches), ' +
|
|
354
|
+
'z (height from floor in inches), and rotation (degrees). ' +
|
|
355
|
+
'Note: x is center X, not bottom-left. Y is back-edge coordinate (distance from south wall to cabinet BACK). ' +
|
|
356
|
+
'Use these values directly in update_cabinet_coordinates for round-trip operations.',
|
|
357
|
+
inputSchema: GetCabinetCoordinatesSchema,
|
|
358
|
+
handler: getCabinetCoordinates,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'update_cabinet_coordinates',
|
|
362
|
+
description:
|
|
363
|
+
'Replace all cabinet coordinates for a placed order OR a saved cart. ' +
|
|
364
|
+
'Accepts both placed order IDs and saved cart tempOrderIds. ' +
|
|
365
|
+
'\n\n' +
|
|
366
|
+
'CORRECT WORKFLOW FOR SAVED CARTS:\n' +
|
|
367
|
+
'1. create_edit_cart (or use existing saved cart tempOrderId)\n' +
|
|
368
|
+
'2. update_cabinet_coordinates(tempOrderId, [...]) ← call this tool with the cart tempOrderId\n' +
|
|
369
|
+
'3. submit_cart_as_version — coordinates stored on the cart are picked up and written to the XML\n' +
|
|
370
|
+
'Do NOT submit first and update coordinates after — that requires a second redundant version.\n' +
|
|
371
|
+
'\n' +
|
|
372
|
+
'MULTI-QUANTITY ARTICLES: When a cabinet was added with quantity > 1, the server expanded it into\n' +
|
|
373
|
+
'individual articles with incrementing suffixes. Example: positionName "DR_B2_01" with quantity=3\n' +
|
|
374
|
+
'produces three separate cabinets: "DR_B2_01", "DR_B2_02", "DR_B2_03". This tool does NOT\n' +
|
|
375
|
+
'auto-expand — you must list every expanded name explicitly. Setting coordinates only for\n' +
|
|
376
|
+
'"DR_B2_01" leaves "DR_B2_02" and "DR_B2_03" without coordinates in the CAD file.\n' +
|
|
377
|
+
'Rule: for each article with quantity=N, include N entries — one per expanded positionName.\n' +
|
|
378
|
+
'\n' +
|
|
379
|
+
'All existing coordinates are deleted and replaced with the provided list.\n' +
|
|
380
|
+
'x = back-edge X in inches:\n' +
|
|
381
|
+
' North/South wall: x = D_adj_west + cumulative_widths_left + this_width/2 (position along wall). D_adj_west = west wall cabinet depth, else 0.\n' +
|
|
382
|
+
' D_adj_west applies ONLY to north/south wall x — NEVER to east/west wall x.\n' +
|
|
383
|
+
' West wall — base: x = 0 (back against west wall). Upper front-flush: x = base_depth - upper_depth.\n' +
|
|
384
|
+
' East wall — base: x = room_width (back against east wall). Upper front-flush: x = base_x - (base_depth - upper_depth).\n' +
|
|
385
|
+
' CRITICAL: For upper cabinets, use x = base_x - (base_depth - upper_depth). For west wall where base_x=0, simplifies to x = base_depth - upper_depth.\n' +
|
|
386
|
+
'y = back-edge Y in inches — ABSOLUTE room coordinate (0 = south wall face, room_depth = north wall face):\n' +
|
|
387
|
+
' South wall — base: y = 0 (back against south wall). Upper front-flush: y = base_depth - upper_depth.\n' +
|
|
388
|
+
' North wall — base: y = room_depth (back against north wall). Upper front-flush: y = base_y - (base_depth - upper_depth).\n' +
|
|
389
|
+
' CRITICAL: For upper cabinets, use y = base_y - (base_depth - upper_depth). For south wall where base_y=0, simplifies to y = base_depth - upper_depth.\n' +
|
|
390
|
+
' East/West wall: cabinets run north-to-south (decreasing y). List them northernmost first.\n' +
|
|
391
|
+
' D_adj_north = north wall cabinet depth if present, else 0.\n' +
|
|
392
|
+
' Northernmost cabinet: y = room_depth - D_adj_north - this_width/2.\n' +
|
|
393
|
+
' Each next going south: y = room_depth - D_adj_north - (sum of widths of all preceding cabinets) - this_width/2.\n' +
|
|
394
|
+
' y DECREASES going south. NEVER add widths — adding goes north (wrong direction).\n' +
|
|
395
|
+
' UPPER cabinets: y = same as the base cabinet at that position. Front-flush = x change only, NOT y.\n' +
|
|
396
|
+
' Do NOT apply north/south front-flush y formulas to east/west wall cabinets.\n' +
|
|
397
|
+
'z = height from floor in inches (0 for base cabinets on the floor).\n' +
|
|
398
|
+
'rotation: 0=north wall, 90=east wall, 180=south wall, 270=west wall.\n' +
|
|
399
|
+
'To remove a cabinet\'s coordinates, omit it from the list.\n' +
|
|
400
|
+
'Call get_cabinet_coordinates first to read current positions before making targeted changes.',
|
|
401
|
+
inputSchema: UpdateCabinetCoordinatesSchema,
|
|
402
|
+
handler: updateCabinetCoordinates,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: 'patch_cabinet_coordinates',
|
|
406
|
+
description:
|
|
407
|
+
'Partially update cabinet coordinates for specific cabinets only. ' +
|
|
408
|
+
'Only the positionNames you include are affected — all other cabinets keep their existing coordinates unchanged. ' +
|
|
409
|
+
'\n\n' +
|
|
410
|
+
'Use this tool when:\n' +
|
|
411
|
+
'- You need to move or reposition one or a few cabinets without disturbing the rest of the layout\n' +
|
|
412
|
+
'- The user says "move cabinet X to position Y" or "adjust the coordinates for Base_01"\n' +
|
|
413
|
+
'\n' +
|
|
414
|
+
'Use update_cabinet_coordinates (PUT) instead when:\n' +
|
|
415
|
+
'- You are setting coordinates for the first time on a new cart or order (full replacement is correct)\n' +
|
|
416
|
+
'- You want to remove a cabinet\'s coordinates (omit it from the full replacement list)\n' +
|
|
417
|
+
'\n' +
|
|
418
|
+
'The response includes "patched" (count of positions you sent), "total" (all coordinates now stored), ' +
|
|
419
|
+
'and the full updated coordinate list is broadcast to the frontend in real time via WebSocket.\n' +
|
|
420
|
+
'\n' +
|
|
421
|
+
'MULTI-QUANTITY ARTICLES: Same rule as update_cabinet_coordinates — if a cabinet was expanded server-side ' +
|
|
422
|
+
'from qty>1 (e.g. "DR_B2_01" qty=3 → "DR_B2_01", "DR_B2_02", "DR_B2_03"), each expanded name is a ' +
|
|
423
|
+
'separate entry. To move all three, include all three in the coordinates list.',
|
|
424
|
+
inputSchema: PatchCabinetCoordinatesSchema,
|
|
425
|
+
handler: patchCabinetCoordinates,
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: 'upload_canvas_background_image',
|
|
429
|
+
description:
|
|
430
|
+
'Upload a background image for a specific canvas (plan or elevation view) from a local file path. ' +
|
|
431
|
+
'Provide the absolute path to the image file — the MCP server reads and encodes it automatically. ' +
|
|
432
|
+
'Supported formats: .png, .jpg / .jpeg, .webp. ' +
|
|
433
|
+
'The existing background image for this canvas type is automatically removed from S3 and replaced. ' +
|
|
434
|
+
'Canvas defaults (scale ratio 1, zoom 1, position 0/0) are initialized automatically for the uploaded ' +
|
|
435
|
+
'canvas type. The canvas is added to activeCanvases so the user sees it when they open the order — ' +
|
|
436
|
+
'no separate initialization step is needed. Call this once per canvas type you want to populate. ' +
|
|
437
|
+
'After upload the drawing tool is notified in real time via WebSocket. ' +
|
|
438
|
+
'Use get_canvas_images to check the current image before replacing.',
|
|
439
|
+
inputSchema: UploadCanvasBackgroundSchema,
|
|
440
|
+
handler: uploadCanvasBackground,
|
|
441
|
+
},
|
|
442
|
+
];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as apiClientModule from '../client/api-client';
|
|
3
|
+
|
|
4
|
+
// Mock the client module
|
|
5
|
+
vi.mock('../client/api-client', () => ({
|
|
6
|
+
client: { get: vi.fn() },
|
|
7
|
+
handleAxiosError: vi.fn((e) => { throw e; }),
|
|
8
|
+
McpApiError: class McpApiError extends Error {
|
|
9
|
+
constructor(public status: number, message: string) { super(message); }
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { searchCatalog, getArticle, listFilterTags } from './catalog';
|
|
14
|
+
|
|
15
|
+
describe('searchCatalog', () => {
|
|
16
|
+
beforeEach(() => vi.clearAllMocks());
|
|
17
|
+
|
|
18
|
+
it('calls /catalog/search with query param', async () => {
|
|
19
|
+
const mockGet = vi.mocked(apiClientModule.client.get);
|
|
20
|
+
mockGet.mockResolvedValue({ data: [{ serialNumber: 'B30', displayName: 'Base 30' }] });
|
|
21
|
+
|
|
22
|
+
const result = await searchCatalog({ q: 'base' });
|
|
23
|
+
|
|
24
|
+
expect(mockGet).toHaveBeenCalledWith('/catalog/search', expect.objectContaining({
|
|
25
|
+
params: expect.objectContaining({ q: 'base' }),
|
|
26
|
+
}));
|
|
27
|
+
expect(result).toContain('B30');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns error text on 401', async () => {
|
|
31
|
+
const mockGet = vi.mocked(apiClientModule.client.get);
|
|
32
|
+
const err = new (apiClientModule.McpApiError as any)(401, 'Invalid API key');
|
|
33
|
+
mockGet.mockRejectedValue(err);
|
|
34
|
+
vi.mocked(apiClientModule.handleAxiosError).mockImplementation(() => { throw err; });
|
|
35
|
+
|
|
36
|
+
const result = await searchCatalog({ q: 'base' });
|
|
37
|
+
expect(result).toContain('Invalid API key');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('getArticle', () => {
|
|
42
|
+
it('returns article details', async () => {
|
|
43
|
+
const mockGet = vi.mocked(apiClientModule.client.get);
|
|
44
|
+
mockGet.mockResolvedValue({ data: { serialNumber: 'B30', displayName: 'Base 30', heightRange: '700-900' } });
|
|
45
|
+
|
|
46
|
+
const result = await getArticle({ serialNumber: 'B30' });
|
|
47
|
+
expect(result).toContain('B30');
|
|
48
|
+
expect(result).toContain('700-900');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('listFilterTags', () => {
|
|
53
|
+
it('returns tags as formatted list', async () => {
|
|
54
|
+
const mockGet = vi.mocked(apiClientModule.client.get);
|
|
55
|
+
mockGet.mockResolvedValue({ data: ['BASE', 'WALL', 'PANTRY'] });
|
|
56
|
+
|
|
57
|
+
const result = await listFilterTags({});
|
|
58
|
+
expect(result).toContain('BASE');
|
|
59
|
+
expect(result).toContain('WALL');
|
|
60
|
+
});
|
|
61
|
+
});
|