Die doppelte JSON-Hülle
pyRevit serialisiert die Antwort des Handlers ein zweites Mal. Beim Aufrufer
kommt JSON an, das eine JSON-Zeichenkette enthält. _decode()
erkennt beide Formen und packt aus.
Ein MCP-Server, der ein geöffnetes Revit-Modell über natürliche Sprache bedienbar macht. Elemente finden und filtern, Parameter lesen und schreiben, Ergebnisse im Modell einfärben, Tabellen in beide Richtungen bewegen, Screenshots in den Chat holen. Ohne Klickweg durch die Oberfläche.
An MCP server that makes an open Revit model operable through natural language. Find and filter elements, read and write parameters, colour results in the model, move tables both ways, pull screenshots into the chat. Without clicking through the interface.
Ein Gebäudemodell beantwortet normalerweise nur, was man anklickt. pyRevit-MCP macht es ansprechbar: Wer im Chat schreibt „setz bei Tür 700932 den Zargentyp auf Stahl 60 Minuten“, bekommt keine Anleitung, wo der Wert im Eigenschaftenfenster steht. Die Tür trägt den Wert danach, und das Änderungsprotokoll von Revit weiß davon.
MCP steht für Model Context Protocol. Es ist die vereinbarte Sprache, in der ein Chat-Modell erfährt, welche Werkzeuge es hat und wie es sie aufruft. Ein MCP-Server meldet beim Start eine Liste von Tools an, jedes mit Namen, Parametern und einer Beschreibung in Prosa. Das Modell liest diese Liste, entscheidet selbst, welches Tool zur Anfrage passt, füllt die Parameter und ruft auf. Der Server macht die Arbeit und gibt das Ergebnis zurück.
Der praktische Unterschied zu einem Makro: Ein Makro muss man kennen, finden und mit den richtigen Argumenten starten. Ein Tool beschreibt sich selbst, und das Modell wählt es aus. Genau daran hängt die Qualität der Tool-Beschreibungen. Sie sind kein Kommentar für Entwickler, sie sind die Bedienungsanleitung für das Modell.
Der Anlass war ein Demo-Video. Ein kurzer Split-Screen, links ein Chat, rechts Revit, um zu zeigen, dass ein Gebäudemodell auf Sprache reagiert. Der Zweck ist breiter geworden. Was hier entsteht, ist ein eigener, schlanker Zugang zu Revit, der Schritt für Schritt wächst und in Kundengesprächen genauso taugt wie in der eigenen Arbeit. Das Video war der Auslöser, nicht das Ziel.
Daraus folgt eine Entscheidung, die sich durch die ganze Doku zieht: Es wird nur gebaut, was verstanden ist. Der Server ist auf einundzwanzig Tools gewachsen, und jedes davon ist Ende zu Ende getestet, bevor es hier steht. Einundzwanzig Tools, die zuverlässig laufen, sind mehr wert als fünfzig, von denen die Hälfte in einer Vorführung stehenbleibt.
pyRevit ist eine freie Erweiterung für Revit, mit der sich Python-Code direkt in Revit ausführen lässt. Routes ist ein Teil davon: ein kleiner Webserver im Revit-Prozess, der aus Python-Funktionen REST-Endpunkte macht. Damit lässt sich Revit von außen ansprechen, ohne ein eigenes Add-in in C# zu schreiben und zu signieren. Routes ist als Beta gekennzeichnet, und das merkt man, siehe Fallen im Betrieb.
A building model usually only answers what you click on. pyRevit-MCP makes it conversational: type “set the frame type on door 700932 to steel, 60 minutes” into the chat and you do not get directions to the right field in the properties panel. The door carries the value afterwards, and Revit’s undo history knows about it.
MCP stands for Model Context Protocol. It is the agreed language in which a chat model learns which tools it has and how to call them. An MCP server announces a list of tools at startup, each with a name, parameters and a description in prose. The model reads that list, decides for itself which tool fits the request, fills in the parameters and calls it. The server does the work and returns the result.
The practical difference from a macro: a macro has to be known, found and started with the right arguments. A tool describes itself, and the model picks it. That is exactly what the quality of the tool descriptions hangs on. They are not comments for developers, they are the manual for the model.
The occasion was a demo video. A short split screen, chat on the left, Revit on the right, to show that a building model responds to language. The purpose has grown wider. What is taking shape here is an own, lean way into Revit that grows step by step and serves customer conversations as well as everyday work. The video was the trigger, not the goal.
One decision follows from that and runs through the whole document: only what is understood gets built. The server has grown to twenty-one tools, and each of them is tested end to end before it appears here. Twenty-one tools that work reliably are worth more than fifty of which half stall during a demo.
pyRevit is a free extension for Revit that lets you run Python code inside Revit. Routes is a part of it: a small web server inside the Revit process that turns Python functions into REST endpoints. That makes Revit addressable from the outside without writing and signing a C# add-in of your own. Routes is labelled beta, and it shows, see pitfalls in daily use.
Drei Ebenen, und der interessante Teil ist der Weg zwischen ihnen. Oben zwei Zugänge: ein Browser mit der eigenen Chat-Oberfläche und, davon unabhängig, jeder beliebige MCP-Client wie Claude Code. Beide landen in derselben HTTP-Brücke. Diese spricht mit dem Webserver, der in Revit selbst läuft. Nichts davon geht über das Netz, alles läuft auf einem Rechner.
Three layers, and the interesting part is the path between them. Two entry points on top: a browser running the project’s own chat interface and, independently of it, any MCP client such as Claude Code. Both end up in the same HTTP bridge. That bridge talks to the web server running inside Revit itself. None of this goes over the network; everything runs on one machine.
app.py legt ihn
Claude mit der Tool-Liste vor, Claude wählt ein Tool, app.py ruft
die Brücke, die Brücke ruft die Route in Revit. Der MCP-Client rechts geht
denselben Weg ab der Brücke, nur ohne eigene Oberfläche und ohne eigenen
API-Schlüssel. Zurück geht immer JSON.
app.py
presents it to Claude together with the tool list, Claude picks a tool,
app.py calls the bridge, the bridge calls the route inside Revit.
The MCP client on the right takes the same path from the bridge onwards, only
without an interface and without an API key of its own. What comes back is
always JSON.
app.py importiert revit_get und revit_post
aus server.py. Das ist die eine Entscheidung, die diese Architektur
zusammenhält. Sonst gäbe es zwei Fassungen desselben HTTP-Codes, und jede
Eigenheit von pyRevit müsste zweimal richtig behandelt werden. Die doppelte
JSON-Hülle ist genau so eine Eigenheit, und sie fällt nur an einer Stelle an.
Der Preis ist eine ungewöhnliche Abhängigkeit: Das Web-Backend hängt am MCP-Server, obwohl es MCP gar nicht spricht. Das ist bewusst so, solange die Brücke klein bleibt. Wächst sie, gehört sie in eine eigene Datei.
Vier Dinge, die sonst in jedem Aufruf einzeln stünden und dort jedes Mal neu falsch werden könnten.
pyRevit serialisiert die Antwort des Handlers ein zweites Mal. Beim Aufrufer
kommt JSON an, das eine JSON-Zeichenkette enthält. _decode()
erkennt beide Formen und packt aus.
Die Basis-URL steht an einer Stelle, und jeder Endpunkt endet auf einem Schrägstrich. Ohne ihn antwortet pyRevit mit 404, was wie eine fehlende Route aussieht.
Ein httpx.AsyncClient wird angelegt und wiederverwendet, mit
30 Sekunden Zeitgrenze. Große Modelle brauchen die, besonders bei Räumen.
dispatch_tool() in app.py fängt jede Ausnahme und
gibt sie als {"error": …} zurück. Das Modell liest den Text und
stellt eine Rückfrage, statt dass das Gespräch abbricht.
app.py imports revit_get and revit_post
from server.py. That is the one decision holding this architecture
together. Otherwise there would be two versions of the same HTTP code, and every
pyRevit quirk would have to be handled correctly twice. The double JSON envelope
is exactly such a quirk, and it is dealt with in one place only.
The price is an unusual dependency: the web backend hangs off the MCP server although it does not speak MCP at all. That is deliberate as long as the bridge stays small. Once it grows, it belongs in a file of its own.
Four things that would otherwise sit in every single call and could go wrong afresh in each one.
pyRevit serialises the handler’s answer a second time. What arrives is JSON
containing a JSON string. _decode() recognises both shapes and
unwraps.
The base URL lives in one place, and every endpoint ends in a slash. Without it pyRevit answers 404, which looks exactly like a missing route.
A single httpx.AsyncClient is created and reused, with a
30 second timeout. Large models need it, rooms in particular.
dispatch_tool() in app.py catches every exception
and returns it as {"error": …}. The model reads the text and asks
a follow-up question instead of the conversation breaking off.
Einundzwanzig Tools in sechs Gruppen. Vollständige Parameterlisten stehen in der Tool-Referenz, hier steht, wofür sie gut sind.
get_status ist der erste Aufruf jeder Sitzung: Läuft Revit, welches
Dokument ist offen. get_model_info liefert den Überblick über das
Modell samt Elementzahlen der wichtigsten Kategorien, get_levels und
get_views die Geschosse und Ansichten, get_categories
alle Kategorien des Modells, auch lokalisierte. list_parameters
beantwortet die Frage vor jedem Filter und jedem Schreibvorgang: Wie heißt der
Parameter genau, ist er Instanz oder Typ, und ist er überhaupt beschreibbar.
find_elements ist das Arbeitstier: Es filtert eine Kategorie nach
Geschoss und nach einem Parameterwert (gleich, enthält, leer, größer, kleiner)
und liefert die gewünschten Parameter im selben Aufruf mit. „Alle Türen unter
1,20 Meter Breite“ ist ein einziger Aufruf, nicht zweihundert.
get_elements holt die einfache Liste einer Kategorie,
get_element_properties alle Werte eines einzelnen Elements,
Instanz- und Typ-Parameter getrennt ausgewiesen, bei Türen samt Raumzuordnung.
Die Suche fällt automatisch von der Instanz auf den Elementtyp zurück. Die Breite einer Tür ist bei Standardfamilien ein Typ-Parameter; ohne diesen Fallback findet ein Breitenfilter schlicht nichts. Werte kommen als Anzeigewert in Projekteinheiten zurück, so wie das Eigenschaftenfenster sie zeigt.
set_parameter setzt einen Wert, set_parameters viele
auf einmal in einer einzigen Revit-Transaktion: ein Roundtrip, ein Eintrag im
Änderungsprotokoll, ein Strg+Z. Beide lesen vor dem Schreiben den alten Wert und
geben ihn zurück, darauf baut die Undo-Funktion der Weboberfläche auf. Trifft
ein Schreibvorgang einen Typ-Parameter, meldet die Antwort, wie viele Instanzen
die Änderung betrifft, samt Warnung.
Das, was in einer Vorführung zählt: override_graphics färbt
Elemente in der aktiven Ansicht, isolate_elements blendet alles
andere aus, color_by_parameter färbt eine ganze Kategorie nach
ihren Parameterwerten, eine Farbe pro Wert, mit Legende in der Antwort.
reset_view nimmt alles davon zurück. export_image
exportiert die aktive Ansicht als PNG, in der Weboberfläche erscheint das Bild
direkt im Chat.
get_selection liest, was in Revit markiert ist, und macht „diese
Wände hier“ zu einer beantwortbaren Frage. set_selection markiert
Elemente aus dem Chat heraus, set_active_view wechselt die Ansicht.
get_warnings gruppiert die Warnungen des Modells nach Typ, mit
Anzahl und betroffenen Elementen. Zusammen mit override_graphics
wird daraus eine Modellprüfung im Chat: „Wie gesund ist dieses Modell?“
save_document speichert, bei Arbeitsteilung wird mit dem
Zentralmodell synchronisiert. Ohne Speichern leben alle Änderungen nur im
Arbeitsspeicher.
Twenty-one tools in six groups. Full parameter lists are in the tool reference; this section says what they are good for.
get_status is the first call of any session: is Revit running,
which document is open. get_model_info gives the model overview
including element counts for common categories, get_levels and
get_views the storeys and views, get_categories every
category in the model, localized names included. list_parameters
answers the question that precedes every filter and every write: what is the
parameter called exactly, is it instance or type, and is it writable at all.
find_elements is the workhorse: it filters a category by level and
by a parameter value (equals, contains, empty, greater, less) and returns the
requested parameters in the same call. “All doors below 1.2 metres of width” is
a single call, not two hundred. get_elements fetches the plain
list of a category, get_element_properties every value of a single
element, instance and type parameters reported separately, with room assignment
for doors.
Lookups fall back from the instance to the element type automatically. A door’s width is a type parameter in standard families; without that fallback a width filter simply finds nothing. Values come back as display values in project units, the way the properties panel shows them.
set_parameter sets one value, set_parameters many at
once inside a single Revit transaction: one round trip, one entry in the undo
history, one Ctrl+Z. Both read the previous value before writing and return it;
the undo function of the web interface builds on that. When a write lands on a
type parameter, the response reports how many instances the change affects,
with a warning.
What counts in a demo: override_graphics colours elements in the
active view, isolate_elements hides everything else,
color_by_parameter colours a whole category by its parameter
values, one colour per value, legend included in the response.
reset_view takes all of it back. export_image exports
the active view as a PNG; in the web interface the picture appears right in the
chat.
get_selection reads what is selected in Revit and turns “these
walls here” into an answerable question. set_selection selects
elements from the chat, set_active_view switches the view.
get_warnings groups the model’s warnings by type, with counts and
the affected elements. Together with override_graphics that makes
a model check inside the chat: “how healthy is this model?”
save_document saves; workshared models synchronize with central.
Without saving, every change lives only in memory.
Eine einzelne HTML-Datei, ausgeliefert von app.py
unter http://localhost:8100. Kein Framework, kein Bauschritt, keine
Abhängigkeit aus dem Netz. Wer die Datei öffnet, sieht alles: Aufbau, Gestaltung
und Verhalten stehen untereinander in derselben Datei.
Sie existiert, weil ein MCP-Client im Terminal für eine Vorführung nicht taugt. Neben einem Revit-Fenster braucht es etwas, das ohne Erklärung lesbar ist und nicht nach Entwicklerwerkzeug aussieht. Genau das ist ihr einziger Zweck, und deshalb ist sie bewusst zurückhaltend gestaltet. Der Star ist das Modell in Revit, nicht die Oberfläche.
Die Nachricht geht an /chat. app.py hängt sie an den
Gesprächsverlauf und schickt alles zusammen mit der Tool-Liste an Claude.
Antwortet Claude mit stop_reason: tool_use, ruft
dispatch_tool die betroffene Route in Revit auf, hängt das Ergebnis
als Werkzeugantwort an den Verlauf und fragt Claude erneut. Diese Schleife läuft,
bis Claude reinen Text zurückgibt. Erst dann geht die Antwort an den Browser,
zusammen mit der Spur der benutzten Tools.
Der Browser zeigt diese Spur als kleine Marken über der Antwort, etwa
get_elements · Doors. Das ist kein Beiwerk. In einer Vorführung ist
es der Beleg, dass tatsächlich das Modell befragt wurde und nicht das
Sprachmodell geraten hat.
Eine Tabelle lässt sich per Ziehen und Ablegen ins Fenster werfen oder über die
Büroklammer auswählen. Pflicht ist eine Spalte element_id, jede
weitere Spalte gilt als Parametername. Das Frontend liest die Datei selbst,
erkennt Komma oder Semikolon als Trenner und zeigt eine Vorschau der ersten zehn
Zeilen als Karte im Gesprächsverlauf. Erst ein Klick auf Write to Revit
schickt die Daten an /upload-csv, das alle Werte gebündelt über
set_parameters schreibt: ein Roundtrip, eine Transaktion, ein
Eintrag im Änderungsprotokoll von Revit.
Dieser Weg läuft bewusst am Sprachmodell vorbei. Bei zweihundert Zeilen wäre der
Umweg über den Chat langsam, teuer und fehleranfällig, ohne dass das Modell
irgendetwas beitragen würde. Die Karte zeigt am Ende, wie viele Werte geschrieben
wurden und wie viele gescheitert sind. Den Rückweg gibt es auch:
export_csv macht aus einem Filterergebnis eine CSV-Datei mit
Download-Knopf im Chat. Exportieren, in Excel ändern, wieder hochladen.
Jeder erfolgreiche Schreibvorgang liefert den alten Wert mit.
app.py sammelt die alten Werte eines Vorgangs zu einem Stapeleintrag
und merkt sich die Reihenfolge. Der Knopf im Chat schickt den Index an
/undo, das die alten Werte in derselben Weise zurückschreibt, über
dieselbe Route und damit erneut in einer Transaktion.
Das ersetzt nicht das Rückgängigmachen in Revit, es ergänzt es. Der Nutzen liegt darin, dass der Rückweg im Chat sichtbar ist, direkt neben der Änderung, die man gerade bereut.
Der Systemprompt verlangt am Ende jeder Antwort zwei bis drei Zeilen, die mit
SUGGEST: beginnen. Das Frontend schneidet sie aus dem Text heraus
und macht Knöpfe daraus. Ein Klick schickt den Vorschlag als nächste Nachricht.
In einer Vorführung nimmt das die Denkpause weg, in der man sonst überlegt, was
man als Nächstes fragt.
Die Seitenleiste trägt eine Tool-Referenz, die zur Laufzeit vom Endpunkt
/tools geladen wird: alle Tools mit Beschreibung und je einem
klickbaren Beispielsatz, der das Eingabefeld füllt, ohne abzuschicken. Weil die
Liste aus dem Code kommt, kann sie nicht veralten.
Screenshots aus export_image erscheinen als Bild direkt in der
Antwortblase, CSV-Exporte als Download-Knopf. Beides kommt aus der Tool-Spur
des Backends, das Sprachmodell muss keine Links in den Text schreiben.
conversation und undo_stack sind Variablen auf
Modulebene, keine Sitzungsdaten. Zwei geöffnete Browserfenster teilen sich
denselben Verlauf und denselben Undo-Stapel. Für eine Vorführung an einem
Rechner ist das kein Problem, für mehrere Benutzer ist es keine Grundlage.
/reset leert beides.
A single HTML file, served by app.py at
http://localhost:8100. No framework, no build step, no dependency
from the network. Open the file and you see everything: structure, styling and
behaviour sit one below the other in the same file.
It exists because an MCP client in a terminal is no good for a demo. Next to a Revit window you need something readable without explanation that does not look like a developer tool. That is its only purpose, which is why it is deliberately understated. The star is the model in Revit, not the interface.
The message goes to /chat. app.py appends it to the
conversation and sends everything to Claude together with the tool list. If
Claude answers with stop_reason: tool_use,
dispatch_tool calls the matching route inside Revit, appends the
result to the conversation as a tool result and asks Claude again. That loop runs
until Claude returns plain text. Only then does the answer go back to the browser,
together with the trace of the tools used.
The browser shows that trace as small chips above the answer, for instance
get_elements · Doors. This is not decoration. In a demo it is the
proof that the model was actually queried rather than the language model
guessing.
A table can be dropped into the window or picked through the paperclip. A column
named element_id is mandatory; every other column is treated as a
parameter name. The frontend reads the file itself, detects comma or semicolon as
the separator and shows a preview of the first ten rows as a card in the
conversation. Only a click on Write to Revit sends the data to
/upload-csv, which writes all values in one bundle through
set_parameters: one round trip, one transaction, one entry in
Revit’s undo history.
This path deliberately bypasses the language model. With two hundred rows, the
detour through the chat would be slow, expensive and error-prone without the
model contributing anything. When it finishes, the card reports how many values
were written and how many failed. The way back exists too:
export_csv turns a filter result into a CSV file with a download
button in the chat. Export, edit in Excel, upload again.
Every successful write returns the previous value. app.py collects
the previous values of one operation into a stack entry and remembers the order.
The button in the chat sends the index to /undo, which writes the
old values back the same way, through the same route and therefore inside a
transaction again.
This does not replace undo in Revit, it complements it. The value lies in the way back being visible in the chat, right next to the change you have just come to regret.
The system prompt asks for two or three lines beginning with
SUGGEST: at the end of every answer. The frontend cuts them out of
the text and turns them into buttons. A click sends the suggestion as the next
message. In a demo that removes the pause in which you would otherwise be
thinking about what to ask next.
The sidebar carries a tool reference loaded at runtime from the
/tools endpoint: every tool with its description and one clickable
example sentence that fills the input field without sending. Because the list
comes from the code, it cannot go stale.
Screenshots from export_image appear as pictures right in the
answer bubble, CSV exports as a download button. Both come from the backend’s
tool trace; the language model never has to write links into its text.
conversation and undo_stack are module-level
variables, not session data. Two open browser windows share the same history
and the same undo stack. For a demo on one machine that is fine; as a basis for
several users it is not. /reset clears both.
Revit 2027 mit einem geöffneten Modell, pyRevit installiert, die Extension
registriert. Ohne geöffnetes Dokument geben die Routen zwar Antwort, aber
document_title ist leer und jede Abfrage läuft ins Nichts.
Der Ordner pyrevit-mcp.extension wird in den pyRevit-Einstellungen
unter den benutzerdefinierten Erweiterungsordnern eingetragen. Danach Revit
starten. Beim Start läuft startup.py, registriert die sechs Module
und schreibt ins pyRevit-Log, wie viele geladen wurden und wie viele gescheitert
sind.
http://localhost:48884/pyrevit_mcp/status/
Im Browser aufrufen. Kommt JSON mit "status": "active" und dem Namen
des offenen Dokuments zurück, steht die Grundlage. Kommt nichts, hilft kein
weiterer Schritt auf der Python-Seite. Der Schrägstrich am Ende ist Pflicht.
uv run uvicorn app:app --port 8100
Danach http://localhost:8100 im Browser. Der API-Schlüssel steht in
.env als ANTHROPIC_API_KEY und wird mit
override=True geladen, damit ein Schlüssel auf Betriebssystemebene
ihn nicht überdeckt.
claude mcp add pyrevit -s user -- uv --directory "C:\path\to\pyrevit-mcp" run server.py
Der Server startet danach mit jeder Chat-Sitzung, es läuft kein Dienst im
Hintergrund. Mit /mcp lässt sich prüfen, ob er verbunden ist und wie
viele Tools er meldet. Es müssen einundzwanzig sein. Im Projektordner übernimmt
die eingecheckte .mcp.json dieselbe Registrierung automatisch. Für
die Weboberfläche wird beides nicht gebraucht, sie ist der zweite, unabhängige
Zugang.
powershell -File scripts\restart_revit.ps1 # Kaltstart automatisiert
uv run python test_routes.py # 24 Prüfungen gegen alle Routen
uv run python test_mcp.py # MCP-Transport über stdio
Das Restart-Skript liest den Pfad des offenen Modells aus der Status-Route,
beendet Revit, öffnet dasselbe Modell neu und wartet, bis die Routen wieder
antworten. test_routes.py prüft danach jede Route Ende zu Ende,
test_mcp.py denselben Server über den MCP-Transport, also den Weg,
den ein echter MCP-Client nimmt. Beide zusammen sind die Antwort auf die teuerste
Eigenschaft dieser Architektur: Ein Extension-Fehler zeigt sich erst nach einem
Kaltstart, also soll ein Kaltstart so billig wie möglich sein.
uv run python explore_model.py
Geht alle fünfzehn Kategorien durch, zählt sie und zeigt anschließend die Parameter einer beliebigen Wand. Der schnellste Weg, ein unbekanntes Modell einzuschätzen und die Parameternamen zu sehen, mit denen man später schreiben will.
Nach jeder Änderung unter pyrevit-mcp.extension/ muss Revit
vollständig beendet und neu gestartet werden. Der Routes-Server ist Beta und
recycelt beim Neuladen seinen Thread, was Revit ohne Meldung beendet. Das
kostet pro Durchgang zwei bis drei Minuten und ist der Grund, warum Änderungen
an der Extension teurer sind als Änderungen an server.py oder
app.py.
Revit 2027 with a model open, pyRevit installed, the extension registered.
Without an open document the routes do answer, but
document_title is empty and every query runs into nothing.
The folder pyrevit-mcp.extension is added to the custom extension
directories in the pyRevit settings. Then start Revit. On startup
startup.py runs, registers the six modules and writes to the
pyRevit log how many loaded and how many failed.
http://localhost:48884/pyrevit_mcp/status/
Open it in a browser. If JSON comes back with "status": "active" and
the name of the open document, the foundation is in place. If nothing comes back,
no further step on the Python side will help. The trailing slash is mandatory.
uv run uvicorn app:app --port 8100
Then http://localhost:8100 in a browser. The API key lives in
.env as ANTHROPIC_API_KEY and is loaded with
override=True, so that a key set at operating-system level cannot
shadow it.
claude mcp add pyrevit -s user -- uv --directory "C:\path\to\pyrevit-mcp" run server.py
The server then starts with every chat session; there is no background service.
Use /mcp to check that it is connected and how many tools it
reports. It must be twenty-one. Inside the project folder, the checked-in
.mcp.json performs the same registration automatically. The web
interface needs neither; it is the second, independent way in.
powershell -File scripts\restart_revit.ps1 # scripted cold restart
uv run python test_routes.py # 24 checks against every route
uv run python test_mcp.py # MCP transport over stdio
The restart script reads the open model’s path from the status route, shuts
Revit down, reopens the same model and waits until the routes answer again.
test_routes.py then checks every route end to end,
test_mcp.py the same server over the MCP transport, the path a
real MCP client takes. Together they answer the most expensive property of this
architecture: an extension bug only shows after a cold restart, so a cold
restart should be as cheap as possible.
uv run python explore_model.py
Walks all fifteen categories, counts them and then prints the parameters of an arbitrary wall. The fastest way to size up an unfamiliar model and to see the parameter names you will later want to write to.
After every change under pyrevit-mcp.extension/ Revit has to be
shut down completely and started again. The Routes server is beta and recycles
its thread on reload, which terminates Revit without a message. That costs two
to three minutes per iteration and is the reason changes to the extension are
more expensive than changes to server.py or app.py.
Fünfzehn Punkte, die einmal Zeit gekostet haben, jeder mit Symptom, Ursache und Regel.
Ein Klick auf Reload in pyRevit beendet Revit. Kein Dialog, kein Logeintrag, das Fenster ist weg. Routes ist Beta und recycelt beim Neuladen den Server-Thread, während der alte den Port noch hält. Die einzige Abhilfe ist ein kalter Neustart nach jeder Änderung an der Extension. Wer das nicht weiß, sucht den Fehler im eigenen Code, und dort ist er nicht.
/status/ antwortet, /status gibt 404. Das gilt für jede
Route, auch für die mit Parameter. Der Fehler sieht aus wie eine nicht
registrierte Route und ist keiner.
Ein Handler gibt eine JSON-Zeichenkette zurück, pyRevit hält das für den
Rückgabewert und serialisiert ihn erneut. Beim Aufrufer kommt eine Zeichenkette
an, wo ein Objekt erwartet wird, und der Fehler taucht erst weit später beim
Feldzugriff auf. _decode() in server.py deckt beide
Formen ab.
Wer erst zählt und dann über denselben FilteredElementCollector
iteriert, bekommt eine leere Liste. Für Gesamtzahl und Elementliste braucht es
zwei frische Collector. Genau daher stammen die beiden Zahlen
total_count und count in der Antwort.
Bis Revit 2023 nahm der Konstruktor eine normale ganze Zahl, ab 2024 erwartet er
System.Int64. Beim Lesen dasselbe umgekehrt: Value ist
neu, IntegerValue alt. utils.py versucht jeweils den
neuen Weg zuerst und fällt zurück, damit derselbe Code über mehrere
Revit-Versionen läuft.
startup.py steht, existiert nichtRouten registrieren sich nicht selbst. Fehlt der Eintrag in der Liste, wird die Datei nie importiert, die Route gibt 404, und im Log steht nichts, weil nichts fehlgeschlagen ist. Die Schleife fängt Fehler pro Modul ab, ein kaputtes Modul reißt die anderen also nicht mit. Es fehlt nur still.
pyRevit gibt den Status 408 auch dann zurück, wenn ein Handler eine Exception wirft. Der Response-Body enthält die echte Fehlermeldung samt Traceback, aber wer bei 408 nur an Timeouts denkt, liest ihn nie und sucht am falschen Ort. Drei Routen fielen so aus, nach jeweils gut zwei Sekunden, und die Ursache war eine UnicodeDecodeError.
json.dumps stürzt an Umlauten ab
IronPythons JSON-Encoder scheitert im Standardmodus an Nicht-ASCII-Zeichen aus
der Revit-API, etwa einem „ü“ im Viewnamen „Lüftung 3.OG“. Tückisch daran:
Routen funktionieren so lange, wie zufällig keine Umlaute in den Daten stecken,
und fallen dann modellabhängig um. Deshalb serialisieren alle Module über
dumps() aus utils.py, nie über json.dumps
direkt.
el.Name wirft bei Räumen eine Exception, der Name steht im
Parameter ROOM_NAME. Und ihr Geschoss verrät ein Raum nicht über
LevelId, sondern über die Property Level. Beides fiel
erst im Probelauf am Klinikmodell auf: Der Geschossfilter fand null Räume, und
ohne Filter stürzte die Route ab. element_name() und die erweiterte
Geschossauflösung decken beide Wege ab.
LookupParameter findet nur Instanz-Parameter
Typ-Parameter liegen am Elementtyp und nicht an der Instanz. Die Suche gibt
None zurück, die Meldung lautet „Parameter nicht gefunden“, und im
Eigenschaftenfenster steht er trotzdem sichtbar da. Seit dem 10.08. fängt
lookup_param() das ab: erst die Instanz, dann der Typ. Beim
Schreiben meldet die Antwort, wie viele Instanzen ein Typ-Parameter betrifft.
| Weitere Punkte | Kurz |
|---|---|
| Kein BOM am Dateianfang | IronPython 2.7 scheitert daran. Stattdessen # -*- coding: utf-8 -*- in Zeile 1 |
doc wird injiziert | Erstes Argument jedes Handlers, steht nicht in der URL. Fehlt es in der Signatur, gibt es einen TypeError |
StorageType steuert die Konvertierung | str, int oder float vor dem Setzen. Parameter vom Typ ElementId werden abgelehnt und die Transaktion zurückgerollt |
load_dotenv(override=True) | Ohne das gewinnt ein Schlüssel auf Betriebssystemebene, und man sucht den 401 in .env |
| Sonnet und Opus geben 429 | Die Organisation hat Kontingent auf Haiku. Beim Umstellen sieht der Fehler nach einem Problem der eigenen Schleife aus |
Fifteen points that cost time once, each with symptom, cause and rule.
A click on Reload in pyRevit terminates Revit. No dialog, no log entry, the window is gone. Routes is beta and recycles the server thread on reload while the old one still holds the port. The only remedy is a cold restart after every change to the extension. Not knowing this, you hunt the fault in your own code, and it is not there.
/status/ answers, /status returns 404. That holds for
every route, including those with a parameter. The failure looks like an
unregistered route and is not one.
A handler returns a JSON string, pyRevit takes that for the return value and
serialises it again. What arrives is a string where an object is expected, and
the failure only surfaces much later on field access. _decode() in
server.py covers both shapes.
Count first and then iterate over the same FilteredElementCollector
and you get an empty list. Total and element list need two fresh collectors. That
is exactly where the two numbers total_count and count
in the response come from.
Up to Revit 2023 the constructor took a plain integer; from 2024 it expects
System.Int64. Reading is the mirror image: Value is
new, IntegerValue old. utils.py tries the new way first
and falls back, so the same code runs across several Revit versions.
startup.py does not existRoutes do not register themselves. Without an entry in the list the file is never imported, the route returns 404, and nothing appears in the log because nothing failed. The loop catches errors per module, so a broken module does not take the others down. It is simply and silently absent.
pyRevit also returns status 408 when a handler throws an exception. The response body carries the real error message with a traceback, but thinking “timeout” at a 408 means never reading it and hunting in the wrong place. Three routes failed exactly that way, after a good two seconds each, and the cause was a UnicodeDecodeError.
json.dumps crashes on umlauts
IronPython’s JSON encoder fails in its default mode on non-ASCII characters
from the Revit API, such as the “ü” in a view named “Lüftung 3.OG”. The
treacherous part: routes keep working as long as no umlaut happens to be in the
data, then fall over depending on the model. That is why every module
serialises through dumps() from utils.py, never
through json.dumps directly.
el.Name throws for rooms; the name lives in the
ROOM_NAME parameter. And a room does not reveal its storey through
LevelId but through the Level property. Both only
surfaced in the dry run on the hospital model: the storey filter found zero
rooms, and without a filter the route crashed. element_name() and
the extended level resolution cover both paths.
LookupParameter only finds instance parameters
Type parameters live on the element type, not on the instance. The lookup returns
None, the message reads “parameter not found”, and the properties
panel shows it plainly all the same. Since 10 August,
lookup_param() catches this: instance first, then the type. On
writes, the response reports how many instances a type parameter affects.
| Further points | In short |
|---|---|
| No BOM at the start of a file | IronPython 2.7 chokes on it. Use # -*- coding: utf-8 -*- on line 1 instead |
doc is injected | First argument of every handler, absent from the URL. Leave it out of the signature and you get a TypeError |
StorageType drives the conversion | str, int or float before setting. Parameters of type ElementId are rejected and the transaction rolled back |
load_dotenv(override=True) | Without it an operating-system level key wins, and you hunt the 401 inside .env |
| Sonnet and Opus return 429 | The organisation has quota on Haiku. On switching, the error looks like a fault in your own loop |
Der Server ändert bestehende Elemente, er legt keine an. Räume, Ebenen, Bauteile, Pläne, nichts davon. Das ist der größte verbleibende Sprung im Funktionsumfang, und er wartet bewusst: Erzeugen braucht einen sauberen Rückweg, den es noch nicht gibt.
Ein Typ-Parameter ändert alle Instanzen des Typs. Die Antwort warnt und nennt die Zahl der Betroffenen, aber erst nachdem geschrieben wurde. Ein Bestätigungsmodus, der die Folgen zeigt, bevor die Transaktion läuft, fehlt noch. Bis dahin verlangt der Systemprompt, dass das Modell vorher nachfragt.
Die Abfrage der Kategorie Räume läuft bei sehr großen Modellen in die
30-Sekunden-Grenze des HTTP-Clients. Ursache ist die Menge an
Begrenzungsberechnung, die Revit dabei anstößt. Behelf ist ein niedriges
limit oder ein Geschossfilter.
Ein Parameter, der auf ein anderes Element verweist, lässt sich nicht aus einer Zeichenkette setzen. Der Schreibversuch wird abgelehnt und die Transaktion zurückgerollt. Betrifft unter anderem Ebenenzuordnungen und Materialverweise.
find_elements und color_by_parameter durchlaufen
höchstens 5.000 Elemente pro Aufruf, damit kein Aufruf den HTTP-Client
aushungert. Die Antwort meldet, wenn die Grenze griff. Bei einer Kategorie
mit mehr Elementen hilft der Geschossfilter.
Route, gegebenenfalls startup.py, @mcp.tool() in
server.py, dazu TOOLS, dispatch_tool und
ein Beispiel in app.py. Bei einundzwanzig Tools noch tragbar,
aber der richtige Umbau wäre, dass app.py die Tool-Liste aus dem
MCP-Server ableitet statt sie zu wiederholen.
Gesprächsverlauf, Undo-Stapel, Exporte und Bilder liegen in Modulvariablen. Zwei Browserfenster teilen sich alles, ein Neustart des Backends leert es. Für eine Vorführung an einem Rechner unerheblich, für Mehrbenutzerbetrieb nicht tragfähig.
Es gibt keinen Parameter für Projekt oder Datei. Angesprochen wird, was in Revit gerade offen ist, auf demselben Rechner. Das ist eine bewusste Vereinfachung, keine Fernsteuerung mehrerer Arbeitsplätze.
Eine Linie zieht sich durch den gesamten heutigen Umfang: Bestehendes lässt sich lesen und ändern, Neues nicht anlegen. Das ist keine Grenze der Revit-API, sondern eine Entscheidung. Ändern ist mit einem gelesenen alten Wert umkehrbar, Erzeugen nicht, und Löschen schon gar nicht. Solange der Rückweg nicht ebenso sauber gebaut ist wie der Hinweg, bleibt der schreibende Teil klein.
Offen sind die erzeugenden Operationen, Bauteillisten und ein Bestätigungsmodus für Typ-Schreibvorgänge. Was zuerst kommt, entscheidet der nächste konkrete Anlass, nicht ein Plan.
Die Doku beschreibt einundzwanzig Tools, die laufen und von zwei Testsuiten geprüft werden. Alles darüber hinaus ist Absichtserklärung. Ein Katalog, dem man trauen kann, ist mehr wert als ein vollständiger.
The server modifies existing elements, it does not create any. Rooms, levels, building elements, sheets, none of it. That is the biggest remaining jump in scope, and it waits on purpose: creating needs a clean way back, which does not exist yet.
A type parameter changes every instance of its type. The response warns and reports the number affected, but only after the write. A confirmation mode that shows the consequences before the transaction runs is still missing. Until then, the system prompt requires the model to ask first.
Querying the rooms category runs into the HTTP client’s 30 second limit on
very large models. The cause is the amount of boundary computation Revit
triggers. The workaround is a low limit or a level filter.
A parameter pointing at another element cannot be set from a string. The write is rejected and the transaction rolled back. This affects level assignments and material references, among others.
find_elements and color_by_parameter walk at most
5,000 elements per call so that no call starves the HTTP client. The response
reports when the cap was hit. For a category with more elements, the level
filter helps.
The route, possibly startup.py, @mcp.tool() in
server.py, plus TOOLS, dispatch_tool
and an example in app.py. Still bearable at twenty-one tools,
but the right refactoring is for app.py to derive the tool list
from the MCP server rather than repeat it.
Conversation, undo stack, exports and images live in module-level variables. Two browser windows share everything, a backend restart clears it. Irrelevant for a demo on one machine, unworkable for multiple users.
There is no parameter for project or file. Whatever is open in Revit on the same machine is what gets addressed. That is a deliberate simplification, not a remote control for several workstations.
One line runs through the entire current scope: what exists can be read and changed, what does not cannot be created. That is not a limit of the Revit API but a decision. A change is reversible once the previous value has been read; creation is not, and deletion certainly is not. As long as the way back is not built as carefully as the way there, the writing part stays small.
What remains open are creating operations, schedules, and a confirmation mode for type writes. What comes first will be decided by the next concrete occasion, not by a plan.
This document describes twenty-one tools that work and are checked by two test suites. Everything beyond that is intention. A catalogue you can trust is worth more than a complete one.
Alle einundzwanzig MCP-Tools mit Parametern, Fallen und einem Beispielsatz, der
sie auslöst, dazu export_csv, das nur die Weboberfläche kennt.
Jedes Tool spricht dieselbe Route an, die auch ein Browser aufrufen könnte, und
arbeitet immer gegen das gerade in Revit geöffnete Dokument. Es gibt keinen
Parameter für Projekt oder Datei, weil es keine Auswahl gibt: Was offen ist, wird
angesprochen.
All twenty-one MCP tools, with parameters, pitfalls and a sample sentence that
triggers each one, plus export_csv, which only the web interface
knows. Every tool addresses the same route a browser could call, and always
works against whichever document is currently open in Revit. There is no
parameter for project or file, because there is no choice: whatever is open is
what gets addressed.