@sealab/mcp-server 1.0.1 → 1.0.3

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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.orderTools = void 0;
3
+ exports.orderTools = exports.ArticleItemSchema = void 0;
4
4
  exports.getOrderBreakdown = getOrderBreakdown;
5
5
  exports.getPositionBreakdown = getPositionBreakdown;
6
6
  exports.createEditCart = createEditCart;
@@ -211,7 +211,7 @@ async function applySavedSettingToArticle(article) {
211
211
  async function applySavedSettingsToArticles(articles) {
212
212
  return Promise.all(articles.map(applySavedSettingToArticle));
213
213
  }
214
- const ArticleItemSchema = zod_1.z.object({
214
+ exports.ArticleItemSchema = zod_1.z.object({
215
215
  serialNumber: zod_1.z.string(),
216
216
  positionName: zod_1.z.string().max(25).describe('Unique label for this cabinet within the order. Must be unique across ALL articles in the order. ' +
217
217
  'Derive from the cabinet display name: "Upper 1125" → "Upper", "Base Cabinet" → "Base". ' +
@@ -364,7 +364,7 @@ const SubmitCartAsVersionSchema = zod_1.z.object({
364
364
  });
365
365
  const AddArticlesToCartSchema = zod_1.z.object({
366
366
  tempOrderId: zod_1.z.string().describe('Saved cart reference ID — a plain numeric string returned by create_saved_cart or list_saved_carts (e.g. "901787"). Do NOT add any prefix such as "SavedCart_".'),
367
- articles: flexArray(ArticleItemSchema).describe('Articles to add to the cart.'),
367
+ articles: flexArray(exports.ArticleItemSchema).describe('Articles to add to the cart.'),
368
368
  });
369
369
  const GetSavedCartSchema = zod_1.z.object({
370
370
  tempOrderId: zod_1.z.string().describe('Saved cart reference ID — a plain numeric string returned by create_saved_cart or list_saved_carts (e.g. "901787"). Do NOT add any prefix such as "SavedCart_".'),
@@ -411,7 +411,7 @@ const DeleteSavedCartArticlesSchema = zod_1.z.object({
411
411
  positionNames: flexArray(zod_1.z.string()).describe('Position names of the articles to delete.'),
412
412
  });
413
413
  const CreateOrderSchema = zod_1.z.object({
414
- articles: flexArray(ArticleItemSchema).describe('Line items to order.'),
414
+ articles: flexArray(exports.ArticleItemSchema).describe('Line items to order.'),
415
415
  projectName: zod_1.z.string().describe('Project name for this order'),
416
416
  purchaseOrder: zod_1.z.string().describe('Purchase order number'),
417
417
  projectAddress: ProjectAddressSchema.optional().describe('Project address (where cabinets will be installed)'),
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.permissionTools = void 0;
4
+ exports.shareOrderAccess = shareOrderAccess;
5
+ exports.updateOrderAccess = updateOrderAccess;
6
+ exports.revokeOrderAccess = revokeOrderAccess;
7
+ exports.listOrderAccess = listOrderAccess;
8
+ const zod_1 = require("zod");
9
+ const api_client_1 = require("../client/api-client");
10
+ // ─── Schemas ─────────────────────────────────────────────────────────────────
11
+ const ShareOrderSchema = zod_1.z.object({
12
+ orderId: zod_1.z.string().describe('The order ID to share access to.'),
13
+ grantedToEmail: zod_1.z.string().describe('Email address of the user to grant access to.'),
14
+ permissionType: zod_1.z.enum(['VIEW', 'EDIT', 'ADMIN']).default('VIEW').describe('Permission level to grant. VIEW = read-only (default), EDIT = can modify order options, ADMIN = full control.'),
15
+ hidePrice: zod_1.z.boolean().default(false).describe('Whether to hide pricing information from this user. Defaults to false.'),
16
+ });
17
+ const UpdateOrderAccessSchema = zod_1.z.object({
18
+ orderId: zod_1.z.string().describe('The order ID whose permission you want to update.'),
19
+ grantedToEmail: zod_1.z.string().describe('Email address of the user whose permission to update.'),
20
+ permissionType: zod_1.z.enum(['VIEW', 'EDIT', 'ADMIN']).describe('New permission level. VIEW = read-only, EDIT = can modify order options, ADMIN = full control.'),
21
+ hidePrice: zod_1.z.boolean().default(false).describe('Whether to hide pricing information from this user.'),
22
+ });
23
+ const RevokeOrderAccessSchema = zod_1.z.object({
24
+ orderId: zod_1.z.string().describe('The order ID to revoke access from.'),
25
+ email: zod_1.z.string().describe('Email address of the user whose access to revoke.'),
26
+ });
27
+ const ListOrderAccessSchema = zod_1.z.object({
28
+ orderId: zod_1.z.string().describe('The order ID to list shared access for.'),
29
+ });
30
+ // ─── Handlers ────────────────────────────────────────────────────────────────
31
+ async function shareOrderAccess(input) {
32
+ try {
33
+ const { data } = await api_client_1.client.post(`/orders/${input.orderId}/permissions/grant`, {
34
+ grantedToEmail: input.grantedToEmail,
35
+ permissionType: input.permissionType,
36
+ hidePrice: input.hidePrice,
37
+ });
38
+ const priceNote = input.hidePrice ? ' Prices will be hidden from them.' : '';
39
+ return `Access granted. ${input.grantedToEmail} now has ${input.permissionType} access to order ${input.orderId}.${priceNote} They will receive an email notification.`;
40
+ }
41
+ catch (error) {
42
+ try {
43
+ (0, api_client_1.handleAxiosError)(error);
44
+ }
45
+ catch (e) {
46
+ return e.message;
47
+ }
48
+ return 'Unexpected error granting order access.';
49
+ }
50
+ }
51
+ async function updateOrderAccess(input) {
52
+ try {
53
+ await api_client_1.client.put(`/orders/${input.orderId}/permissions/update`, {
54
+ grantedToEmail: input.grantedToEmail,
55
+ permissionType: input.permissionType,
56
+ hidePrice: input.hidePrice,
57
+ });
58
+ const priceNote = input.hidePrice ? ' Prices are hidden from them.' : ' Prices are visible to them.';
59
+ return `Permission updated. ${input.grantedToEmail} now has ${input.permissionType} access to order ${input.orderId}.${priceNote}`;
60
+ }
61
+ catch (error) {
62
+ try {
63
+ (0, api_client_1.handleAxiosError)(error);
64
+ }
65
+ catch (e) {
66
+ return e.message;
67
+ }
68
+ return 'Unexpected error updating order access.';
69
+ }
70
+ }
71
+ async function revokeOrderAccess(input) {
72
+ try {
73
+ await api_client_1.client.delete(`/orders/${input.orderId}/permissions/revoke`, {
74
+ params: { email: input.email },
75
+ });
76
+ return `Access revoked. ${input.email} can no longer access order ${input.orderId}.`;
77
+ }
78
+ catch (error) {
79
+ try {
80
+ (0, api_client_1.handleAxiosError)(error);
81
+ }
82
+ catch (e) {
83
+ return e.message;
84
+ }
85
+ return 'Unexpected error revoking order access.';
86
+ }
87
+ }
88
+ async function listOrderAccess(input) {
89
+ try {
90
+ const { data } = await api_client_1.client.get(`/orders/${input.orderId}/permissions`);
91
+ if (!data || data.length === 0) {
92
+ return `No shared access on order ${input.orderId}. Only you can view it.`;
93
+ }
94
+ const lines = [`Shared access for order ${input.orderId}:\n`];
95
+ for (const p of data) {
96
+ const priceNote = p.hidePrice ? ' | Prices hidden' : '';
97
+ const grantedDate = p.grantedDate ? ` | Granted: ${p.grantedDate.split('T')[0]}` : '';
98
+ lines.push(`• ${p.grantedToEmail} — ${p.permissionType}${priceNote}${grantedDate}`);
99
+ }
100
+ return lines.join('\n');
101
+ }
102
+ catch (error) {
103
+ try {
104
+ (0, api_client_1.handleAxiosError)(error);
105
+ }
106
+ catch (e) {
107
+ return e.message;
108
+ }
109
+ return 'Unexpected error listing order access.';
110
+ }
111
+ }
112
+ // ─── Tool Definitions ─────────────────────────────────────────────────────────
113
+ exports.permissionTools = [
114
+ {
115
+ name: 'share_order_access',
116
+ description: `Grant another user access to one of your orders.
117
+
118
+ Use when the user says things like:
119
+ - "Share order 12345 with someone@email.com"
120
+ - "Give Jane access to my order"
121
+ - "Let my contractor view this order without seeing prices"
122
+
123
+ BEFORE calling, confirm with the user:
124
+ - The order ID
125
+ - The recipient's email address
126
+ - Permission level: VIEW (read-only, default), EDIT (modify options), ADMIN (full control)
127
+ - Whether to hide prices from the recipient (default: no)
128
+
129
+ After granting:
130
+ - The recipient receives an email notification
131
+ - They log in to their Sealab account to view the shared order
132
+ - You can update or revoke access at any time using the other permission tools
133
+
134
+ Rules enforced by the system:
135
+ - You can only share your own orders
136
+ - Cannot share with yourself
137
+ - Maximum 50 users per order`,
138
+ inputSchema: ShareOrderSchema,
139
+ handler: shareOrderAccess,
140
+ },
141
+ {
142
+ name: 'update_order_access',
143
+ description: `Update the permission level or price visibility for a user who already has access to one of your orders.
144
+
145
+ Use when the user wants to:
146
+ - Change someone's permission from VIEW to EDIT or ADMIN
147
+ - Toggle whether prices are hidden for a specific user
148
+ - Adjust existing shared access without revoking and re-granting
149
+
150
+ Requires the order ID, the user's email, the new permission type, and the new hidePrice setting.`,
151
+ inputSchema: UpdateOrderAccessSchema,
152
+ handler: updateOrderAccess,
153
+ },
154
+ {
155
+ name: 'revoke_order_access',
156
+ description: `Revoke a user's access to one of your orders.
157
+
158
+ Use when the user wants to:
159
+ - Remove someone's access to an order
160
+ - Revoke a collaborator or contractor's view of an order
161
+
162
+ After revoking, the user immediately loses access. No email notification is sent on revocation.
163
+
164
+ Requires the order ID and the email address of the user whose access to remove.`,
165
+ inputSchema: RevokeOrderAccessSchema,
166
+ handler: revokeOrderAccess,
167
+ },
168
+ {
169
+ name: 'list_order_access',
170
+ description: `List all users who currently have shared access to one of your orders.
171
+
172
+ Returns each user's email, permission level (VIEW/EDIT/ADMIN), whether prices are hidden for them, and the date access was granted.
173
+
174
+ Use when the user asks:
175
+ - "Who has access to order 12345?"
176
+ - "Show me who I've shared this order with"`,
177
+ inputSchema: ListOrderAccessSchema,
178
+ handler: listOrderAccess,
179
+ },
180
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealab/mcp-server",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "MCP server for the Sealab cabinetry catalog",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -17,14 +17,16 @@
17
17
  "@modelcontextprotocol/sdk": "^1.0.0",
18
18
  "axios": "^1.6.0",
19
19
  "form-data": "^4.0.0",
20
+ "pngjs": "^7.0.0",
20
21
  "zod": "^3.22.0",
21
22
  "zod-to-json-schema": "^3.22.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/node": "^20.0.0",
26
+ "@types/pngjs": "^6.0.5",
27
+ "ts-node": "^10.9.0",
25
28
  "typescript": "^5.3.0",
26
- "vitest": "^1.0.0",
27
- "ts-node": "^10.9.0"
29
+ "vitest": "^1.0.0"
28
30
  },
29
31
  "engines": {
30
32
  "node": ">=18"
@@ -7,13 +7,21 @@ if (!API_KEY) {
7
7
  throw new Error('SEALAB_API_KEY environment variable is required');
8
8
  }
9
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
- });
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 const aiDrawingClient: AxiosInstance = axios.create({
19
+ baseURL: `${API_URL}/api/ai-drawing/v1`,
20
+ headers: {
21
+ 'X-API-Key': API_KEY,
22
+ },
23
+ timeout: 10000,
24
+ });
17
25
 
18
26
  export class McpApiError extends Error {
19
27
  constructor(public readonly status: number, message: string) {
package/src/index.ts CHANGED
@@ -10,8 +10,12 @@ import { configurationTools } from './tools/configuration';
10
10
  import { configurationInfoTools } from './tools/configuration-info';
11
11
  import { savedSettingsTools } from './tools/saved-settings';
12
12
  import { canvasTools } from './tools/canvas';
13
+ import { permissionTools } from './tools/permissions';
14
+ import { aiDrawingTools } from './tools/ai-drawing';
15
+ import { aiDrawingOverlayTools } from './tools/ai-drawing-overlay';
16
+ import { normalizeMcpJsonSchema } from './schema-normalizer';
13
17
 
14
- const allTools = [...catalogTools, ...orderTools, ...configurationTools, ...configurationInfoTools, ...savedSettingsTools, ...canvasTools];
18
+ const allTools = [...catalogTools, ...orderTools, ...configurationTools, ...configurationInfoTools, ...savedSettingsTools, ...canvasTools, ...permissionTools, ...aiDrawingTools, ...aiDrawingOverlayTools];
15
19
 
16
20
  const server = new Server(
17
21
  { name: 'sealab', version: '1.0.0' },
@@ -22,7 +26,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
22
26
  tools: allTools.map((t) => ({
23
27
  name: t.name,
24
28
  description: t.description,
25
- inputSchema: zodToJsonSchema(t.inputSchema as any, { target: 'openApi3' }),
29
+ inputSchema: normalizeMcpJsonSchema(zodToJsonSchema(t.inputSchema as any, { target: 'openApi3' })),
26
30
  })),
27
31
  }));
28
32
 
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeMcpJsonSchema } from './schema-normalizer';
3
+
4
+ describe('normalizeMcpJsonSchema', () => {
5
+ it('converts OpenAPI boolean exclusiveMinimum into JSON Schema numeric exclusiveMinimum', () => {
6
+ expect(normalizeMcpJsonSchema({
7
+ type: 'number',
8
+ minimum: 0,
9
+ exclusiveMinimum: true,
10
+ })).toEqual({
11
+ type: 'number',
12
+ exclusiveMinimum: 0,
13
+ });
14
+ });
15
+
16
+ it('converts OpenAPI boolean exclusiveMaximum into JSON Schema numeric exclusiveMaximum', () => {
17
+ expect(normalizeMcpJsonSchema({
18
+ type: 'number',
19
+ maximum: 1000,
20
+ exclusiveMaximum: true,
21
+ })).toEqual({
22
+ type: 'number',
23
+ exclusiveMaximum: 1000,
24
+ });
25
+ });
26
+
27
+ it('removes false exclusive bounds without changing inclusive bounds', () => {
28
+ expect(normalizeMcpJsonSchema({
29
+ type: 'number',
30
+ minimum: 0,
31
+ maximum: 1000,
32
+ exclusiveMinimum: false,
33
+ exclusiveMaximum: false,
34
+ })).toEqual({
35
+ type: 'number',
36
+ minimum: 0,
37
+ maximum: 1000,
38
+ });
39
+ });
40
+
41
+ it('normalizes nested placement item schemas', () => {
42
+ const normalized = normalizeMcpJsonSchema({
43
+ type: 'object',
44
+ properties: {
45
+ placements: {
46
+ type: 'array',
47
+ items: {
48
+ type: 'object',
49
+ properties: {
50
+ height: {
51
+ type: 'number',
52
+ minimum: 0,
53
+ exclusiveMinimum: true,
54
+ },
55
+ },
56
+ },
57
+ },
58
+ },
59
+ }) as any;
60
+
61
+ expect(normalized.properties.placements.items.properties.height).toEqual({
62
+ type: 'number',
63
+ exclusiveMinimum: 0,
64
+ });
65
+ });
66
+
67
+ it('collapses homogeneous tuple items into a Gemini-compatible item schema', () => {
68
+ const normalized = normalizeMcpJsonSchema({
69
+ type: 'array',
70
+ minItems: 4,
71
+ maxItems: 4,
72
+ items: [
73
+ { type: 'number', minimum: 0, maximum: 1000 },
74
+ { $ref: '#/properties/proposals/items/properties/bbox/items/0' },
75
+ { $ref: '#/properties/proposals/items/properties/bbox/items/0' },
76
+ { $ref: '#/properties/proposals/items/properties/bbox/items/0' },
77
+ ],
78
+ }) as any;
79
+
80
+ expect(Array.isArray(normalized.items)).toBe(false);
81
+ expect(normalized).toEqual({
82
+ type: 'array',
83
+ minItems: 4,
84
+ maxItems: 4,
85
+ items: { type: 'number', minimum: 0, maximum: 1000 },
86
+ });
87
+ });
88
+
89
+ it('keeps heterogeneous tuple schemas valid by using anyOf item schemas', () => {
90
+ const normalized = normalizeMcpJsonSchema({
91
+ type: 'array',
92
+ minItems: 2,
93
+ maxItems: 2,
94
+ items: [
95
+ { type: 'number' },
96
+ { type: 'string' },
97
+ ],
98
+ }) as any;
99
+
100
+ expect(normalized.items).toEqual({
101
+ anyOf: [
102
+ { type: 'number' },
103
+ { type: 'string' },
104
+ ],
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,86 @@
1
+ type JsonObject = { [key: string]: unknown };
2
+
3
+ function isJsonObject(value: unknown): value is JsonObject {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+
7
+ function normalizeExclusiveBounds(schema: JsonObject): JsonObject {
8
+ const normalized = { ...schema };
9
+
10
+ if (normalized.exclusiveMinimum === true) {
11
+ if (typeof normalized.minimum === 'number') {
12
+ normalized.exclusiveMinimum = normalized.minimum;
13
+ delete normalized.minimum;
14
+ } else {
15
+ delete normalized.exclusiveMinimum;
16
+ }
17
+ } else if (normalized.exclusiveMinimum === false) {
18
+ delete normalized.exclusiveMinimum;
19
+ }
20
+
21
+ if (normalized.exclusiveMaximum === true) {
22
+ if (typeof normalized.maximum === 'number') {
23
+ normalized.exclusiveMaximum = normalized.maximum;
24
+ delete normalized.maximum;
25
+ } else {
26
+ delete normalized.exclusiveMaximum;
27
+ }
28
+ } else if (normalized.exclusiveMaximum === false) {
29
+ delete normalized.exclusiveMaximum;
30
+ }
31
+
32
+ return normalized;
33
+ }
34
+
35
+ function schemaSignature(schema: unknown): string {
36
+ if (Array.isArray(schema)) {
37
+ return `[${schema.map((item) => schemaSignature(item)).join(',')}]`;
38
+ }
39
+
40
+ if (!isJsonObject(schema)) {
41
+ return JSON.stringify(schema);
42
+ }
43
+
44
+ return `{${Object.keys(schema)
45
+ .sort()
46
+ .map((key) => `${JSON.stringify(key)}:${schemaSignature(schema[key])}`)
47
+ .join(',')}}`;
48
+ }
49
+
50
+ function isRefToFirstTupleItem(item: unknown): boolean {
51
+ return isJsonObject(item) && typeof item.$ref === 'string' && item.$ref.endsWith('/items/0');
52
+ }
53
+
54
+ function normalizeTupleItems(schema: JsonObject): JsonObject {
55
+ if (!Array.isArray(schema.items)) {
56
+ return schema;
57
+ }
58
+
59
+ const normalizedItems = schema.items.map((item) => normalizeMcpJsonSchema(item));
60
+ const firstItem = normalizedItems[0];
61
+ const allItemsMatchFirst = normalizedItems.every((item) => (
62
+ schemaSignature(item) === schemaSignature(firstItem) || isRefToFirstTupleItem(item)
63
+ ));
64
+
65
+ return {
66
+ ...schema,
67
+ items: allItemsMatchFirst ? firstItem : { anyOf: normalizedItems },
68
+ };
69
+ }
70
+
71
+ export function normalizeMcpJsonSchema(schema: unknown): unknown {
72
+ if (Array.isArray(schema)) {
73
+ return schema.map((item) => normalizeMcpJsonSchema(item));
74
+ }
75
+
76
+ if (!isJsonObject(schema)) {
77
+ return schema;
78
+ }
79
+
80
+ const normalized = normalizeTupleItems(normalizeExclusiveBounds(schema));
81
+ for (const [key, value] of Object.entries(normalized)) {
82
+ normalized[key] = normalizeMcpJsonSchema(value);
83
+ }
84
+
85
+ return normalized;
86
+ }
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { aiDrawingOverlayTools } from './ai-drawing-overlay';
4
+
5
+ describe('aiDrawingOverlayTools', () => {
6
+ it('does not register overlay extraction or placement-candidate tools in the MCP surface', () => {
7
+ expect(aiDrawingOverlayTools).toEqual([]);
8
+ });
9
+ });
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+
3
+ export const aiDrawingOverlayTools: Array<{
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodTypeAny;
7
+ handler: (input: unknown) => Promise<string>;
8
+ }> = [];