@assetlab/mcp-server 1.26.0 → 1.28.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.
@@ -20,6 +20,27 @@ function buildBody(params) {
20
20
  return body;
21
21
  }
22
22
  const PM_RESOURCE_TYPES = ['TOOL', 'PART', 'MATERIAL', 'EQUIPMENT'];
23
+ // Shared by the four PM tools: linking a form here makes every work order the schedule
24
+ // generates carry a copy of that form for the technician to fill in.
25
+ const FORM_TEMPLATE_LINK_SCHEMA = z
26
+ .string()
27
+ .uuid()
28
+ .optional()
29
+ .describe('Form template ID to attach to every work order this generates — resolve via list_form_templates. Use a PUBLISHED template: generation resolves the current published version of the form, so a draft attaches nothing until it is published.');
30
+ // Work orders and PM schedules store each association twice — a singular id and a plural array.
31
+ // The API mirrors whichever side is sent, but callers still need the arrays to associate several
32
+ // records at once (and systems, which have no singular field at all).
33
+ const ASSOCIATION_ARRAY_SCHEMA = (description) => z.array(z.string().uuid()).optional().describe(description);
34
+ const FORM_TEMPLATE_STATUSES = ['draft', 'published', 'archived'];
35
+ const FORM_ITEM_TYPES = [
36
+ 'section',
37
+ 'checkbox',
38
+ 'single_select',
39
+ 'multi_select',
40
+ 'number',
41
+ 'text',
42
+ 'photo',
43
+ ];
23
44
  function randomTaskId() {
24
45
  const g = globalThis;
25
46
  return g.crypto?.randomUUID?.() ?? `task_${Math.random().toString(36).slice(2, 12)}`;
@@ -98,8 +119,15 @@ export function registerWriteTools(server, client) {
98
119
  .string()
99
120
  .uuid()
100
121
  .optional()
101
- .describe('Location ID — resolve last via list_locations filtered by building_id'),
102
- asset_id: z.string().uuid().optional().describe('Asset ID'),
122
+ .describe('Location this work order is for — resolve last via list_locations filtered by building_id. The server mirrors it into location_ids, so send this OR location_ids, not a conflicting pair.'),
123
+ asset_id: z
124
+ .string()
125
+ .uuid()
126
+ .optional()
127
+ .describe('Asset this work order is for — resolve via list_assets. The server mirrors it into asset_ids.'),
128
+ location_ids: ASSOCIATION_ARRAY_SCHEMA('Locations this work order covers — use instead of location_id when there is more than one. Sending location_id alone replaces this with that single id.'),
129
+ asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this work order covers — use instead of asset_id when there is more than one.'),
130
+ system_ids: ASSOCIATION_ARRAY_SCHEMA('Systems this work order covers — resolve via list_systems. Systems have no singular field; this array is the only way to associate them.'),
103
131
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
104
132
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
105
133
  estimated_time: z.number().min(0).optional().describe('Estimated time in hours'),
@@ -150,8 +178,15 @@ export function registerWriteTools(server, client) {
150
178
  .string()
151
179
  .uuid()
152
180
  .optional()
153
- .describe('Location ID — resolve last via list_locations filtered by building_id'),
154
- asset_id: z.string().uuid().optional().describe('Asset ID'),
181
+ .describe('Location this work order is for — resolve last via list_locations filtered by building_id. The server mirrors it into location_ids, so send this OR location_ids, not a conflicting pair.'),
182
+ asset_id: z
183
+ .string()
184
+ .uuid()
185
+ .optional()
186
+ .describe('Asset this work order is for — resolve via list_assets. The server mirrors it into asset_ids.'),
187
+ location_ids: ASSOCIATION_ARRAY_SCHEMA('Locations this work order covers — use instead of location_id when there is more than one. Sending location_id alone replaces this with that single id.'),
188
+ asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this work order covers — use instead of asset_id when there is more than one.'),
189
+ system_ids: ASSOCIATION_ARRAY_SCHEMA('Systems this work order covers — resolve via list_systems. Systems have no singular field; this array is the only way to associate them.'),
155
190
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
156
191
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
157
192
  estimated_time: z.number().min(0).optional().describe('Estimated time in hours'),
@@ -886,9 +921,9 @@ export function registerWriteTools(server, client) {
886
921
  .describe('Meter interval — trigger every N units'),
887
922
  meter_unit: z.string().max(50).optional().describe('Meter unit (km, miles, hours, cycles)'),
888
923
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
889
- asset_ids: z.array(z.string().uuid()).optional().describe('Array of asset IDs'),
890
- system_ids: z.array(z.string().uuid()).optional().describe('Array of system IDs'),
891
- location_ids: z.array(z.string().uuid()).optional().describe('Array of location IDs'),
924
+ asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this schedule covers — use instead of asset_id when there is more than one.'),
925
+ system_ids: ASSOCIATION_ARRAY_SCHEMA('Systems this schedule covers. Systems have no singular field; this array is the only way to associate them.'),
926
+ location_ids: ASSOCIATION_ARRAY_SCHEMA('Locations this schedule covers — use instead of location_id when there is more than one. Sending location_id alone replaces this with that single id.'),
892
927
  tasks: z
893
928
  .array(z.object({
894
929
  id: z.string().describe('Unique task ID (use a random string)'),
@@ -897,6 +932,7 @@ export function registerWriteTools(server, client) {
897
932
  }))
898
933
  .optional()
899
934
  .describe('Checklist of tasks for this PM schedule'),
935
+ form_template_id: FORM_TEMPLATE_LINK_SCHEMA,
900
936
  }, async (params) => {
901
937
  try {
902
938
  const result = await client.create('pm-schedules', buildBody(params));
@@ -960,9 +996,9 @@ export function registerWriteTools(server, client) {
960
996
  .describe('Meter interval — trigger every N units'),
961
997
  meter_unit: z.string().max(50).optional().describe('Meter unit (km, miles, hours, cycles)'),
962
998
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
963
- asset_ids: z.array(z.string().uuid()).optional().describe('Array of asset IDs'),
964
- system_ids: z.array(z.string().uuid()).optional().describe('Array of system IDs'),
965
- location_ids: z.array(z.string().uuid()).optional().describe('Array of location IDs'),
999
+ asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this schedule covers — use instead of asset_id when there is more than one.'),
1000
+ system_ids: ASSOCIATION_ARRAY_SCHEMA('Systems this schedule covers. Systems have no singular field; this array is the only way to associate them.'),
1001
+ location_ids: ASSOCIATION_ARRAY_SCHEMA('Locations this schedule covers — use instead of location_id when there is more than one. Sending location_id alone replaces this with that single id.'),
966
1002
  tasks: z
967
1003
  .array(z.object({
968
1004
  id: z.string().describe('Unique task ID'),
@@ -971,6 +1007,7 @@ export function registerWriteTools(server, client) {
971
1007
  }))
972
1008
  .optional()
973
1009
  .describe('Checklist of tasks for this PM schedule'),
1010
+ form_template_id: FORM_TEMPLATE_LINK_SCHEMA,
974
1011
  }, async ({ id, ...rest }) => {
975
1012
  try {
976
1013
  const result = await client.update('pm-schedules', id, buildBody(rest));
@@ -1055,6 +1092,7 @@ export function registerWriteTools(server, client) {
1055
1092
  .array(z.string().uuid())
1056
1093
  .optional()
1057
1094
  .describe('Default location IDs to seed on derived schedules'),
1095
+ form_template_id: FORM_TEMPLATE_LINK_SCHEMA,
1058
1096
  }, async (params) => {
1059
1097
  try {
1060
1098
  const result = await client.create('pm-templates', buildBody(normalizePmTemplateBody(params)));
@@ -1118,6 +1156,7 @@ export function registerWriteTools(server, client) {
1118
1156
  documents: z.array(z.record(z.unknown())).optional().describe('Document references'),
1119
1157
  asset_ids: z.array(z.string().uuid()).optional().describe('Default asset IDs'),
1120
1158
  location_ids: z.array(z.string().uuid()).optional().describe('Default location IDs'),
1159
+ form_template_id: FORM_TEMPLATE_LINK_SCHEMA,
1121
1160
  }, async ({ id, ...rest }) => {
1122
1161
  try {
1123
1162
  const result = await client.update('pm-templates', id, buildBody(normalizePmTemplateBody(rest)));
@@ -1137,6 +1176,184 @@ export function registerWriteTools(server, client) {
1137
1176
  }
1138
1177
  });
1139
1178
  // ============================================================
1179
+ // 8c. Forms & Inspections (scope: form_templates, form_template_items)
1180
+ // ============================================================
1181
+ server.tool('create_form_template', 'Create a form template — a reusable inspection, checklist, compliance, or survey definition. Step 1 of building a form: create it as a draft, then add questions with create_form_template_item (or bulk_create on form-template-items), then publish with update_form_template status=published (publishing is rejected if any question is malformed). Requires form_templates:write scope.', {
1182
+ name: z.string().min(1).max(500).describe('Form template name (required)'),
1183
+ description: z.string().max(5000).optional().describe('Description'),
1184
+ work_category_id: z
1185
+ .string()
1186
+ .uuid()
1187
+ .optional()
1188
+ .describe('Work category ID — the same tenant-configured categories used by work orders (e.g. Electrical, Plumbing, HVAC). Look them up with list_work_categories and pick the closest match; omit if none fits.'),
1189
+ status: z
1190
+ .enum(FORM_TEMPLATE_STATUSES)
1191
+ .optional()
1192
+ .describe('Publication status — leave as draft (default) until all questions are added.'),
1193
+ }, async (params) => {
1194
+ try {
1195
+ const result = await client.create('form-templates', buildBody(params));
1196
+ return formatResult(result);
1197
+ }
1198
+ catch (err) {
1199
+ return formatError(err);
1200
+ }
1201
+ });
1202
+ server.tool('update_form_template', 'Update an existing form template by ID. Requires form_templates:write scope.', {
1203
+ id: z.string().uuid().describe('Form template ID'),
1204
+ name: z.string().min(1).max(500).optional().describe('Form template name'),
1205
+ description: z.string().max(5000).optional().describe('Description'),
1206
+ work_category_id: z
1207
+ .string()
1208
+ .uuid()
1209
+ .optional()
1210
+ .describe('Work category ID (look up with list_work_categories)'),
1211
+ status: z
1212
+ .enum(FORM_TEMPLATE_STATUSES)
1213
+ .optional()
1214
+ .describe('Publication status (draft, published, or archived)'),
1215
+ }, async ({ id, ...rest }) => {
1216
+ try {
1217
+ const result = await client.update('form-templates', id, buildBody(rest));
1218
+ return formatResult(result);
1219
+ }
1220
+ catch (err) {
1221
+ return formatError(err);
1222
+ }
1223
+ });
1224
+ server.tool('delete_form_template', 'Delete a form template by ID. Requires form_templates:write scope.', { id: z.string().uuid().describe('Form template ID') }, async ({ id }) => {
1225
+ try {
1226
+ const result = await client.remove('form-templates', id);
1227
+ return formatResult(result);
1228
+ }
1229
+ catch (err) {
1230
+ return formatError(err);
1231
+ }
1232
+ });
1233
+ server.tool('create_form_template_item', 'Add one question (item) to a form template. Build a form by calling this once per question in order, or use bulk_create on form-template-items. Requires form_template_items:write scope.', {
1234
+ template_id: z.string().uuid().describe('Form template ID — resolve via list_form_templates'),
1235
+ item_key: z
1236
+ .string()
1237
+ .min(1)
1238
+ .max(200)
1239
+ .optional()
1240
+ .describe('Optional stable key, unique within the template. OMIT IT and the server derives one from the label (recommended). Only set it when a later item’s visible_when must reference this one — then use a short slug like "compressor_status".'),
1241
+ sort_order: z
1242
+ .number()
1243
+ .int()
1244
+ .min(0)
1245
+ .describe('Display order within the template (0-based; questions render in this order)'),
1246
+ item_type: z
1247
+ .enum(FORM_ITEM_TYPES)
1248
+ .describe('Pick by the answer you want: single_select = exactly one choice (Pass/Fail, Yes/No, Yes/No/N-A, or custom — supply options); multi_select = pick several (supply options); number = a numeric reading/count (use config.min/max/unit/integer); checkbox = a single done/not-done tick; text = free comment (config.multiline for long text); photo = photo evidence (config.maxPhotos); section = a non-answerable heading that groups the questions under it.'),
1249
+ label: z.string().min(1).max(2000).describe('Question text / prompt shown to the user'),
1250
+ help_text: z.string().max(5000).optional().describe('Optional hint shown under the label'),
1251
+ required: z
1252
+ .boolean()
1253
+ .optional()
1254
+ .describe('Whether an answer is required to complete the form (default false)'),
1255
+ options: z
1256
+ .array(z.object({ value: z.string(), label: z.string() }))
1257
+ .optional()
1258
+ .describe('REQUIRED for single_select/multi_select: at least 2 choices as { value, label } with unique values. value is a machine slug (e.g. "fail"), label is shown to the user (e.g. "Fail"). Omit for other types.'),
1259
+ config: z
1260
+ .record(z.unknown())
1261
+ .optional()
1262
+ .describe('Per-type settings. number: { min, max, unit, integer, decimals }. text: { multiline, maxLength, placeholder }. multi_select: { minSelections, maxSelections }. photo: { minPhotos, maxPhotos }.'),
1263
+ visible_when: z
1264
+ .object({ itemKey: z.string(), op: z.string(), value: z.unknown().optional() })
1265
+ .nullable()
1266
+ .optional()
1267
+ .describe('Optional conditional visibility { itemKey, op, value }. itemKey must reference an EARLIER item’s key (set that item’s item_key explicitly). Operators by referenced type — single_select/checkbox: equals, not_equals, in, not_in, is_answered, is_blank; number: gt, lt, gte, lte, equals, not_equals, is_answered, is_blank; text: is_answered, is_blank, equals, not_equals; multi_select: in, not_in, is_answered, is_blank. e.g. show a "Details" text item only when item "compressor_status" equals "fail".'),
1268
+ }, async (params) => {
1269
+ try {
1270
+ const result = await client.create('form-template-items', buildBody(params));
1271
+ return formatResult(result);
1272
+ }
1273
+ catch (err) {
1274
+ return formatError(err);
1275
+ }
1276
+ });
1277
+ server.tool('update_form_template_item', 'Update an existing form template item by ID. template_id and item_key are immutable and cannot be changed. Requires form_template_items:write scope.', {
1278
+ id: z.string().uuid().describe('Form template item ID'),
1279
+ sort_order: z.number().int().min(0).optional().describe('Display order within the template'),
1280
+ item_type: z
1281
+ .enum(FORM_ITEM_TYPES)
1282
+ .optional()
1283
+ .describe('Item type (section, checkbox, single_select, multi_select, number, text, photo)'),
1284
+ label: z.string().min(1).max(2000).optional().describe('Question label / prompt'),
1285
+ help_text: z.string().max(5000).optional().describe('Help text shown under the label'),
1286
+ required: z.boolean().optional().describe('Whether an answer is required'),
1287
+ options: z
1288
+ .array(z.object({ value: z.string(), label: z.string() }))
1289
+ .optional()
1290
+ .describe('Choices for single_select / multi_select items'),
1291
+ config: z
1292
+ .record(z.unknown())
1293
+ .optional()
1294
+ .describe('Per-type configuration (e.g. { min, max, unit, multiline })'),
1295
+ visible_when: z
1296
+ .object({ itemKey: z.string(), op: z.string(), value: z.unknown().optional() })
1297
+ .nullable()
1298
+ .optional()
1299
+ .describe('Conditional-visibility rule referencing another item'),
1300
+ }, async ({ id, ...rest }) => {
1301
+ try {
1302
+ const result = await client.update('form-template-items', id, buildBody(rest));
1303
+ return formatResult(result);
1304
+ }
1305
+ catch (err) {
1306
+ return formatError(err);
1307
+ }
1308
+ });
1309
+ server.tool('delete_form_template_item', 'Delete a form template item by ID. Requires form_template_items:write scope.', { id: z.string().uuid().describe('Form template item ID') }, async ({ id }) => {
1310
+ try {
1311
+ const result = await client.remove('form-template-items', id);
1312
+ return formatResult(result);
1313
+ }
1314
+ catch (err) {
1315
+ return formatError(err);
1316
+ }
1317
+ });
1318
+ // ============================================================
1319
+ // 8d. Form responses — attach / detach (scope: form_responses)
1320
+ // ============================================================
1321
+ server.tool('create_form_response', "Attach a published form to one record so it can be filled in — the direct way to put an inspection or checklist on an existing work order. The form's questions are snapshotted at attach time, so later edits to the template never change a form already in progress. To put a form on every work order a PM schedule generates, set form_template_id on the schedule instead of calling this per work order. Answering and completing the form happen in the AssetLab app, not through this API. Requires form_responses:write scope.", {
1322
+ template_id: z
1323
+ .string()
1324
+ .uuid()
1325
+ .describe('Published form template ID — resolve via list_form_templates. A draft or archived template is rejected; publish it first with update_form_template status="published".'),
1326
+ subject_type: z
1327
+ .enum(['work_order', 'pm_schedule', 'infrastructure_asset', 'compliance_record', 'site'])
1328
+ .describe('What kind of record the form is being attached to'),
1329
+ subject_id: z
1330
+ .string()
1331
+ .uuid()
1332
+ .describe('ID of the record — resolve via the matching list tool (list_work_orders, list_pm_schedules, list_infrastructure_assets, list_compliance_records, list_sites)'),
1333
+ }, async (params) => {
1334
+ try {
1335
+ const result = await client.create('form-responses', buildBody(params));
1336
+ return formatResult(result);
1337
+ }
1338
+ catch (err) {
1339
+ return formatError(err);
1340
+ }
1341
+ });
1342
+ server.tool('delete_form_response', 'Remove a form from the record it is attached to, along with any answers already given. Use this before attaching a different form, since a record holds at most one form. Requires form_responses:write scope.', {
1343
+ id: z
1344
+ .string()
1345
+ .uuid()
1346
+ .describe('Form response ID — resolve via list_form_responses filtered by subject_id'),
1347
+ }, async ({ id }) => {
1348
+ try {
1349
+ const result = await client.remove('form-responses', id);
1350
+ return formatResult(result);
1351
+ }
1352
+ catch (err) {
1353
+ return formatError(err);
1354
+ }
1355
+ });
1356
+ // ============================================================
1140
1357
  // 9. Projects (scope: projects)
1141
1358
  // ============================================================
1142
1359
  server.tool('create_project', 'Create a new project. Requires projects:write scope.', {
@@ -4129,6 +4346,9 @@ export function registerWriteTools(server, client) {
4129
4346
  'system-classes',
4130
4347
  'pm-schedules',
4131
4348
  'pm-templates',
4349
+ 'form-templates',
4350
+ 'form-template-items',
4351
+ 'form-responses',
4132
4352
  'projects',
4133
4353
  'contracts',
4134
4354
  'invoices',