Contract Surface — godot-mcp-toolkit ⇄ godot-mcp-server
The as-built wire contract between the two repos: every format, protocol message, and field the server consumes from the toolkit, each with a stability tier.
The toolkit owns this contract. It is the protocol producer/publisher — it owns the ports, the WebSocket framing, the response envelope, the error-code vocabulary, and the registry / token / LSP publishing. The server (godot-mcp-server, the npm MCP bridge) is the consumer; it opens a localhost WebSocket to the toolkit and forwards an AI assistant’s MCP calls over it. This document is the canonical, version-controlled description of that boundary; the architecture doc covers the toolkit’s internals.
Each entry records the toolkit shape (the authoritative wire form, with the file where it is defined) and the server consumer (how the bridge reads or produces it). File references name the file only — line numbers rot and are omitted; grep the named file for the symbol.
Stability tiers
The tiers feed semantic versioning:
- public — semver-protected. Breaking a public contract requires a major version bump.
- semi-public — deprecate first, then change across a minor release.
- internal — no compatibility guarantee; may change at any time.
The full per-tool name + parameter-schema catalogue (C8) and the tool-group catalogue (C12) are not duplicated here — they live in the generated tool-reference doc. This document describes the framing of tools and groups (naming convention, registration, activation), not the per-tool list.
Maintaining this document
Each detail section below carries a provenance comment immediately above it:
<!-- data-depicts="<toolkit source files>" data-verified="<short-sha>" -->
data-depicts lists the toolkit (publisher-side) files the section is drawn from, as space-separated, repo-root-relative paths; data-verified is the commit the section was last checked-correct against. Bumping data-verified is an attestation that a human or agent re-read the section against the code at that SHA — it is never auto-generated. Provenance tracks the toolkit/publisher side only; the freshness check is single-repo. Server-side consumer drift is caught by the smoke suite and contract-alignment review, not by this check.
When the contract changes: edit the affected section, then bump its data-verified. Find what to re-check by grepping data-depicts for a file you changed; the advisory, non-blocking freshness check (scripts/check_arch_freshness.sh docs/dev/contract.md) lists sections whose depicted files moved since their data-verified SHA. It over-flags by design — a false re-check costs a glance; a missed drift ships a lying contract.
The chronological record of how the contract reached this state lives in the plan repo’s contract-change-ledger (a closed historical record). This document carries only the current as-built state.
Summary index
| # | Contract | Where defined (toolkit) | Tier |
|---|---|---|---|
| C1 | WebSocket transport & framing (JSON-RPC 2.0/WS, ports, bind, buffer) | transport/mcp_server.gd, runtime/mcp_runtime_server.gd, transport/ws_transport.gd | public |
| C2 | Auth handshake (first-frame token exchange; version + headless fields) | security/auth.gd + both servers + transport/ws_transport.gd | public |
| C3 | Response envelope (success + error shape) | contract/mcp_toolkit_success.gd, contract/mcp_toolkit_error.gd | public |
| C4 | Error-code vocabulary (CODES) | contract/mcp_toolkit_error.gd | public |
| C5 | Dispatch + concurrency notifications (JSON-RPC codes; _queued/_executing/_cancel/echo; id coercion) | transport/dispatch/{server_request_router,dispatch_lane,mutation_watchdog}.gd + transport/mcp_server.gd | public |
| C6 | Idempotency (status + if_exists) | create commands + commands/editor_helpers.gd | public |
| C7 | Type-tag coercion vocabulary (18 tags, bidirectional) | contract/coerce.gd | public |
| C8 | Tool names & param schemas (domain.verb) | commands/*.gd + transport/mcp_toolkit_command_registry.gd | public |
| C9 | Read-only model (GODOT_MCP_READ_ONLY; server-authoritative; published annotations) | toolkit annotations + transport/mcp_toolkit_command_registry.gd | public |
| C10 | Environment variables & CLI flags | security/auth.gd, transport/mcp_server.gd, runtime/mcp_runtime_server.gd, transport/port_config.gd, .mcp.json.template | public |
| C11 | .mcp.json file format | ui/mcp_json_sync.gd (read) + ui/dock/mcp/dock_mcp_json_panel.gd (write) | public |
| C12 | Tool group names | toolkit publishes group per command (options builder); catalogue is server-side | semi-public |
| C13 | Extension API (register; options/undo/save/ctx facades; base classes) | transport/mcp_toolkit_command_registry.gd + extensions/mcp_toolkit_extension*.gd, contract/mcp_toolkit_tool_context.gd | semi-public |
| C14 | Extension surface signaling (extensions.list/refresh; extensions.changed broadcast) | extensions/services/{extension_meta_commands,extension_watcher}.gd | semi-public |
| C15 | LSP status round-trip (reverse: server→toolkit set_lsp_status) | commands/editor/editor_commands.gd + transport/mcp_server.gd | semi-public |
| C16 | projects.json registry format (entry fields + aggregate) | registry/registry_client.gd + registry/store/* | internal |
| C17 | Project-instance hash | paths/project_paths.gd + paths/project_key.gd | internal |
| C18 | Token file discovery (token_path field) | security/auth.gd + registry/registry_client.gd | internal |
| C19 | LSP endpoint publishing (lsp_host/lsp_port) | paths/lsp_publisher.gd → registry | internal |
| C20 | Runtime discovery (runtime_port/runtime_pid) | registry/registry_client.gd | internal |
| C21 | Untrusted envelope | security/untrusted.gd | internal (LLM-facing) |
| C22 | ProjectSettings keys | core/settings_registration.gd | internal |
Detail
C1 — WebSocket transport & framing · public
- Toolkit shape: JSON-RPC 2.0 messages over a WebSocket text channel, one JSON object per frame.
- Mode A (editor): default scan band 6550–6560 (
PORT_MIN/PORT_MAXintransport/mcp_server.gd;GODOT_MCP_EDITOR_PORTpins an exact port bind-exact-or-fail,GODOT_MCP_EDITOR_PORT_MIN/_MAXrelocate the band — resolved by the export-cleantransport/port_config.gd, see C10), bind127.0.0.1(theBINDconst). - Mode B (runtime / in-game): default scan band 6570–6585 (
PORT_MIN/PORT_MAXinruntime/mcp_runtime_server.gd;GODOT_MCP_RUNTIME_PORTpins bind-exact-or-fail,GODOT_MCP_RUNTIME_PORT_MIN/_MAXrelocate — sametransport/port_config.gdresolver), bind127.0.0.1. - Peer buffer (both modes): sized from the ProjectSetting
mcp_toolkit/limits/ws_buffer_kb, default 1024 KiB, read by the shared transport at peer accept (transport/ws_transport.gd) and applied in/outbound — there is no per-mode override; in Mode B the game process reads its own project’s setting value.
- Mode A (editor): default scan band 6550–6560 (
- Constraint: a response frame larger than the peer buffer is dropped wholesale by the engine (
wsl_peer.cpp, 4.2 & 4.5 — no chunking), surfacing to the LLM as a hung request. The buffer-ceiling guard (C4RESPONSE_TOO_LARGE) exists to pre-empt this. - Server consumer: the bridge does not blind-scan the ranges — it is a registry-driven consumer.
createBridge(transport/bridge.ts) →createChannel(transport/channel.ts) reaches the editor channel via the port read fromprojects.json(registry.ts) and the runtime channel via the entry’sruntime_port(C20), opening aws://127.0.0.1:<port>text channel that speaks the same one-JSON-object-per-frame JSON-RPC 2.0 framing. StaticGODOT_MCP_EDITOR_PORT/GODOT_MCP_RUNTIME_PORToverrides (or the--editor-port/--runtime-portCLI flags) pin a port and skip the registry lookup (read inindex.ts, threaded intocreateBridge), with a fail-fast desync cross-check against the registry so a pin the editor never got fails loudly instead of hanging (C10). The toolkit’s per-frame framing and WS buffer ceiling are matched by thewslibrary. - Example: request
{"jsonrpc":"2.0","id":1,"method":"scene.create","params":{...}}→ response{"jsonrpc":"2.0","id":1,"result":{"success":true,...}}.
C2 — Auth handshake · public
- Toolkit shape: before any RPC, the client’s first WS text frame must be
{"auth":"<token>"}(an optional"version":"X.Y.Z"client hint may accompany). The request is identical across modes; the success reply differs by mode:- Mode A (editor):
{"authed":true,"godot_version":"<M.m.p>","version":"<plugin-version>","headless":<bool>}(the ack override,_build_auth_ackintransport/mcp_server.gd) — carries the engine + plugin version (the server’s version-gating input) plusheadless(DisplayServer.get_name()=="headless", viaVersionUtils.is_headless()): the wire signal the server reads to branch its headless-degraded tool assertions. - Mode B (runtime):
{"authed":true}only — the shared transport’s default ack; the runtime supplies no override (runtime/mcp_runtime_server.gd) — no version orheadlessfields. - Failure: WS close 1008 “invalid token”. Silence > 2000 ms (
_AUTH_TIMEOUT_MS) → WS close 1008 “auth timeout” (transport/ws_transport.gd). Auth handling is unified in the shared transport for both modes.
- Mode A (editor):
- Token: 64-char lowercase hex / 256-bit,
Crypto.new().generate_random_bytes(32).hex_encode()(security/auth.gd); written raw, no trailing newline (write_token); validated by plain==(validate). Shared by both modes. - Server consumer: the bridge sends the exact
{"auth":"<token>"}first frame viacreateChannel’sperformAuth, re-reading the token from disk on every connect (transport/channel.ts→readToken, C18) so it always matches whatever the editor/runtime last wrote (the standalone-game self-heal relies on this re-read). From the editor ack it parsesgodot_version→ the bridge’sgetGodotVersion()(transport/bridge.ts) and stores it (feeds the version gate, C5/C9), and likewise parsesheadless→bridge.isHeadless()(transport/bridge.ts) —undefinedpre-auth, mirroringgetGodotVersion()— which the smoke/flows suites read to branch their headless-degraded tool assertions. For Mode B (runtime) the ack carries no version (orheadless) → the bridge gets the Godot version from the registry pre-pop instead (lookupProject,transport/bridge.ts), not the handshake. - Constraint: localhost-only + a single shared token ⇒ this is a single-user local tool (the real boundary, alongside the read-only filter and the human in the editor).
C3 — Response envelope · public
- Toolkit shape:
- Success:
MCPToolkitSuccess.ok(data)returnsdatawithsuccess=trueadded (contract/mcp_toolkit_success.gd). It mutates its argument in place (deliberate). Success payloads never carrycode. - Error:
MCPToolkitError.fail(code,message,hint)→{"success":false,"error":<message>,"code":<code>}, plus"hint"iff an explicit non-empty hint is passed orcode ∈ DEFAULT_HINTS(contract/mcp_toolkit_error.gd). Error payloads never carrystatus. remediation: string[](success-side disclosure): a successful capture that required a visible side effect to produce a usable frame lists it here —editor.screenshotemits"switched_main_screen"(auto-healed a non-2D/3D main screen) and/or"foregrounded_editor";runtime.screenshotemits"foregrounded_game". Absent when none (a clean capture omits the key), and it is an array because un-minimize + main-screen-switch can co-occur in one call. Screenshot-only today; a general success-side “what I had to do” channel.runtime.screenshot’s"foregrounded_game"is emitted only for a top-level game — an embedded playtest (the editor Game view; Windows/X11 4.4+, macOS 4.5+) can’t be independently foregrounded, so the lever is a no-op there and instead surfaces ahint(neverforegrounded_game).- Screenshot
image_response_mode(both capture tools): an optionalimage_response_mode: "inline" | "disk" | "both"(default"inline") selects the capture-return shape. inline (default):{image_base64, mime_type, width, height, bytes, image_detail, returned}. disk (lean envelope, noimage_base64):{path, width, height, bytes, mime_type, image_detail, returned, hint}wherepathis the globalized absolute file path andbytesis the PNG byte size on disk. both: the inline shape pluspath+hint. An optionalsave_path(.png;editor.screenshotallowsres://+user://screenshots/,runtime.screenshotuser://screenshots/only) names the destination for disk/both (auto-named underuser://screenshots/when omitted); in inline mode a suppliedsave_pathis validated but not persisted.remediation(andruntime.screenshot’shint) ride all three shapes. Shaping is owned by the runtime-safecontract/screenshot_response.gdhelper. - Screenshot
image_detailcap + disclosure (both capture tools): an optionalimage_detail: "full" | "mid" | "low"(default"full", toolkit-owned) caps the inline image’s long edge —full= native,mid≈ 1024 px,low≈ 512 px — proportional, aspect-preserving, and shrink-only (never upscales) viaImage.INTERPOLATE_LANCZOS. It applies to the inline image only:image_response_mode:"disk"/"both"always persists the full-res PNG regardless. Every shape echoes the appliedimage_detailplusreturned(a"WxH"string — the returned inline image’s dims for inline/both, the full-res saved-file dims for disk), so a size reduction is never silent. disk/both add ahintdisclosing the saved file is full resolution ("Saved full-res → <path>. Read it for pixel detail without re-capturing."), which composes with (does not clobber)runtime.screenshot’s foregroundhint. The purecontract/screenshot_response.gdshaper owns theimage_detail_dimscalculator + the disclosure threading; the capture handler owns theImage.resizeand encodes a full-res buffer for disk plus a downscaled buffer for the inline base64 (both mode carries both). A badimage_detailvalue →INVALID_PARAMS.
- Success:
- Server consumer: the bridge branches on the toolkit’s
successfield:callAndWrap(registration/toolDispatch.ts) treats{success:false}→toolErrorFromPayload(shared/errorContract.ts, preservingcode+message+ the toolkithint); the happy path →stableStringify(result)(shared/schemaMin.ts, key-sort only) forwarded verbatim (REFLECT — no response-schema re-encode; the WS response resolves atmessage.resultintransport/channel.ts, the single forwarding point, wrapped as{content:[{type:"text",text:…}]}). A toolkit-suppliedhintis never overwritten — a server-sidesuccessHintis injected only when the toolkit set none (injectSuccessHint,registration/toolDispatch.ts). The screenshot tools are the one non-REFLECT success path:buildScreenshotResult(registration/screenshotResponse.ts) re-shapes the capture and carriesremediation,hint,image_detail, andreturnedthrough the text metadata block (the fields would otherwise be dropped by that re-shape). For a disk capture (apathwith noimage_base64) it emits a single text-only block —{path, width, height, bytes, mime_type, image_detail, returned, remediation?, hint?}— never an empty image block; inline/both keep the image+text multi-content. scene.create_nodeinline-property outcomes: apropertiesdict supplied at create time is post-set-verified per key. A silently-dropped write (a wrong-type valueObject.set()discards — the same classnode.set_propertyrejects) is restored, excluded fromproperties_set, and reported inproperties_failed: [{name, error}](theerrormirrorsnode.set_property’s message, incl. the bare-res://-string tagged-form steer). An engine-adjusted write (truncate/normalize) stores and counts, adding a delta warning towarnings: string[].properties_settherefore counts only the writes that actually stored.- Example: success
{"success":true,"status":"created","path":"res://scenes/main.tscn","root_name":"main"}· error{"success":false,"error":"file exists at res://…; set if_exists:'replace'…","code":"ALREADY_EXISTS"}. - Cross-ref: the
codevocabulary and hint auto-attach list = C4.
C4 — Error-code vocabulary (CODES) · public
- Toolkit shape:
MCPToolkitError.CODESenumerates 56 codes (contract/mcp_toolkit_error.gd) — the canonical wire vocabulary (full list in source).NOT_UNIQUE(script.edit:old_stringmatches more than once andreplace_allis not set) joinsNOT_FOUNDas the surgical-edit match-failure pair.EDITOR_VIEWPORT_UNAVAILABLE(editor.screenshot: window minimized-&-not-forced, or the main-screen heal left the viewport collapsed) andRUNTIME_WINDOW_MINIMIZED(runtime.screenshot: the game window can’t render — a top-level game minimized or fully occluded, decided capability-based fromDisplayServer.window_can_drawafter a bounded frame-wait, not by mode alone; an embedded playtest keeps compositing and never trips this) both signal a viewport that cannot composite a usable frame —EDITOR_VIEWPORT_UNAVAILABLEis retryable viaforce_foreground_editor, andRUNTIME_WINDOW_MINIMIZEDviaforce_foreground_gameon a top-level game (C8).DEFAULT_HINTSauto-attach for 7 codes:TIMEOUT,UNSUPPORTED,PATH_DENIED,PARENT_NOT_FOUND,COMPILATION_FAILED,GAME_NOT_RUNNING,RESPONSE_TOO_LARGE(the two screenshot codes pass an explicit cause-specific hint instead — they are not inDEFAULT_HINTS).LOG_BUSY/LOG_UNAVAILABLEare deliberately absent fromDEFAULT_HINTS: their recovery advice is version-gated (source:"buffer"is a real fallback only on Godot 4.5+), so every emit site passes the version-gatedlog_busy_hint/log_unavailable_hintexplicitly instead of relying on a default here. fail()— dev-time gate, string-tolerant wire: a debug-buildassert(code in CODES)enforces the vocabulary at dev time (mcp_toolkit_error.gd); release builds strip the assert, so the wire stays string-tolerant and theemitted ⊆ CODESaudit describes release behavior as advisory, not enforced. (UNSUPPORTED,CLASS_MISMATCH, andUNSUPPORTED_FILE_TYPEare all inCODES.)- Server consumer (own-enum + string-tolerant): the server keeps its own authoritative
ErrorCodeunion (shared/types.ts) with a “keep in sync with the toolkit codes” header obligation — but the emit path is deliberately string-tolerant:toolError(code: ErrorCode | string, …)(shared/errorContract.ts) andtoolErrorFromPayload(typeof obj.code === "string" ? obj.code : "INTERNAL") forward any unknown plugin code verbatim, never reject or remap. The union is also a superset by design — it carries bridge-origin transport codes the plugin never sends (AUTH_FAILED,CLOSED,RPC_ERROR,SEND_FAILED). Any toolkit code, including ones the server’s union has not mirrored yet, passes straight through; the dual enum is documented own-enum + string-tolerant wire, not a defect.
C5 — Dispatch + concurrency notifications · public
- Toolkit shape (dispatch lives in
transport/dispatch/*+transport/notifier.gd):- JSON-RPC errors:
-32700parse,-32600invalid request,-32601method not found / unregistered / hot-reload race (transport/dispatch/server_request_router.gd,transport/dispatch/dispatch_lane.gd),-32000mutation-watchdog timeout (server-emitted,transport/dispatch/mutation_watchdog.gd). - Server→client notifications:
_queued {"request_id":<id>}(command parked behind a mutation-lock or scene-lease) and_executing {"request_id":<id>}(mutation began) — both fromdispatch_lane.gd, sent viatransport/notifier.gd. - Client→server notifications:
_cancel {"request_id":"<id>"}(fire-and-forget cooperative cancel — triggers an in-flightctx.cancel()or flags queued entries) andecho <params>(transport diagnostic, echoes back) — both inserver_request_router.gd. - id coercion: JSON numbers parse as float; whole-float ids coerce back to int so
{"id":1}round-trips (server_request_router.gd). - Lanes: read-only commands bypass both locks; mutations serialize single-in-flight FIFO; scene-lease routing precedes both (
is_read_only/needs_serialization,transport/mcp_toolkit_command_registry.gd).
- JSON-RPC errors:
- Server consumer: the bridge owns the JSON-RPC id↔response correlation (per-request id, resolver map) in
createChannel(transport/channel.ts) and understands_queued/_executing; LSP tools are the one exception that bypasses this dispatch entirely (own TCP client, C15/C19,groups/groupToolHandlers.ts). The server is authoritative for the version-unsupported error: two layers gate before the WS forward — (1) a registration-time filter (registration/toolRegistry.ts) drops a known-incompatible tool fromtools/list; (2) a per-call defense-in-depth check returnstoolError("UNSUPPORTED", "<name> is not supported on this Godot version (connected: <maj>.<min>)", "<versionSupportText> Use classdb.get_info for alternatives.")— the version requirement rides in the hint, not the message body. On the happy path the server therefore never sees the toolkit’s-32601version-block. - Version-gating parity invariant: the server is authoritative and reports the connected version as
connected:. A version-gated built-in needs both a toolkit.with_min_godot_versiongate and a matching server-catalogue bound (the server bound is authoritative for the message). The toolkit’s own version-block branch is unreachable via live transport — the-32601 has_commandshort-circuit wins over it. Exactly one built-in is gated today:scene.close @4.5+.
C6 — Idempotency: status + if_exists · public
- Toolkit shape:
status(result discriminator): the create subset is the closed setcreated/returned(idempotent no-op) /replaced(commands/scene_commands.gd,commands/resource_commands.gd,commands/editor_helpers.gd).statusis a general result discriminator, not create-only — non-create mutations also set it (added/removedfor node-group ops incommands/node_commands.gd;already_runningincommands/playtest/playtest_control.gd). The create subset is closed; the full field is open per-command.if_exists(file-level creates):return(default → idempotent no-op,status:"returned") /fail(→ALREADY_EXISTS) /replace(→ overwrite,status:"replaced"+ metadata). An invalid value →INVALID_PARAMS.- File-level (support
if_exists):scene.create,scene.create_inherited,resource.write(viawrite_asset_with_settleincommands/editor_helpers.gd). Node-level (return-only, noif_exists):scene.create_node(matching class →returned, elseCLASS_MISMATCH),scene.instantiate(collision →returnedor auto-rename). - Returned-path disclosure: a
status:"returned"response no longer silently drops the args it didn’t apply.scene.create_node/scene.instantiateadd awarningnaming the ignoredproperties/layout_mode/unique_name/transform;resource.writeappends towarnings[]naming an ignoredtype. The disclosure fires only when such an arg was actually passed, so a no-extra-args call still gets a byte-identical response.
- Server consumer: pure passthrough (REFLECT).
if_existsis a request param the LLM sets (carried ininputSchema);statusis a response field the bridge forwards verbatim viacallAndWrap(registration/toolDispatch.ts) →stableStringify(shared/schemaMin.ts) →message.result(transport/channel.ts) — no server-side interpretation ofcreated/returned/replaced. The toolkit owns the discriminator; the server adds no idempotency logic of its own. - Example:
{"success":true,"status":"replaced","path":"res://x.tres","previous_root_type":"Resource"}.
C7 — Type-tag coercion vocabulary · public
- Toolkit shape (
contract/coerce.gd, bidirectionalcoerce_value⟷serialize_value): complex Godot types cross JSON as tagged dicts{"type":"<Tag>",…}. 18 tags:Vector2{x,y}·Vector3{x,y,z}·Vector4{x,y,z,w}·Vector2i{x,y}·Vector3i{x,y,z}(noVector4i) ·Color{r,g,b,a}(a default 1.0) ·Rect2{x,y,w,h}·Rect2i{x,y,w,h}·Transform2D{x_axis,y_axis,origin}·Transform3D{basis{x,y,z},origin}·NodePath{path}·Resource{path[,class]}·ResourceRef{path}(alias of Resource) ·NewResource{class,properties}(input-only) ·PackedVector2Array{values:[…]}·PackedVector3Array{values:[…]}·PackedColorArray{values:[…]}·LayerMask{category="2d_physics",layers:[…]}→ int bitmask.- Unknown tag → reject with
{"_coerce_error":"Unknown type tag '<x>'. Supported: …"}— not passed through.
- Server consumer (REFLECT): the server does all coercion handling on the request path only —
inputSchemaZod with LLM string-coercion (addStringCoercion,shared/schemaCoercion.ts) so a JSON-stringified tagged dict re-parses; it does not re-encode on the response path. The tagged read-back forms (including theserialize_valuePacked* arms and thesave.read/script.readpaging fields) surface transparently throughcallAndWrap(registration/toolDispatch.ts) →stableStringify(key-sort only,shared/schemaMin.ts) →message.result(transport/channel.ts). The bidirectional symmetry is therefore the toolkit’scontract/coerce.gdcontract; the server forwards both directions verbatim. (Some top-level paging keys, e.g.offset, are declared in the request Zod so the catalogue does not strip them — request-path, not a response re-encode.) - Example:
{"type":"Transform3D","basis":{"x":{"x":1,"y":0,"z":0},"y":{"x":0,"y":1,"z":0},"z":{"x":0,"y":0,"z":1}},"origin":{"x":0,"y":1,"z":0}}.
C8 — Tool names & param schemas · public
- Toolkit shape: ~102 handlers across the
commands/*.gdmodules (including thecommands/{editor,playtest,tileset}/subdirs), each astatic register(registry, server), nameddomain.verb(e.g.scene.create,node.set_property). Param schemas are the JSON shapes each handler reads fromparams(including the C7 tagged types). The naming conventiondomain.verb→domain_verb(snake_case tool name) is what the server mirrors. - Server consumer: the projected surface is the catalogue —
ALL_TOOL_DEFS(registration/catalogue.ts), the single deduplicated SSOT of everyToolDef {name (snake_case), method (dotted), inputSchema, annotations?, godotMin/MaxVersion?, successHint?, pathParams?}(shared/types.ts), spread fromtools/*.ts. The eager startup surface is a visibility partition =EAGER_TOOLS − GROUP_TOOL_NAMES(startup/serverMode.ts,security/profiles.ts), plus 2 meta tools (discover_tools,extensions_refresh) registered directly inindex.ts/groups.ts— outsideALL_TOOL_DEFS; on-demand group tools are absent (no stubs) untildiscover_toolsactivates them. Every registration funnels through the singleregisterToolWrappedchoke point (registration/toolRegistry.ts). A CI completeness gate (test/sections/01_catalogue.ts) asserts the group / runtime / LSP names ⊆ALL_TOOL_NAMES, no dups — the naming lockstep is build-enforced. Current scale is ~110 tool defs (≈33 eager + ≈77 on-demand) plus the 2 meta tools. - Tool list lives elsewhere: the authoritative per-tool name + parameter schema list is not duplicated here — it lives in the generated tool-reference doc. This section covers the naming convention and the registration/activation mechanism only.
- Read/write split: read-only sibling tools are kept separate from their mutating verbs (command-query separation) — e.g.
audiobus.list(commands/audiobus_commands.gd) andanimationtree.list(commands/animation_commands.gd) are standalone read tools, not sub-actions of an*.editverb. - Whole-file vs surgical write split:
script.write(whole-file create/overwrite) andscript.edit(surgicalold_string→new_stringreplacement) are kept as separate mutating verbs — the MCP analogue of nativeWritevsEdit— rather than one overloaded write tool (commands/script_commands.gd). Both route through the same private write/undo/index/diagnose pipeline (_commit_content), so an edit carries the identical UndoRedo entry,EditorFileSystemreindex, and inline.gddiagnostics as a whole-file write, plus areplacementscount.script.editreportsNOT_FOUND(no match),NOT_UNIQUE(multiple matches withoutreplace_all), orINVALID_PARAMS(no-op / emptyold_string). - Uniform pagination contract: read/cap tools return one self-describing envelope, built through the shared
Modules.Paginationclass (contract/pagination.gd) —returned(this page’s size), a canonicaltotal_<unit>(total_classes,total_nodes,total_cells,total_assets,total_dependencies,total_lines,total_matches,total_bytes), andhas_morealways present, plus a linear resume field (next_offset/next_start_line) where the read is resumable — or a documented cursor-less nav (path_prefix/region) where it is not — with the paginate guidance in each tool’s.describe()(paginationDoc). Response is REFLECT (no server-side response builder); the only server reads of these fields are the summary handlers (consoleSummaryHandler,debuggerLogHandler) plus theerrorContract.tscrash-context fallback, via sharedPAGE_FIELDconsts (ADR 0020 · code-standards B5). - Event-batch vocabulary:
input_simulate(input.simulate,runtime/mcp_runtime_server.gd) exposes an enumeratedevent_typeset as part of its public param schema —key | mouse_button | mouse_motion | action | click | click_node | send_text.send_textis a convenience synthesizer (a string → NInputEventKey(unicode)events delivered viaViewport.push_input+ an optionalsubmitEnter) returningfocus_target/focus_source/text_changed/text_after(secret-redacted) /chars_sent/hint. The server mirrors the vocabulary in theevent_typezod enum (twice — single-object and array-item variants);event_dataforwards untyped (z.record), so the per-field shape is the toolkit’s contract. - Screenshot foreground levers:
editor_screenshot(editor.screenshot) reads an optionalforce_foreground_editor: bool(default false) andruntime_screenshot(runtime.screenshot,runtime/mcp_runtime_server.gd) an optionalforce_foreground_game: bool(default false). When set, the tool un-minimizes + raises/focuses the target window before capturing (then discloses viaremediation, C3) instead of returning theEDITOR_VIEWPORT_UNAVAILABLE/RUNTIME_WINDOW_MINIMIZEDsignal (C4); default-off so an interactive user’s window is never yanked. The server declares both asz.boolean().optional()in the tool inputSchema with a.describe(), so they reach the toolkit (the catalogue zod strips undeclared top-level params). - Screenshot capture-return controls: both capture tools also read an optional
image_response_mode: "inline" | "disk" | "both"(z.enum(...).optional(); toolkit default"inline") andsave_path: string(the.pngdestination for disk/both; auto-named when omitted).editor.screenshot’ssave_pathallowsres://oruser://screenshots/;runtime.screenshot’s allowsuser://screenshots/only (pathParamsprefixes["user://screenshots/"]). The response shapes these select are the C3 inline/disk/both envelopes; the enum has no.default()(the toolkit owns the default, the.describe()states it). Both tools additionally read an optionalimage_detail: "full" | "mid" | "low"(z.enum(...).optional(), no.default()— toolkit-owned default"full") capping the inline image’s long edge (mid≈ 1024 px,low≈ 512 px; proportional, aspect-preserving, shrink-only; inline-only — disk/both stay full-res); the response echoes the appliedimage_detail+ areturned“WxH” (C3).editor.screenshot’s formersize{width,height}param is removed (pre-1.0, no back-compat) — it was node-focus-only exact-WxH that could aspect-distort/upscale;image_detail’s universal proportional cap supersedes it, and node framing staysnode_path’s job.
C9 — Read-only model · public
- Toolkit shape: server-authoritative. The toolkit publishes per-tool annotations (
readOnlyHint/destructiveHint, via the friendly→MCP map on the options builder,contract/mcp_toolkit_command_options.gd); it does not gate dispatch onGODOT_MCP_READ_ONLY. It consumes its ownread_onlyflag only for (1) concurrency routing (is_read_only()→needs_serializationread-bypass,transport/mcp_toolkit_command_registry.gd) and (2) a best-effort dock/status mirror parsed from.mcp.json(ui/mcp_json_sync.gd, non-authoritative). Annotation correctness is load-bearing for both read-only filtering and the concurrency lock-bypass.save.write/save.deletecarrydestructiveHint:true. - Known annotation caveats:
game.start/game.stopare markedread_only(masked in practice by exclusive execution), anddebugger.get_logis read-only yet canstop_playing_scene. These are minor accuracy edges, not contract guarantees. - Server consumer: (a) layer = filter at registration —
isExcludedByReadOnly(readOnly, annotations)(security/profiles.ts) skips the tool so it is never registered → absent fromtools/list(reg sites acrossregistration/toolRegistry.ts,groups/groupActivation.ts,groups/extensionGroups.ts,extensions/extensionRegistrar.ts,extensions/extensionChanges.ts). There is no per-call forward-time reject — the tool simply is not there. (b) semantics = STRICT:isAllowedInReadOnly(security/profiles.ts) exposes a tool iffreadOnlyHint:true ∧ ¬destructiveHint; an un-annotated tool → excluded (safe), so an un-annotated mutating tool is hidden. The server adds one server-local rule:readOnlyHint ∧ destructiveHintis a contradiction → treat-as-mutating + stderr warn. (c) test:groups.test.tsread-only-filter contract +profilesunit coverage.
C10 — Environment variables & CLI flags · public
- Toolkit-consumed:
GODOT_MCP_TOKEN_PATH(overridesget_token_path(),security/auth.gd); the listen-port vars, all resolved by the export-cleantransport/port_config.gdand consumed bytransport/mcp_server.gd(editor) +runtime/mcp_runtime_server.gd(runtime):GODOT_MCP_EDITOR_PORT/GODOT_MCP_RUNTIME_PORTpin an exact listen port (bind-exact-or-fail — never scans elsewhere, bounded same-port grace then a loud error), andGODOT_MCP_EDITOR_PORT_MIN/_MAX/GODOT_MCP_RUNTIME_PORT_MIN/_MAXrelocate the scan band (defaults 6550–6560 / 6570–6585, inclusive). The two modes are exclusive (a pin ignores the band); a malformed pin / out-of-range / MIN>MAX is a clear error (dock + log), never a silent default.GODOT_MCP_HIDE_UNAVAILABLEis reserved/unused. (The formerGODOT_MCP_PORT-as-toolkit-consumed entry was an error — the toolkit read zero port env before this cell; the six vars above are its first.) - Server-consumed (the bridge reads its process env, launched from
.mcp.json):GODOT_MCP_READ_ONLY(→ the C9 registration filter,security/profiles.ts);GODOT_MCP_EDITOR_PORT/GODOT_MCP_RUNTIME_PORT(pin a channel port to dial, skip the registry lookup — read inindex.ts, threaded intocreateBridge; the same two vars the toolkit reads to listen, so a pin inherited by both processes makes listen + dial agree);GODOT_MCP_LSP_PORT/GODOT_MCP_LSP_HOST(the top-priority LSP endpoint override, the multi-instance lever —lsp/lspClient.ts, C19);GODOT_MCP_PROJECT_PATH(?? cwdfor registry lookup + LSP dispatch —index.ts,groups/groupToolHandlers.ts);GODOT_MCP_CONFIG_VERSION(set by the template, a migration marker). The_MIN/_MAXband vars are listen-side only — the server never reads them (registry discovery covers the scanned case).GODOT_MCP_TOKEN_PATHis also the server-side operator override (C18,transport/tokenPath.ts). The deadGODOT_MCP_PROFILE/--litedeprecation stubs are removed (no-op pre-1.0). - Server CLI flags (hand-rolled parser in
index.ts; precedence CLI > env > registry discovery > default):--editor-port <n>/--runtime-port <n>/--lsp-port <n>/--lsp-host <h>move the dial target (connect-side — CLI can’t reach the toolkit’s listen port), plus--helpand--tools-count. - Desync cross-check: because a pin can reach one side only (e.g.
.mcp.jsonsets it for the server, a desktop-shortcut editor launch doesn’t inherit it), when a pin is in effect the server does one registry read at connect to verify the live editor is on the pinned port; a mismatch is a precise, actionable error, never a silent hang. The toolkit publishes its real bound port in all modes (pin included) — re-published on every fresh bind, so even a pinned port that frees late after a startup conflict lands in the registry — precisely so this check has ground truth. The listen-side twin is the editor dock warning + status-row state on any not-listening condition (pinned port occupied, scan band exhausted, or invalid config). - Constraint: the
.mcp.json.templatesetsGODOT_MCP_CONFIG_VERSIONbut notGODOT_MCP_TOKEN_PATH; with it unset the toolkit globalizesuser://…/mcp_tokento an absolute path before publishingtoken_path(→ C18), so the bridge does not have to derive it.
C11 — .mcp.json file format · public
- Toolkit shape: read (
ui/mcp_json_sync.gd) — readsmcpServers.<key>.env(string-coerced), keying on agodot-mcp-toolkit/godot-mcpsubstring. write (ui/mcp_json_sync.gd, invoked via theui/mcp_json_write_flow.gdconfirm helper from the dock/wizard/tool-menu) — builds themcpServers.<key>entry per-OS (macOS:npx; Windows:cmd /c npx; Linux:npx) from the template’s env skeleton, preserving an existing file’sGODOT_MCP_*env keys; regenerated on demand. - Server consumer: the MCP client (Claude Code) reads
.mcp.jsonto launch the server with the right command + env. The server itself re-reads it on config-reload:readMcpJsonEnv(startup/configReload.ts) finds thegodot-mcp*server entry (mirroring the toolkit’s_find_server_key) andapplyEnvUpdatereconciles onlyGODOT_MCP_*keys (deletes removed, sets new — never touches unrelated env), driven by the debouncedhandleConfigReload(startup/reconcile.ts). Theconfig_versionmigration is a client/server matter, outside this cell’s wire shape.
C12 — Tool group names · semi-public
- Toolkit shape: each command may declare a
groupvia the options builder (contract/mcp_toolkit_command_options.gd); the registry publishes that per-commandgroup(transport/mcp_toolkit_command_registry.gd). Group membership + on-demand activation are server-side. - Server consumer: the server owns the group catalogue —
GROUPS: GroupDef[](canonical order ingroups/builtinGroups.ts, per-group defs ingroups/defs/*.ts; each{name, description, tools: string[], keywords: string[]}) + the derivedGROUP_TOOL_NAMES/RUNTIME_TOOLS/LSP_TOOLSindex sets (groups/groupCatalogue.ts).discover_toolskeyword-searches/scores/activates these group names (groups/groupMatch.tsscoring +groups/groupActivation.ts); group membership holds tool names resolved againstALL_TOOL_DEFS. - Group list lives elsewhere: the authoritative group-name catalogue is not duplicated here — it lives in the generated tool-reference doc.
C13 — Extension API · semi-public
- Toolkit shape: an extension is an
MCPToolkitExtension(GDScript,extensions/mcp_toolkit_extension.gd) or anMCPToolkit*-prefixed.cs(C#) with one virtualregister(registry, server). Theregistryfacade (the single reachable surface, especially for C# which cannot await GDScript statics) exposes 7 methods (transport/mcp_toolkit_command_registry.gd):create_options(),create_extension_options(description),create_undo_action(description, context_object=null),queue_save(path=""),check_save(save_id, clear=false),fail(code,message,hint=""),require(parameters, required).- Cancellation ctx (
contract/mcp_toolkit_tool_context.gd):signal cancelled+is_cancelled() → bool. Handler arity — non-cancellable handlers get(params), cancellable get(params, ctx)(dispatch passesctxwhen the command isis_cancellable). - Safe-save (
scene/mcp_toolkit_safe_scene_ops.gd) and the undo builder (scene/mcp_toolkit_undo_redo_action.gd, headless-no-op) are the other semi-public extension surfaces — extension code depends on their method names.
- Cancellation ctx (
- Server consumer: the server projects each discovered extension command into an MCP tool.
discoverExtensions()(extensions/extensionDiscovery.ts) reads the toolkit’sextensions.listcommands → builds{readOnlyHint/destructiveHint/idempotentHint ?? false}annotations, applies the C9 read-only skip, and registers viaregisterToolWrapped(extensions/extensionRegistrar.ts); grouped extension commands defer to a dynamic extension-group (groups/extensionGroups.tsaddExtensionGroup). Each extension cmd carries its own raw JSON-Schema → Zod (jsonSchemaToZodShape,shared/schemaCoercion.ts) + version bounds (min/max_godot_version). Threat model = full-trust (ADR 0009). - Collision guard: the toolkit rejects a colliding extension
add(wire shape unchanged); the server also guards explicitly —extensionNameCollides(toolName)=isBuiltinToolName(registration/catalogue.ts, covers on-demand + meta built-ins thathasToolRefmisses pre-registration) ORhasToolRef, checked at all 4 ext-registration sites (helperregistration/extensionCollision.ts). A colliding extension name is skipped + warned; the incumbent (built-in / first-registered) always wins, never crashes. The MCP SDK’sregisterToolthrows on a dup, so the pre-check prevents that aborting the batch. The guard also covers the reconcile ledger-add and grouped activation.
C14 — Extension surface signaling · semi-public
- Toolkit shape (
extensions/extension_loader.gdis a thin facade; the work lives inextensions/services/*):extensions.list→{"success":true,"commands":[{method,description?,input_schema?,annotations?,group?,timeout_ms?}]}(extensions/services/extension_meta_commands.gd).extensions.refresh→ the same +{"refreshed":true,"hint"?}(extensions/services/extension_watcher.gd).extensions.changedbroadcast (on hot-reload add/remove/modify):{"notification":"extensions.changed","params":{"commands":[…],"removed":[…method names]}}, sent to all authed peers viaserver.broadcast_notification(extension_watcher.gd→transport/mcp_server.gd).
- Server consumer: the server declares
capabilities.tools.listChanged: true(index.ts) and emits exactly onetools/list_changedper change viabatchToolRegistration(registration/toolRegistry.ts, the monkey-patch-then-fire-once primitive). On the toolkit’sextensions.changedbroadcast,handleExtensionsChanged()(extensions/extensionChanges.ts) reconciles the dynamic extension surface — re-adding via the idempotentaddExtensionGroup(groups/extensionGroups.ts, dedup-by-method) + re-registering ungrouped cmds — inside one batch.extensions_refreshis the always-on meta-tool (extensions/extensionRegistrar.ts) the LLM calls to force a manual re-sync. - Platform caveat:
tools/list_changedis unreliable in all Claude Code modes (dropped notifications). The bridge cannot rely on the broadcast alone for live refresh;extensions_refresh+ eager-promotion of dynamic-activation-sensitive tools (security/profiles.tsEAGER_TOOLS) compensate. - Extension read pattern: extension-provided read commands follow the same uniform pagination envelope as built-ins (
returned+ canonicaltotal_<unit>+has_more, plus a resume field or cursor-less nav where applicable; C8). Like every extension response it surfaces transparently (REFLECT) — the server forwardsmessage.resultverbatim (C3/C7) — so a paginating extension read needs no server-side schema; the contract is the only convention to follow.
C15 — LSP status round-trip (reverse: server→toolkit) · semi-public
- Toolkit shape: the bridge is the LSP-liveness authority (cross-process liveness + a root-verify the editor cannot do) and pushes
set_lsp_status {state: active|conflict|unavailable, host, port, detail}back to the toolkit; the toolkit receives it as theeditor.set_lsp_statuscommand (commands/editor/editor_commands.gd) →set_reported_lsp_status(transport/mcp_server.gd) →paths/lsp_publisher.gd→ dock render. (The toolkit publishes the intended endpoint — C19; the server verifies + reports.) - Server consumer (the server is the EMITTER, not a reader): the server computes the dock verdict and pushes it. Two tiers: a fast registry verdict
getLspStatus(projectPath)(lsp/lspClient.ts, resolution only, no socket) fired on bridge connect/reconnect + config-reload, then a verified verdict from a real connect via the status reporter insideensureLsp(lsp/lspSession.ts) — both de-duped bystate:host:portand sent throughsendLspStatus→editor.set_lsp_status(ADR 0008,lsp/lspStatusReporter.ts).LspStatus = {state: active|conflict|unavailable, host, port, detail}(lsp/lspClient.ts). The toolkit only renders it; the server originates it (the editor cannot self-determine its own LSP bind status).
C16 — projects.json registry format · internal
- Toolkit shape: a machine-wide registry dir (NOT
user://;%APPDATA%/~/Library/Application Support/$XDG_DATA_HOME,registry/store/registry_paths.gd). Each editor writes a uniqueentries/<hash>.json(registry/registry_client.gd); a locked rebuild (_rebuild_projects_json→registry/store/registry_projection.gdmerge_by_path) fans in toprojects.jsonfor the bridge. Per-entry fields:_key(project path),port,token_path,pid,started_at,godot_version,runtime_port,runtime_pid,lsp_host,lsp_port(registry/store/registry_entry_file.gdbuild_entry). Atomic writes (entry tmp→rename; registry two-phase tmp→.bak→target). GC = port-conflict pruning only (PID-GC removed — Windowsis_process_runningfalse-positives). - Server consumer:
readRegistry()reads exactlyregistryPath()=…/godot-mcp-toolkit/projects.json(registry.ts) — never the per-instanceentries/dir, so any runtime-side entry split is purely toolkit-internal. The server’sRegistryEntrytype (registry.ts) matches the toolkitbuild_entrywriter field-for-field, withgodot_version?/lsp_*?optional as the forward-compat seam for old-toolkit entries. Tolerance: stale editor entries linger (no PID-GC) and the server reads them; runtime-only entries (port:-1,token_path:"",lsp_port:null) are read gracefully (discoverLspEndpointreturns null onlsp_port==null); ping-pong port transitions are absorbed tear-down-before-connect bydiffAndNotify. The path-normalization parity (normalizePath↔ toolkitcanonical()) has no shared equivalence fixture — a candidate cross-repo regression test.
C17 — Project-instance hash · internal
- Toolkit shape:
ProjectPaths.project_hash()(paths/project_paths.gd) = SHA-256 of the canonical project root →\→/→ strip trailing/→ lowercase on Win/macOS → first 12 hex (paths/project_key.gdcurrent_hash). - Server consumer: the bridge does NOT recompute the sha256 hash.
by_pathis keyed by_key= the canonical project PATH (registry/store/registry_projection.gdmerge_by_path; the frozen contract inpaths/project_key.gd— the normalized path is the_keythe TypeScript bridge reads, while the 12-char hash names the per-instance entry file). The server forms the same key bynormalizePath(projectPath)(registry.ts, used bylookupProject) — recomputing the path normalization (backslash→/, strip trailing/, lowercase on Win/macOS), not the hash. The 12-char hash is a toolkit-internal filename /user://-dir token the bridge never consumes; the only cross-repo recompute is the canonicalization recipe (the no-shared-fixture gap → the C16 test candidate).
C18 — Token file discovery (token_path) · internal
- Toolkit shape: the token is written to
<instance_dir>/mcp_token(or$GODOT_MCP_TOKEN_PATH); thetoken_pathpublished in the registry entry is the GLOBALIZED ABSOLUTE path —MCPAuth.get_published_token_path()=ProjectSettings.globalize_path(get_token_path())(security/auth.gd), used at the editor publish sites. In-engine readers keep theuser://form (get_token_path(), unchanged); the runtime autoload’s empty-token_pathwriter is untouched. - Server consumer: the bridge looks up
entry.token_pathvialookupProjectand structurally validates it before opening — it does NOT recomputesha256(path)or re-derive the path.readToken(projectPath)(transport/tokenPath.ts, consumed bytransport/channel.tsperformAuthon every connect) →lookupProject(registry.ts) →assertPublishedTokenPath(transport/tokenPath.ts): absolute · no..· an existing regular file · suffix matching…/addons/godot_mcp_toolkit/project_instance_<12-hex>/mcp_token(a format check on the instance segment[0-9a-f]{12}, not a recomputed hash; lexical only).GODOT_MCP_TOKEN_PATHis an operator override read directly (absolute + existing-file; bypasses the suffix check). Every failure throws a distinct, actionableAUTH_FAILED(no silent stale-open). Becauseglobalize_pathreadsuse_custom_user_dirlive, a relocateduser://is honored. See ADR 0009 / 0011.
C19 — LSP endpoint publishing · internal
- Toolkit shape: the editor resolves the endpoint via
resolve_lsp_endpoint(EditorSettingsnetwork/language_server/remote_host/remote_port; default127.0.0.1:6005) — the logic lives inpaths/lsp_publisher.gd, delegated fromtransport/mcp_server.gd— and passes the resolvedlsp_host/lsp_portintoregister().registry/registry_client.gdstays export-clean (it receives params, never touchesEditorInterface). - Server consumer: the LSP client reads
entry.lsp_host/lsp_portfromprojects.jsonviadiscoverLspEndpoint(projectPath)(registry.ts) — the middle tier of the 3-tier resolver (GODOT_MCP_LSP_PORTenv → registry → guarded-6005-never-blind;lsp/lspClient.ts). The registry tier applies an earliest-live-claimant ownership rule (liveLspClaimants,registry.ts: connect only if strictly the earliest live claimant bystarted_at, else{conflict}→LSP_PORT_CONFLICT) with PID-liveness viaisPidAlive→process.kill(pid,0)(reliable on Windows, unlike the toolkit’sOS.is_process_running). Then it verifies the live endpoint and reports the verdict → C15. Thenull-lsp_portruntime-only case returns null (no LSP), per C16.
C20 — Runtime discovery · internal
- Toolkit shape: a live game writes
runtime_port/runtime_pidinto its registry entry on bind (registry/registry_client.gdset_runtime, read-merge); both are cleared on stop. - Server consumer: the bridge reads
runtime_portviadiscoverRuntime/getCachedRuntimePort(registry.ts) to reach the running game (Mode B,callRuntime) vs the edited scene (Mode A,call);diffAndNotify(registry.ts) watches onlyruntime_porttransitions (null→port / port→null / port→port) to re-target the runtime channel — editorport/godot_version/lsp_portchanges do not flow through it (by design — the watcher exists for playtest discovery). Liveness asymmetry: the runtime path does not gate onisPidAlive(runtime_pid)(the LSP path does), so it can return a staleruntime_portfrom a crashed playtest; the bridge’s connect-failure absorbs it. - Constraint: a
config/namerename currently re-publishes viaregister, which nullsruntime_port/runtime_pid; the editor⟷runtime entry RMW is unsynchronized. The server tolerates the resulting transitions (above).
C21 — Untrusted envelope · internal (LLM-facing)
- Toolkit shape: read-path project content is wrapped
<untrusted-{nonce} kind="…" source="…">\n{body}\n</untrusted-{nonce}>, the nonce%08x randi(), with any existing<untrusted…>tags pre-scrubbed (anti tag-breakout). Read-paths only (never writes / binary).Scrubber.scrub(security/scrubber.gd) redacts secrets from logs/console before return (3 precision regexes →[REDACTED]). - Server consumer (REFLECT verbatim): the toolkit does the primary wrapping (
security/untrusted.gdwrap, across script / scene_tree / user-file / resource / project_settings / console / animation / audiobus / game_log read paths) and the server forwards it verbatim —callAndWrap(registration/toolDispatch.ts) →stableStringify(shared/schemaMin.ts) →message.result(transport/channel.ts), no double-wrap, no unwrap/parse. The server has its ownuntrustedWrap(security/untrusted.ts, an exact TS mirror — same envelope + scrub) used in exactly one place:tools/lsp.tswrapslsp_hovertext, which never transits the toolkit wrapper (it comes straight from the engine LSP over the server’s own TCP socket). The other 5 LSP tools return structured data (no free-form prose) → no wrap.
C22 — ProjectSettings keys · internal
- Toolkit shape (
core/settings_registration.gd):mcp_toolkit/limits/ws_buffer_kb(1024) +save_read_cap_kb(256), plus the limits / audit / status keys (auditaudit/enableddefault-on;limits/*clamped), plus the concurrency group:mcp_toolkit/concurrency/scan_idle_timeout_ms(5000 — how long a scene save/open waits for the EditorFileSystem scan before aborting) andmcp_toolkit/concurrency/mutation_watchdog_grace_ms(60000 — grace added to a mutation’s deadline before the dispatch watchdog force-clears a wedged lock; read live at dispatch time). Personal prefs (e.g. the unfocused-editor setting) live in EditorSettings, not ProjectSettings. - Server consumer: NONE. These ProjectSettings are toolkit-internal config — the server never reads or writes
mcp_toolkit/*. Thews_buffer_kb/save_read_cap_kb/ audit / status keys are honored entirely editor-side; the server learns their effect only through wire behavior (a dropped over-buffer frame, C1; thesave.readcap surfaced via response fields). The contract that the toolkit denies LLM writes tomcp_toolkit/*/mcp/*/editor/*is toolkit-enforced. Listed for completeness; no server consumer applies.