@assetlab/mcp-server 2.0.1 → 2.3.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.
@@ -128,6 +128,7 @@ export function registerWriteTools(server, client) {
128
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
129
  asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this work order covers — use instead of asset_id when there is more than one.'),
130
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.'),
131
+ infrastructure_asset_ids: ASSOCIATION_ARRAY_SCHEMA('Infrastructure features this work order covers — resolve via list_infrastructure_assets. Use this when one job covers several features (a round of hydrant flushing); the whole selection is one work order with one completion and one cost, split across the features. Mutually exclusive with asset/location/system targets.'),
131
132
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
132
133
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
133
134
  estimated_time: z.number().min(0).optional().describe('Estimated time in hours'),
@@ -142,7 +143,7 @@ export function registerWriteTools(server, client) {
142
143
  .string()
143
144
  .max(2000)
144
145
  .optional()
145
- .describe('Image URL (upload via create_upload_url with bucket "attachments", then set this to the public_url)'),
146
+ .describe('Image storage path (upload via create_upload_url with bucket "attachments", then set this to the returned path)'),
146
147
  meter_reading: z
147
148
  .number()
148
149
  .min(0)
@@ -187,6 +188,7 @@ export function registerWriteTools(server, client) {
187
188
  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
189
  asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this work order covers — use instead of asset_id when there is more than one.'),
189
190
  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.'),
191
+ infrastructure_asset_ids: ASSOCIATION_ARRAY_SCHEMA('Infrastructure features this work order covers — resolve via list_infrastructure_assets. Use this when one job covers several features (a round of hydrant flushing); the whole selection is one work order with one completion and one cost, split across the features. Mutually exclusive with asset/location/system targets.'),
190
192
  start_date: z.string().optional().describe('Start date (ISO 8601)'),
191
193
  due_date: z.string().optional().describe('Due date (ISO 8601)'),
192
194
  estimated_time: z.number().min(0).optional().describe('Estimated time in hours'),
@@ -201,7 +203,7 @@ export function registerWriteTools(server, client) {
201
203
  .string()
202
204
  .max(2000)
203
205
  .optional()
204
- .describe('Image URL (upload via create_upload_url with bucket "attachments", then set this to the public_url)'),
206
+ .describe('Image storage path (upload via create_upload_url with bucket "attachments", then set this to the returned path)'),
205
207
  meter_reading: z
206
208
  .number()
207
209
  .min(0)
@@ -924,6 +926,7 @@ export function registerWriteTools(server, client) {
924
926
  asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this schedule covers — use instead of asset_id when there is more than one.'),
925
927
  system_ids: ASSOCIATION_ARRAY_SCHEMA('Systems this schedule covers. Systems have no singular field; this array is the only way to associate them.'),
926
928
  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.'),
929
+ infrastructure_asset_ids: ASSOCIATION_ARRAY_SCHEMA('Infrastructure features this schedule covers — resolve via list_infrastructure_assets. One schedule over several features generates one work order per cycle covering all of them. Mutually exclusive with asset/location/system targets.'),
927
930
  tasks: z
928
931
  .array(z.object({
929
932
  id: z.string().describe('Unique task ID (use a random string)'),
@@ -999,6 +1002,7 @@ export function registerWriteTools(server, client) {
999
1002
  asset_ids: ASSOCIATION_ARRAY_SCHEMA('Assets this schedule covers — use instead of asset_id when there is more than one.'),
1000
1003
  system_ids: ASSOCIATION_ARRAY_SCHEMA('Systems this schedule covers. Systems have no singular field; this array is the only way to associate them.'),
1001
1004
  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.'),
1005
+ infrastructure_asset_ids: ASSOCIATION_ARRAY_SCHEMA('Infrastructure features this schedule covers — resolve via list_infrastructure_assets. One schedule over several features generates one work order per cycle covering all of them. Mutually exclusive with asset/location/system targets.'),
1002
1006
  tasks: z
1003
1007
  .array(z.object({
1004
1008
  id: z.string().describe('Unique task ID'),
@@ -1918,6 +1922,56 @@ export function registerWriteTools(server, client) {
1918
1922
  }
1919
1923
  });
1920
1924
  // ============================================================
1925
+ // 16b. Work Order Schedules (scope: work_order_schedules)
1926
+ // ============================================================
1927
+ server.tool('create_work_order_schedule', "Schedule a work order for a technician on a date (one entry of a day plan). stop_order gives the position in the technician's day; leave it unset for an unordered calendar entry. Requires work_order_schedules:write scope.", {
1928
+ work_order_id: z.string().uuid().describe('Work order ID (required)'),
1929
+ technician_id: z.string().min(1).describe('Technician Clerk user ID (required)'),
1930
+ scheduled_date: z.string().describe('Date (YYYY-MM-DD, required)'),
1931
+ scheduled_start_time: z.string().optional().describe('Start time (HH:MM)'),
1932
+ scheduled_end_time: z.string().optional().describe('End time (HH:MM)'),
1933
+ duration_minutes: z.number().int().optional().describe('Planned duration in minutes'),
1934
+ stop_order: z.number().int().optional().describe('1-based stop position in the day plan'),
1935
+ travel_time_minutes: z.number().int().optional().describe('Travel time from previous stop'),
1936
+ scheduling_notes: z.string().optional().describe('Scheduling notes'),
1937
+ }, async (params) => {
1938
+ try {
1939
+ const result = await client.create('work-order-schedules', buildBody(params));
1940
+ return formatResult(result);
1941
+ }
1942
+ catch (err) {
1943
+ return formatError(err);
1944
+ }
1945
+ });
1946
+ server.tool('update_work_order_schedule', 'Update a work order schedule entry by ID (date, times, stop_order, notes). work_order_id is immutable. Requires work_order_schedules:write scope.', {
1947
+ id: z.string().uuid().describe('Work order schedule ID'),
1948
+ technician_id: z.string().min(1).optional().describe('Technician Clerk user ID'),
1949
+ scheduled_date: z.string().optional().describe('Date (YYYY-MM-DD)'),
1950
+ scheduled_start_time: z.string().optional().describe('Start time (HH:MM)'),
1951
+ scheduled_end_time: z.string().optional().describe('End time (HH:MM)'),
1952
+ duration_minutes: z.number().int().optional().describe('Planned duration in minutes'),
1953
+ stop_order: z.number().int().optional().describe('1-based stop position in the day plan'),
1954
+ travel_time_minutes: z.number().int().optional().describe('Travel time from previous stop'),
1955
+ scheduling_notes: z.string().optional().describe('Scheduling notes'),
1956
+ }, async ({ id, ...rest }) => {
1957
+ try {
1958
+ const result = await client.update('work-order-schedules', id, buildBody(rest));
1959
+ return formatResult(result);
1960
+ }
1961
+ catch (err) {
1962
+ return formatError(err);
1963
+ }
1964
+ });
1965
+ server.tool('delete_work_order_schedule', "Delete a work order schedule entry by ID (removes the stop from the technician's day). Requires work_order_schedules:write scope.", { id: z.string().uuid().describe('Work order schedule ID') }, async ({ id }) => {
1966
+ try {
1967
+ const result = await client.remove('work-order-schedules', id);
1968
+ return formatResult(result);
1969
+ }
1970
+ catch (err) {
1971
+ return formatError(err);
1972
+ }
1973
+ });
1974
+ // ============================================================
1921
1975
  // 17. Project Comments (scope: project_comments)
1922
1976
  // ============================================================
1923
1977
  server.tool('create_project_comment', 'Create a new comment on a project. Requires project_comments:write scope.', {
@@ -3151,7 +3205,7 @@ export function registerWriteTools(server, client) {
3151
3205
  // ============================================================
3152
3206
  // Upload URLs
3153
3207
  // ============================================================
3154
- server.tool('create_upload_url', 'Generate a signed upload URL for uploading a file to AssetLab storage. Returns a signed_url to PUT the file to, a public_url for referencing, and the path. IMPORTANT — When a user wants to upload a file, always clarify the target. File upload paths: (1) Asset IMAGE: bucket "asset-images" → update_asset with image_url. (2) Asset DOCUMENT (O&M, warranty, spec): bucket "documents" → create_asset_document. (3) Work order IMAGE: bucket "attachments" → update_work_order with image_url. (4) Work order/request/PM ATTACHMENT: bucket "attachments" → create_attachment with the parent ID. (5) Project DOCUMENT: bucket "project-documents" → create_project_document. (6) Contract DOCUMENT: bucket "contract-documents" → create_contract_document. Always ask the user which type they mean if ambiguous. Requires upload_urls:write scope.', {
3208
+ server.tool('create_upload_url', 'Generate a signed upload URL for uploading a file to AssetLab storage. Returns a signed_url to PUT the file to, and the storage path to store on the record. IMPORTANT — When a user wants to upload a file, always clarify the target. File upload paths: (1) Asset IMAGE: bucket "asset-images" → update_asset with image_url. (2) Asset DOCUMENT (O&M, warranty, spec): bucket "documents" → create_asset_document. (3) Work order IMAGE: bucket "attachments" → update_work_order with image_url. (4) Work order/request/PM ATTACHMENT: bucket "attachments" → create_attachment with the parent ID. (5) Project DOCUMENT: bucket "project-documents" → create_project_document. (6) Contract DOCUMENT: bucket "contract-documents" → create_contract_document. Always ask the user which type they mean if ambiguous. Requires upload_urls:write scope.', {
3155
3209
  bucket: z
3156
3210
  .enum([
3157
3211
  'documents',
@@ -3171,7 +3225,7 @@ export function registerWriteTools(server, client) {
3171
3225
  return formatError(err);
3172
3226
  }
3173
3227
  });
3174
- server.tool('upload_file', 'Upload a file to AssetLab storage by sending its bytes inline (base64). The AssetLab backend performs the storage upload server-side — use this tool when the client cannot PUT directly to Supabase Storage (e.g. Claude integrations whose outbound network blocks arbitrary supabase.co hosts). Returns { path, public_url, bucket, file_size, content_type }. After uploading, pass path or public_url to the appropriate record tool (update_asset image_url, create_asset_document file_path, update_work_order image_url, create_attachment file_path, create_project_document file_path, create_contract_document file_path). Server limit is ~10 MB decoded; MCP arg ceiling effectively caps file size around 700 KB–1 MB. For larger files, use create_upload_url instead. Requires upload_urls:write scope.', {
3228
+ server.tool('upload_file', 'Upload a file to AssetLab storage by sending its bytes inline (base64). The AssetLab backend performs the storage upload server-side — use this tool when the client cannot PUT directly to Supabase Storage (e.g. Claude integrations whose outbound network blocks arbitrary supabase.co hosts). Returns { path, bucket, file_size, content_type }. After uploading, pass path to the appropriate record tool (update_asset image_url, create_asset_document file_path, update_work_order image_url, create_attachment file_path, create_project_document file_path, create_contract_document file_path). Server limit is ~10 MB decoded; MCP arg ceiling effectively caps file size around 700 KB–1 MB. For larger files, use create_upload_url instead. Requires upload_urls:write scope.', {
3175
3229
  bucket: z
3176
3230
  .enum([
3177
3231
  'documents',
@@ -4642,6 +4696,137 @@ export function registerWriteTools(server, client) {
4642
4696
  }
4643
4697
  });
4644
4698
  // ============================================================
4699
+ // Infrastructure Lifecycle Events (scope: infrastructure_lifecycle_events)
4700
+ // ============================================================
4701
+ const lifecycleEventScope = {
4702
+ feature_class: z
4703
+ .string()
4704
+ .regex(/^[a-z][a-z0-9_]{0,49}$/)
4705
+ .optional()
4706
+ .describe('Feature class code the strategy scope applies to (omit for a material-wide scope; at least one of feature_class/material is required)'),
4707
+ material: z
4708
+ .string()
4709
+ .max(200)
4710
+ .optional()
4711
+ .describe('Material the scope applies to, exactly as features carry it (e.g. "PVC")'),
4712
+ diameter_min_mm: z
4713
+ .number()
4714
+ .positive()
4715
+ .optional()
4716
+ .describe('Lower bound of a diameter band (requires material); bands ladder like rates'),
4717
+ };
4718
+ const lifecycleEventFields = {
4719
+ event_class: z
4720
+ .enum(['preventive', 'rehabilitation'])
4721
+ .describe('Event type — preventative maintenance or rehabilitation'),
4722
+ trigger_condition_max: z
4723
+ .number()
4724
+ .int()
4725
+ .min(1)
4726
+ .max(99)
4727
+ .describe('Upper bound of the trigger window — the event fires when projected condition falls to this'),
4728
+ trigger_condition_min: z
4729
+ .number()
4730
+ .int()
4731
+ .min(0)
4732
+ .max(98)
4733
+ .optional()
4734
+ .describe('Lower bound of the trigger window (default 0); a feature already below it has missed the event'),
4735
+ impact_method: z
4736
+ .enum(['add_years', 'reset_condition'])
4737
+ .describe('Effect: add years of life, or reset condition to a value'),
4738
+ impact_add_years: z
4739
+ .number()
4740
+ .positive()
4741
+ .max(100)
4742
+ .optional()
4743
+ .describe('Years added (required when impact_method is add_years)'),
4744
+ impact_reset_to: z
4745
+ .number()
4746
+ .int()
4747
+ .min(1)
4748
+ .max(100)
4749
+ .optional()
4750
+ .describe('Condition after the event (required when impact_method is reset_condition)'),
4751
+ cost_method: z
4752
+ .enum(['per_unit', 'fixed'])
4753
+ .optional()
4754
+ .describe("Costing: per unit (uses the feature's measured quantity and unit) or a fixed amount (default per_unit)"),
4755
+ unit_cost: z
4756
+ .number()
4757
+ .min(0)
4758
+ .optional()
4759
+ .describe('Cost per unit (current dollars, never indexed)'),
4760
+ fixed_cost: z.number().min(0).optional().describe('Fixed cost (current dollars)'),
4761
+ cost_source: z
4762
+ .string()
4763
+ .max(200)
4764
+ .optional()
4765
+ .describe('Provenance of the cost ("Engineering 2026", a tender reference)'),
4766
+ max_applications: z
4767
+ .number()
4768
+ .int()
4769
+ .min(1)
4770
+ .max(10)
4771
+ .optional()
4772
+ .describe("How many times the event may fire over a feature's life (default 1)"),
4773
+ min_years_between: z
4774
+ .number()
4775
+ .min(1)
4776
+ .max(100)
4777
+ .optional()
4778
+ .describe('Minimum years between firings of a recurring event (default 1)'),
4779
+ sort_order: z.number().int().min(0).optional().describe('Evaluation order within the strategy'),
4780
+ };
4781
+ server.tool('create_infrastructure_lifecycle_event', 'Create a lifecycle strategy event. Events attach to a SCOPE (feature_class code, material, optional diameter band) — never to individual features; every feature resolves the most specific matching scope, like replacement rates. Replacement is NOT an event (it is priced by the rates and scheduled by the renewal forecast) — model the interventions BEFORE replacement: crack sealing, relining, resurfacing. List existing events first to reuse a scope. Requires infrastructure_lifecycle_events:write scope.', {
4782
+ name: z.string().max(200).describe('Event name (required, e.g. "Crack Sealing")'),
4783
+ ...lifecycleEventScope,
4784
+ ...lifecycleEventFields,
4785
+ }, async (params) => {
4786
+ try {
4787
+ const result = await client.create('infrastructure-lifecycle-events', buildBody(params));
4788
+ return formatResult(result);
4789
+ }
4790
+ catch (err) {
4791
+ return formatError(err);
4792
+ }
4793
+ });
4794
+ server.tool('update_infrastructure_lifecycle_event', 'Update a lifecycle strategy event by ID. Editing re-confirms the cost (cost_reviewed_on is server-set). Set is_active false to disable an event without deleting it — projections recompute immediately. Requires infrastructure_lifecycle_events:write scope.', {
4795
+ id: z.string().uuid().describe('Lifecycle event ID'),
4796
+ name: z.string().max(200).optional().describe('Event name'),
4797
+ is_active: z.boolean().optional().describe('Disable/enable the event'),
4798
+ event_class: lifecycleEventFields.event_class.optional(),
4799
+ trigger_condition_max: lifecycleEventFields.trigger_condition_max.optional(),
4800
+ trigger_condition_min: lifecycleEventFields.trigger_condition_min,
4801
+ impact_method: lifecycleEventFields.impact_method.optional(),
4802
+ impact_add_years: lifecycleEventFields.impact_add_years,
4803
+ impact_reset_to: lifecycleEventFields.impact_reset_to,
4804
+ cost_method: lifecycleEventFields.cost_method,
4805
+ unit_cost: lifecycleEventFields.unit_cost,
4806
+ fixed_cost: lifecycleEventFields.fixed_cost,
4807
+ cost_source: lifecycleEventFields.cost_source,
4808
+ max_applications: lifecycleEventFields.max_applications,
4809
+ min_years_between: lifecycleEventFields.min_years_between,
4810
+ sort_order: lifecycleEventFields.sort_order,
4811
+ }, async ({ id, ...rest }) => {
4812
+ try {
4813
+ const result = await client.update('infrastructure-lifecycle-events', id, buildBody(rest));
4814
+ return formatResult(result);
4815
+ }
4816
+ catch (err) {
4817
+ return formatError(err);
4818
+ }
4819
+ });
4820
+ server.tool('delete_infrastructure_lifecycle_event', 'Delete a lifecycle strategy event by ID. Projections recompute immediately; consider update with is_active=false to disable instead. Requires infrastructure_lifecycle_events:write scope.', { id: z.string().uuid().describe('Lifecycle event ID') }, async ({ id }) => {
4821
+ try {
4822
+ const result = await client.remove('infrastructure-lifecycle-events', id);
4823
+ return formatResult(result);
4824
+ }
4825
+ catch (err) {
4826
+ return formatError(err);
4827
+ }
4828
+ });
4829
+ // ============================================================
4645
4830
  // Infrastructure Networks (scope: infrastructure_networks)
4646
4831
  // ============================================================
4647
4832
  server.tool('create_infrastructure_network', 'Create an infrastructure network — a named grouping of features bound to one feature class. Requires infrastructure_networks:write scope.', {