@rui.branco/revit-mcp 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.
@@ -0,0 +1,550 @@
1
+ // Documentation: the half of the job that happens after the model is built —
2
+ // cropping a view to what the drawing is about, hiding what is in the way of it,
3
+ // laying a sheet out, reading a schedule back, and getting the set out of Revit
4
+ // as PDF.
5
+ //
6
+ // Every write tool here takes dry_run and it DEFAULTS TO TRUE. That is not the
7
+ // convention the modelling tools use, and it is deliberate: these operate on
8
+ // presentation drawings a human has already looked at and approved, so the model
9
+ // has to ask for the change twice — once by calling, once by turning the dry run
10
+ // off. Read the dry run's "before" and only then apply.
11
+ //
12
+ // Model coordinates are Revit internal units (decimal feet). Sheet coordinates
13
+ // are feet on the PAPER, not model feet — an A1 sheet is 1.95 x 1.38, an A0 is
14
+ // 2.76 x 3.90. The two never mix: revit_set_view_crop is model feet,
15
+ // revit_set_viewport_position is paper feet.
16
+
17
+ import { z } from "zod";
18
+ import { clampLimit, DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from "./read.js";
19
+
20
+ const point3 = z.object({ x: z.number(), y: z.number(), z: z.number() });
21
+ const point2 = z.object({ x: z.number(), y: z.number() });
22
+
23
+ export function registerDocumentationTools(server, bridge) {
24
+ server.tool(
25
+ "revit_get_view_crop",
26
+ "Read the crop of one view or a batch of them: whether it is on, whether the rectangle is drawn, and where it actually is. 'modelBounds' is the answer you want — the crop box's eight corners put through the view's own transform, so it is in MODEL coordinates and comparable with anything else in feet. 'localBounds' is the raw Min/Max in the crop box's own coordinate system and is reported only so the two are never confused: for any view that is not aligned with the project axes they are different boxes, and reading localBounds as model coordinates is the classic way to crop a section to the wrong place. 'annotationCrop' is null on a view that has no such parameter, which is not the same answer as false. 'templateControlledCrop' lists the crop parameters this view's template owns — when it is non-empty, revit_set_view_crop will refuse the view, because Revit would ignore the write.",
27
+ {
28
+ view_id: z
29
+ .number()
30
+ .int()
31
+ .optional()
32
+ .describe("Single view id from revit_list_views. Use this or view_ids, not both."),
33
+ view_ids: z
34
+ .array(z.number().int())
35
+ .min(1)
36
+ .optional()
37
+ .describe("View ids to read, all in this one call. Use this or view_id, not both."),
38
+ },
39
+ async ({ view_id, view_ids }) => {
40
+ // Checked here rather than in the schema: a raw shape cannot express
41
+ // "one of these two", and a call with neither must not reach Revit.
42
+ if (view_id === undefined && view_ids === undefined) {
43
+ return {
44
+ content: [
45
+ { type: "text", text: "Error: pass either view_id (one view) or view_ids (a batch)." },
46
+ ],
47
+ };
48
+ }
49
+ if (view_id !== undefined && view_ids !== undefined) {
50
+ return {
51
+ content: [{ type: "text", text: "Error: pass either view_id or view_ids, not both." }],
52
+ };
53
+ }
54
+
55
+ try {
56
+ // The tool arguments stay snake_case like every other tool's; the bridge
57
+ // reads viewId / viewIds — map them here rather than on the C# side.
58
+ const result = await bridge.call("/views/crop", { viewId: view_id, viewIds: view_ids });
59
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
60
+ } catch (error) {
61
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
62
+ }
63
+ },
64
+ );
65
+
66
+ server.tool(
67
+ "revit_set_view_crop",
68
+ "Crop one view or a batch of them to a region of the MODEL, in feet. You give model coordinates and never have to know the view's own coordinate system: the bridge puts all eight corners of your box through the view's crop transform and takes the box around the result, so a section looking north-east crops to the region you actually named. The view's transform is left alone — it belongs to the view's orientation, not to the crop. Three things are written every time: the box, crop active TRUE and crop region visible FALSE, which is the state a drawing wants — cropped, with no crop rectangle printed on the sheet. This is what fixes a plan that sits tiny in a corner of its sheet: uncropped views carry section marks and elevation markers far outside the building, and the viewport is sized to all of it. Crop first, then re-scale, then check revit_get_sheet_layout. Refusals are checked for every view BEFORE anything is written, so the call is all-or-nothing: CROP_NOT_SUPPORTED for a sheet, schedule, legend or view template, and CROP_CONTROLLED_BY_TEMPLATE when the view's template owns the crop parameters — Revit would ignore the write, so the bridge names the template instead of reporting a success the drawing would not show. dry_run DEFAULTS TO TRUE: it reports the current crop and the box it would write, and changes nothing. Read that, then call again with dry_run false. One applied call is one undo step, and 'after' is read back off each view.",
69
+ {
70
+ view_id: z
71
+ .number()
72
+ .int()
73
+ .optional()
74
+ .describe("Single view id from revit_list_views. Use this or view_ids, not both."),
75
+ view_ids: z
76
+ .array(z.number().int())
77
+ .min(1)
78
+ .optional()
79
+ .describe("View ids to crop, all in this one call. Use this or view_id, not both."),
80
+ model_bounds: z
81
+ .object({
82
+ min: point3.describe("Lower corner in MODEL feet"),
83
+ max: point3.describe("Upper corner in MODEL feet. Must exceed min on all three axes."),
84
+ })
85
+ .describe(
86
+ "The region of the model to crop to, in MODEL coordinates in feet — not the view's own coordinates. Get a sensible box from the modelExtents of a 3D view, or from the bounding box of the elements the drawing is about.",
87
+ ),
88
+ dry_run: z
89
+ .boolean()
90
+ .default(true)
91
+ .describe(
92
+ "DEFAULTS TO TRUE. True reports the current crop and the box that would be written, and changes nothing. Pass false to apply it.",
93
+ ),
94
+ },
95
+ async ({ view_id, view_ids, model_bounds, dry_run }) => {
96
+ if (view_id === undefined && view_ids === undefined) {
97
+ return {
98
+ content: [
99
+ { type: "text", text: "Error: pass either view_id (one view) or view_ids (a batch)." },
100
+ ],
101
+ };
102
+ }
103
+ if (view_id !== undefined && view_ids !== undefined) {
104
+ return {
105
+ content: [{ type: "text", text: "Error: pass either view_id or view_ids, not both." }],
106
+ };
107
+ }
108
+
109
+ // Checked here rather than in the schema: zod can say "a number", not
110
+ // "less than the other number", and a zero-depth crop is not a crop.
111
+ const { min, max } = model_bounds;
112
+ if (min.x >= max.x || min.y >= max.y || min.z >= max.z) {
113
+ return {
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text: "Error: model_bounds needs min strictly less than max on all three axes.",
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ try {
124
+ const result = await bridge.call("/views/set-crop", {
125
+ viewId: view_id,
126
+ viewIds: view_ids,
127
+ modelBounds: model_bounds,
128
+ dryRun: dry_run,
129
+ });
130
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
131
+ } catch (error) {
132
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
133
+ }
134
+ },
135
+ );
136
+
137
+ server.tool(
138
+ "revit_hide_elements_in_view",
139
+ "Hide elements in ONE view, permanently — the 'Hide in View > Elements' override Revit stores on that view. Two things it is NOT, both of which look identical on screen: it is not deletion, so the elements stay in the model, in every other view and in the schedules — hiding entourage that stands in front of a presentation elevation does not touch the planting design or its quantities; and it is not temporary hide/isolate, so it survives closing the view and it reaches the sheet. Pass hidden false to bring the same list back. Every id is checked with CanBeHidden BEFORE anything is hidden and one element Revit refuses — a group, an array, a constraint, a link — fails the whole call with ELEMENT_CANNOT_BE_HIDDEN naming it, because Revit refuses the batch rather than skipping the offender. To hide whole categories instead, that is revit_hide_view_categories, and it is the right tool for level datums and section marks. dry_run DEFAULTS TO TRUE and reports canBeHidden and the current isHidden for every id. 'isHidden' in the answer is read back off the view, so an element left invisible by a category switch or a template is visible as such rather than credited to this call. One applied call is one undo step.",
140
+ {
141
+ view_id: z.number().int().describe("View id from revit_list_views. One view, not a batch."),
142
+ ids: z
143
+ .array(z.number().int())
144
+ .min(1)
145
+ .describe("Element ids to hide or unhide in that view, all in this one call"),
146
+ hidden: z
147
+ .boolean()
148
+ .default(true)
149
+ .describe("True hides them (the default). False unhides the same list."),
150
+ dry_run: z
151
+ .boolean()
152
+ .default(true)
153
+ .describe(
154
+ "DEFAULTS TO TRUE. True reports canBeHidden and the current isHidden for every id and changes nothing. Pass false to apply it.",
155
+ ),
156
+ },
157
+ async ({ view_id, ids, hidden, dry_run }) => {
158
+ try {
159
+ const result = await bridge.call("/views/hide-elements", {
160
+ viewId: view_id,
161
+ ids,
162
+ hidden,
163
+ dryRun: dry_run,
164
+ });
165
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
166
+ } catch (error) {
167
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
168
+ }
169
+ },
170
+ );
171
+
172
+ server.tool(
173
+ "revit_export_pdf",
174
+ "Export named views and sheets to PDF through Revit's own PDF exporter — vectors, on white paper, at the sheet's real size. THIS IS THE DELIVERABLE FORMAT: revit_export_view_image is a capture of the view as Revit draws it on screen, so a view with a dark background comes out as a black-paper negative of the drawing, and no PNG setting makes that a printable sheet. Use a PDF for anything anyone is meant to read or plot, and a PNG only for looking at the 3D model. It is also nothing to do with rendering — the API cannot start Revit's raytracer at all. 'view_ids' and 'sheet_ids' are explicit lists and at least one is required; there is no 'export everything' shorthand, because a drawing set is a decision. Sheets go in sheet_ids and ordinary views in view_ids — the wrong way round is a BAD_REQUEST rather than a surprise. 'folder' must be absolute and is created if missing; an existing file is never overwritten unless overwrite is true, and that is checked for every target before the first byte is written. What the manifest proves and what it does not: 'path' and 'bytes' are read off the disk afterwards and a file that is not there is an error, never a success — Revit reporting a successful export is not taken as evidence that anything was written; 'pages' is counted out of the PDF itself and is null with pagesMeasured false when it cannot be; and 'pageMapping' says 'exact' when each file holds one view, or 'requestedOrder' when a combined PDF's page numbers are the order the views were handed to Revit, which is an assumption and not a measurement. Not an undo step: exporting does not change the model.",
175
+ {
176
+ view_ids: z
177
+ .array(z.number().int())
178
+ .min(1)
179
+ .optional()
180
+ .describe("Ordinary view ids to export, from revit_list_views. Not sheets."),
181
+ sheet_ids: z
182
+ .array(z.number().int())
183
+ .min(1)
184
+ .optional()
185
+ .describe("Sheet ids to export, from revit_list_sheets. Exported after the views."),
186
+ folder: z
187
+ .string()
188
+ .min(1)
189
+ .describe(
190
+ "Absolute folder the PDFs are written to, e.g. 'C:\\\\Projects\\\\PDF'. Created if it does not exist, and proved writable before anything is exported.",
191
+ ),
192
+ filename: z
193
+ .string()
194
+ .min(1)
195
+ .optional()
196
+ .describe(
197
+ "File name stem, without '.pdf'. With combine true it names the single file; with combine false it prefixes each one. Omit it for the project title (combined) or the view and sheet names (not combined).",
198
+ ),
199
+ combine: z
200
+ .boolean()
201
+ .default(true)
202
+ .describe(
203
+ "TRUE by default: one PDF holding every view and sheet, in the order given. False writes one PDF per view, named after it.",
204
+ ),
205
+ overwrite: z
206
+ .boolean()
207
+ .default(false)
208
+ .describe(
209
+ "False by default: an existing file at any target path fails the whole call with FILE_EXISTS and nothing is exported.",
210
+ ),
211
+ },
212
+ async ({ view_ids, sheet_ids, folder, filename, combine, overwrite }) => {
213
+ // Checked here rather than in the schema: a raw shape cannot express
214
+ // "at least one of these two", and an empty export must not reach Revit.
215
+ if (view_ids === undefined && sheet_ids === undefined) {
216
+ return {
217
+ content: [
218
+ {
219
+ type: "text",
220
+ text: "Error: pass view_ids, sheet_ids, or both. There is no shorthand for exporting the whole document.",
221
+ },
222
+ ],
223
+ };
224
+ }
225
+
226
+ try {
227
+ const result = await bridge.call("/export/pdf", {
228
+ viewIds: view_ids,
229
+ sheetIds: sheet_ids,
230
+ folder,
231
+ filename,
232
+ combine,
233
+ overwrite,
234
+ });
235
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
236
+ } catch (error) {
237
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
238
+ }
239
+ },
240
+ );
241
+
242
+ server.tool(
243
+ "revit_read_schedule",
244
+ "Read the text of a schedule, cell by cell. This is the only way to see what a schedule actually says: a schedule cannot be exported as an image — Revit's image export does not accept one as a view — and Revit's own schedule export writes a text file to a disk you then cannot read either. Two independent sources of the column headings come back and they disagree in a way that matters: 'columns' is the schedule's definition, the fields in display order with the heading text actually printed and an isHidden flag for fields that are in the definition but not drawn; 'header' and 'body' are the raw laid-out grids exactly as Revit builds them, and which of the two holds the heading row depends on the schedule, so both are returned whole rather than guessed at. A cell Revit will not give text for — merged, or holding an image — is null, which is not the same answer as an empty cell. 'offset' and 'limit' page through the BODY rows only and 'totalRows' always says how much you did not get: if it is larger than returnedRows, say so instead of treating the page as the whole schedule. Read-only.",
245
+ {
246
+ schedule_id: z
247
+ .number()
248
+ .int()
249
+ .describe("Schedule id — a view whose viewType is Schedule, from revit_list_views"),
250
+ limit: z
251
+ .number()
252
+ .int()
253
+ .positive()
254
+ .default(DEFAULT_QUERY_LIMIT)
255
+ .describe(
256
+ `Max body rows to return (default ${DEFAULT_QUERY_LIMIT}, hard cap ${MAX_QUERY_LIMIT} — higher values are clamped, not rejected)`,
257
+ ),
258
+ offset: z
259
+ .number()
260
+ .int()
261
+ .min(0)
262
+ .default(0)
263
+ .describe("Body rows to skip, for paging through a long schedule"),
264
+ },
265
+ async ({ schedule_id, limit, offset }) => {
266
+ try {
267
+ const result = await bridge.call("/schedules/read", {
268
+ scheduleId: schedule_id,
269
+ limit: clampLimit(limit),
270
+ offset,
271
+ });
272
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
273
+ } catch (error) {
274
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
275
+ }
276
+ },
277
+ );
278
+
279
+ server.tool(
280
+ "revit_get_sheet_layout",
281
+ "Measure where everything on a sheet actually is, in feet on the PAPER. Call this before moving anything: a plan that has come out a stamp in the corner of an A0 could be the view's crop, the title block's extent or the viewport's position, and those are three different fixes — this is what tells them apart. 'outline' is the paper itself. 'titleblocks' carries each title block's bounding box, which is the real drawing area, because a title block is not always flush with the paper. Each viewport carries 'center' — the point revit_set_viewport_position moves, and NOT the bottom-left — plus 'bounds', what the view occupies with its crop included, and 'labelBounds', the view title, which sits outside the box and is what usually collides with the next view. 'scheduleInstances' carries each schedule's 'topLeft', which is Revit's own anchor for a schedule rather than its centre, and its bounds: the pair that says whether a table is running off the bottom of the sheet. Anything Revit gives no geometry for is null rather than zeroes. Read-only.",
282
+ {
283
+ sheet_id: z.number().int().describe("Sheet id from revit_list_sheets"),
284
+ },
285
+ async ({ sheet_id }) => {
286
+ try {
287
+ const result = await bridge.call("/sheets/layout", { sheetId: sheet_id });
288
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
289
+ } catch (error) {
290
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
291
+ }
292
+ },
293
+ );
294
+
295
+ server.tool(
296
+ "revit_get_browser_organization",
297
+ "Inspect how the Project Browser currently groups the Sheets section, and find out honestly what can be done about it. READ-ONLY, and that is the finding, not a limitation of this tool: Revit's API can read the browser organization scheme and cannot change it — there is no Create, the sorting order and sorting parameter are get-only, nothing defines folder levels and nothing makes a scheme active. The response says so in 'canApplyFromApi' (always false) and 'applyLimitation'. It also names the alternative: Revit 2025's SheetCollection is a different, writable mechanism giving native collapsible sheet groups one level deep, not a nested hierarchy — that is what the dedicated sheet collection tools do, and this tool stays read-only. What you do get here: 'active' is the scheme in force with its sorting parameter, 'schemes' lists every scheme defined in the document by name — those are the ones a user can pick in the UI — and each sheet's 'folders' is the actual chain of browser folders it sits in, with the parameter that produced each one. 'groupingLevels' is derived from a sample sheet and labelled as such, because the scheme does not expose its own definition. The automatable half of the job is the parameter the grouping reads: revit_create_project_parameter to add it and revit_set_sheet_parameters to stamp it, after which a human points the browser at it once. Never report the grouping as applied on the strength of having stamped the parameter.",
298
+ {
299
+ sheet_ids: z
300
+ .array(z.number().int())
301
+ .min(1)
302
+ .optional()
303
+ .describe("Sheet ids to report folders for. Omit it for every sheet in the document."),
304
+ limit: z
305
+ .number()
306
+ .int()
307
+ .positive()
308
+ .default(DEFAULT_QUERY_LIMIT)
309
+ .describe(
310
+ `Max sheets to return (default ${DEFAULT_QUERY_LIMIT}, hard cap ${MAX_QUERY_LIMIT} — higher values are clamped, not rejected)`,
311
+ ),
312
+ },
313
+ async ({ sheet_ids, limit }) => {
314
+ try {
315
+ const result = await bridge.call("/sheets/browser-organization", {
316
+ sheetIds: sheet_ids,
317
+ limit: clampLimit(limit),
318
+ });
319
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
320
+ } catch (error) {
321
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
322
+ }
323
+ },
324
+ );
325
+
326
+ server.tool(
327
+ "revit_set_viewport_position",
328
+ "Move one viewport on its sheet. All lengths are feet on the PAPER, not model feet. 'center' is the CENTRE of the viewport's box — the same point revit_get_sheet_layout reports as 'center', not the bottom-left and not the view's origin — so call that first and move relative to what it said rather than guessing a coordinate. 'label_offset' and 'label_line_length' are the view title's position relative to the viewport and the length of the line under it; both are left exactly as they are when not passed. Moving a viewport does not resize it: if the view is too big or too small for the sheet, that is revit_set_view_crop and revit_set_view_scale, and this only places the result. dry_run DEFAULTS TO TRUE and reports the current position without moving anything. 'before' and 'after' are both read back through Revit, so a viewport that did not go where it was told — one whose positioning is not free — is visible rather than silent. One applied call is one undo step.",
329
+ {
330
+ viewport_id: z
331
+ .number()
332
+ .int()
333
+ .describe("Viewport id from revit_get_sheet_layout — not the view id and not the sheet id"),
334
+ center: point2.describe(
335
+ "Where to put the CENTRE of the viewport's box, in feet on the paper. Compare with the 'center' revit_get_sheet_layout reported.",
336
+ ),
337
+ label_offset: point2
338
+ .optional()
339
+ .describe(
340
+ "View title position relative to the viewport, in feet on the paper. Omit it to leave the title where it is.",
341
+ ),
342
+ label_line_length: z
343
+ .number()
344
+ .positive()
345
+ .optional()
346
+ .describe(
347
+ "Length of the line under the view title, in feet on the paper. Omit it to leave it as it is.",
348
+ ),
349
+ dry_run: z
350
+ .boolean()
351
+ .default(true)
352
+ .describe(
353
+ "DEFAULTS TO TRUE. True reports the current position and moves nothing. Pass false to apply it.",
354
+ ),
355
+ },
356
+ async ({ viewport_id, center, label_offset, label_line_length, dry_run }) => {
357
+ try {
358
+ const result = await bridge.call("/sheets/set-viewport-position", {
359
+ viewportId: viewport_id,
360
+ center,
361
+ labelOffset: label_offset,
362
+ labelLineLength: label_line_length,
363
+ dryRun: dry_run,
364
+ });
365
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
366
+ } catch (error) {
367
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
368
+ }
369
+ },
370
+ );
371
+
372
+ server.tool(
373
+ "revit_set_schedule_position",
374
+ "Move ONE schedule on its sheet. This is a different tool from revit_set_viewport_position and it has to be: a schedule on a sheet is a ScheduleSheetInstance, not a Viewport, and Revit anchors it by its TOP-LEFT corner rather than by the centre of a box. Passing a schedule instance id to the viewport tool fails. Use the id from revit_get_sheet_layout's 'scheduleInstances' — that is the INSTANCE id, not the schedule view's id, and the two are different numbers. Lengths are feet ON THE PAPER. The revision schedule inside a title block is refused with REVISION_SCHEDULE_IS_FIXED: Revit prohibits moving it and its position belongs to the title block family, so the fix is to edit the family. dry_run DEFAULTS TO TRUE. 'before' and 'after' both carry bounds measured off the sheet rather than the point echoed back, which is the only way to see the common case — a schedule that is not misplaced but simply longer than the page, where moving it cannot help and the table needs splitting or fewer rows.",
375
+ {
376
+ instance_id: z
377
+ .number()
378
+ .int()
379
+ .describe(
380
+ "ScheduleSheetInstance id from revit_get_sheet_layout's scheduleInstances — not the schedule id and not the sheet id",
381
+ ),
382
+ top_left: point2.describe(
383
+ "Where to put the TOP-LEFT corner of the schedule, in feet on the paper. Compare with the 'topLeft' revit_get_sheet_layout reported.",
384
+ ),
385
+ dry_run: z
386
+ .boolean()
387
+ .default(true)
388
+ .describe(
389
+ "DEFAULTS TO TRUE. True reports the current position and moves nothing. Pass false to apply it.",
390
+ ),
391
+ },
392
+ async ({ instance_id, top_left, dry_run }) => {
393
+ try {
394
+ const result = await bridge.call("/sheets/set-schedule-position", {
395
+ instanceId: instance_id,
396
+ topLeft: top_left,
397
+ dryRun: dry_run,
398
+ });
399
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
400
+ } catch (error) {
401
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
402
+ }
403
+ },
404
+ );
405
+
406
+ server.tool(
407
+ "revit_configure_schedule",
408
+ "Reshape how an EXISTING schedule presents the rows it already has: grouping, totals, column headings and widths. It never adds a column and never removes one — revit_create_schedule is what makes a new schedule, and a schedule's columns are its author's decision. The one to reach for is itemized: false, which is the API behind Revit's 'Itemize every instance' tick box and the thing that turns 142 rows of one plant each into one row per species carrying a count. It does nothing on its own — rows only collapse where the group_by fields make them equal — so send group_by in the same call. group_by REPLACES the sort/group list rather than adding to it; the previous list comes back under 'before' so it can be put back by hand. Revit refuses to group by Count, percentage and formula fields, because none of them has a value until after grouping has happened, and that is checked before anything is written. For totals, CanTotal is checked first: asking a text column to total is an error, not a silent no-op, and revit_read_schedule reports 'canTotal' per column so you can look before asking. Everything is validated before the transaction opens, so a request that is wrong in its last entry changes nothing at all. dry_run DEFAULTS TO TRUE. Read 'bodyRows' in the before/after — it is the row count Revit actually lays out and the only honest proof a regrouping did what was asked.",
409
+ {
410
+ schedule_id: z
411
+ .number()
412
+ .int()
413
+ .describe("Schedule view id — revit_list_views with viewType Schedule, or the scheduleId from revit_get_sheet_layout"),
414
+ itemized: z
415
+ .boolean()
416
+ .optional()
417
+ .describe(
418
+ "False collapses elements that the grouping makes equal onto one row; true gives every element its own row. Omit to leave it alone. Pair false with group_by or nothing collapses.",
419
+ ),
420
+ group_by: z
421
+ .array(
422
+ z.object({
423
+ field: z
424
+ .union([z.number().int(), z.string()])
425
+ .describe(
426
+ "Field id ('fieldId' from revit_read_schedule) or field name. An ambiguous name is refused with the candidates listed.",
427
+ ),
428
+ sort_order: z.enum(["Ascending", "Descending"]).optional(),
429
+ show_header: z.boolean().optional(),
430
+ show_footer: z.boolean().optional(),
431
+ show_footer_count: z.boolean().optional(),
432
+ show_blank_line: z.boolean().optional(),
433
+ }),
434
+ )
435
+ .optional()
436
+ .describe(
437
+ "REPLACES the whole sort/group list, in order. Omit to leave the existing grouping untouched.",
438
+ ),
439
+ fields: z
440
+ .array(
441
+ z.object({
442
+ field: z
443
+ .union([z.number().int(), z.string()])
444
+ .describe("Field id or field name, same resolution as group_by"),
445
+ heading: z.string().optional().describe("Column heading text as printed"),
446
+ width_ft: z.number().positive().optional().describe("Column width in feet on the paper"),
447
+ totals: z
448
+ .union([z.boolean(), z.enum(["Standard", "Totals", "Min", "Max", "MinMax"])])
449
+ .optional()
450
+ .describe(
451
+ "true means Totals, false means Standard. Refused unless the column's canTotal is true.",
452
+ ),
453
+ hidden: z.boolean().optional(),
454
+ }),
455
+ )
456
+ .optional()
457
+ .describe("Changes to existing columns only. Never adds or removes a column."),
458
+ filters: z
459
+ .array(
460
+ z.object({
461
+ field: z
462
+ .union([z.number().int(), z.string()])
463
+ .describe("Field id or field name, same resolution as group_by"),
464
+ operator: z.enum(["BeginsWith", "Equal", "GreaterThan"]),
465
+ value: z
466
+ .union([z.string(), z.number()])
467
+ .describe(
468
+ "A string for a text field, a number for a numeric one. The two are checked against what the field stores before anything is written.",
469
+ ),
470
+ }),
471
+ )
472
+ .optional()
473
+ .describe(
474
+ "REPLACES every filter on the schedule. Omit to leave the existing filters alone; pass [] to clear them. BeginsWith on Sheet Number is how one sheet list becomes a per-series index.",
475
+ ),
476
+ grand_total: z
477
+ .object({
478
+ show: z.boolean().optional(),
479
+ show_count: z.boolean().optional(),
480
+ show_title: z.boolean().optional(),
481
+ title: z.string().optional(),
482
+ })
483
+ .optional()
484
+ .describe("The grand total row at the bottom of the schedule."),
485
+ dry_run: z
486
+ .boolean()
487
+ .default(true)
488
+ .describe(
489
+ "DEFAULTS TO TRUE. True reports the current configuration and changes nothing. Pass false to apply it.",
490
+ ),
491
+ },
492
+ async ({ schedule_id, itemized, group_by, fields, filters, grand_total, dry_run }) => {
493
+ if (
494
+ itemized === undefined &&
495
+ group_by === undefined &&
496
+ fields === undefined &&
497
+ filters === undefined &&
498
+ grand_total === undefined
499
+ ) {
500
+ return {
501
+ content: [
502
+ {
503
+ type: "text",
504
+ text:
505
+ "Error: nothing to change. Pass at least one of itemized, group_by, fields, " +
506
+ "filters or grand_total. To read a schedule instead, use revit_read_schedule.",
507
+ },
508
+ ],
509
+ };
510
+ }
511
+
512
+ try {
513
+ const result = await bridge.call("/schedules/configure", {
514
+ scheduleId: schedule_id,
515
+ itemized,
516
+ groupBy: group_by?.map((entry) => ({
517
+ field: entry.field,
518
+ sortOrder: entry.sort_order,
519
+ showHeader: entry.show_header,
520
+ showFooter: entry.show_footer,
521
+ showFooterCount: entry.show_footer_count,
522
+ showBlankLine: entry.show_blank_line,
523
+ })),
524
+ fields: fields?.map((entry) => ({
525
+ field: entry.field,
526
+ heading: entry.heading,
527
+ widthFt: entry.width_ft,
528
+ totals: entry.totals,
529
+ hidden: entry.hidden,
530
+ })),
531
+ filters: filters?.map((entry) => ({
532
+ field: entry.field,
533
+ operator: entry.operator,
534
+ value: entry.value,
535
+ })),
536
+ grandTotal: grand_total && {
537
+ show: grand_total.show,
538
+ showCount: grand_total.show_count,
539
+ showTitle: grand_total.show_title,
540
+ title: grand_total.title,
541
+ },
542
+ dryRun: dry_run,
543
+ });
544
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
545
+ } catch (error) {
546
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
547
+ }
548
+ },
549
+ );
550
+ }