Contract Surface — godot-mcp-toolkitgodot-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_MAX in transport/mcp_server.gd; GODOT_MCP_EDITOR_PORT pins an exact port bind-exact-or-fail, GODOT_MCP_EDITOR_PORT_MIN/_MAX relocate the band — resolved by the export-clean transport/port_config.gd, see C10), bind 127.0.0.1 (the BIND const).
    • Mode B (runtime / in-game): default scan band 6570–6585 (PORT_MIN/PORT_MAX in runtime/mcp_runtime_server.gd; GODOT_MCP_RUNTIME_PORT pins bind-exact-or-fail, GODOT_MCP_RUNTIME_PORT_MIN/_MAX relocate — same transport/port_config.gd resolver), bind 127.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.
  • 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 (C4 RESPONSE_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 from projects.json (registry.ts) and the runtime channel via the entry’s runtime_port (C20), opening a ws://127.0.0.1:<port> text channel that speaks the same one-JSON-object-per-frame JSON-RPC 2.0 framing. Static GODOT_MCP_EDITOR_PORT / GODOT_MCP_RUNTIME_PORT overrides (or the --editor-port / --runtime-port CLI flags) pin a port and skip the registry lookup (read in index.ts, threaded into createBridge), 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 the ws library.
  • 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_ack in transport/mcp_server.gd) — carries the engine + plugin version (the server’s version-gating input) plus headless (DisplayServer.get_name()=="headless", via VersionUtils.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 or headless fields.
    • 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.
  • 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 via createChannel’s performAuth, re-reading the token from disk on every connect (transport/channel.tsreadToken, 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 parses godot_version → the bridge’s getGodotVersion() (transport/bridge.ts) and stores it (feeds the version gate, C5/C9), and likewise parses headlessbridge.isHeadless() (transport/bridge.ts) — undefined pre-auth, mirroring getGodotVersion() — which the smoke/flows suites read to branch their headless-degraded tool assertions. For Mode B (runtime) the ack carries no version (or headless) → 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) returns data with success=true added (contract/mcp_toolkit_success.gd). It mutates its argument in place (deliberate). Success payloads never carry code.
    • Error: MCPToolkitError.fail(code,message,hint){"success":false,"error":<message>,"code":<code>}, plus "hint" iff an explicit non-empty hint is passed or code ∈ DEFAULT_HINTS (contract/mcp_toolkit_error.gd). Error payloads never carry status.
    • remediation: string[] (success-side disclosure): a successful capture that required a visible side effect to produce a usable frame lists it here — editor.screenshot emits "switched_main_screen" (auto-healed a non-2D/3D main screen) and/or "foregrounded_editor"; runtime.screenshot emits "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 a hint (never foregrounded_game).
    • Screenshot image_response_mode (both capture tools): an optional image_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, no image_base64): {path, width, height, bytes, mime_type, image_detail, returned, hint} where path is the globalized absolute file path and bytes is the PNG byte size on disk. both: the inline shape plus path + hint. An optional save_path (.png; editor.screenshot allows res:// + user://screenshots/, runtime.screenshot user://screenshots/ only) names the destination for disk/both (auto-named under user://screenshots/ when omitted); in inline mode a supplied save_path is validated but not persisted. remediation (and runtime.screenshot’s hint) ride all three shapes. Shaping is owned by the runtime-safe contract/screenshot_response.gd helper.
    • Screenshot image_detail cap + disclosure (both capture tools): an optional image_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) via Image.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 applied image_detail plus returned (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 a hint disclosing 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 foreground hint. The pure contract/screenshot_response.gd shaper owns the image_detail_dims calculator + the disclosure threading; the capture handler owns the Image.resize and encodes a full-res buffer for disk plus a downscaled buffer for the inline base64 (both mode carries both). A bad image_detail value → INVALID_PARAMS.
  • Server consumer: the bridge branches on the toolkit’s success field: callAndWrap (registration/toolDispatch.ts) treats {success:false}toolErrorFromPayload (shared/errorContract.ts, preserving code + message + the toolkit hint); the happy path → stableStringify(result) (shared/schemaMin.ts, key-sort only) forwarded verbatim (REFLECT — no response-schema re-encode; the WS response resolves at message.result in transport/channel.ts, the single forwarding point, wrapped as {content:[{type:"text",text:…}]}). A toolkit-supplied hint is never overwritten — a server-side successHint is 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 carries remediation, hint, image_detail, and returned through the text metadata block (the fields would otherwise be dropped by that re-shape). For a disk capture (a path with no image_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_node inline-property outcomes: a properties dict supplied at create time is post-set-verified per key. A silently-dropped write (a wrong-type value Object.set() discards — the same class node.set_property rejects) is restored, excluded from properties_set, and reported in properties_failed: [{name, error}] (the error mirrors node.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 to warnings: string[]. properties_set therefore 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 code vocabulary and hint auto-attach list = C4.

C4 — Error-code vocabulary (CODES) · public

  • Toolkit shape: MCPToolkitError.CODES enumerates 56 codes (contract/mcp_toolkit_error.gd) — the canonical wire vocabulary (full list in source). NOT_UNIQUE (script.edit: old_string matches more than once and replace_all is not set) joins NOT_FOUND as the surgical-edit match-failure pair. EDITOR_VIEWPORT_UNAVAILABLE (editor.screenshot: window minimized-&-not-forced, or the main-screen heal left the viewport collapsed) and RUNTIME_WINDOW_MINIMIZED (runtime.screenshot: the game window can’t render — a top-level game minimized or fully occluded, decided capability-based from DisplayServer.window_can_draw after 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_UNAVAILABLE is retryable via force_foreground_editor, and RUNTIME_WINDOW_MINIMIZED via force_foreground_game on a top-level game (C8). DEFAULT_HINTS auto-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 in DEFAULT_HINTS). LOG_BUSY / LOG_UNAVAILABLE are deliberately absent from DEFAULT_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-gated log_busy_hint / log_unavailable_hint explicitly instead of relying on a default here.
  • fail() — dev-time gate, string-tolerant wire: a debug-build assert(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 the emitted ⊆ CODES audit describes release behavior as advisory, not enforced. (UNSUPPORTED, CLASS_MISMATCH, and UNSUPPORTED_FILE_TYPE are all in CODES.)
  • Server consumer (own-enum + string-tolerant): the server keeps its own authoritative ErrorCode union (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) and toolErrorFromPayload (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: -32700 parse, -32600 invalid request, -32601 method not found / unregistered / hot-reload race (transport/dispatch/server_request_router.gd, transport/dispatch/dispatch_lane.gd), -32000 mutation-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 from dispatch_lane.gd, sent via transport/notifier.gd.
    • Client→server notifications: _cancel {"request_id":"<id>"} (fire-and-forget cooperative cancel — triggers an in-flight ctx.cancel() or flags queued entries) and echo <params> (transport diagnostic, echoes back) — both in server_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).
  • 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 from tools/list; (2) a per-call defense-in-depth check returns toolError("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 -32601 version-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_version gate 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_command short-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 set created / returned (idempotent no-op) / replaced (commands/scene_commands.gd, commands/resource_commands.gd, commands/editor_helpers.gd). status is a general result discriminator, not create-only — non-create mutations also set it (added / removed for node-group ops in commands/node_commands.gd; already_running in commands/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 (via write_asset_with_settle in commands/editor_helpers.gd). Node-level (return-only, no if_exists): scene.create_node (matching class → returned, else CLASS_MISMATCH), scene.instantiate (collision → returned or auto-rename).
    • Returned-path disclosure: a status:"returned" response no longer silently drops the args it didn’t apply. scene.create_node / scene.instantiate add a warning naming the ignored properties/layout_mode/unique_name/transform; resource.write appends to warnings[] naming an ignored type. 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_exists is a request param the LLM sets (carried in inputSchema); status is a response field the bridge forwards verbatim via callAndWrap (registration/toolDispatch.ts) → stableStringify (shared/schemaMin.ts) → message.result (transport/channel.ts) — no server-side interpretation of created/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, bidirectional coerce_valueserialize_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} (no Vector4i) · 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 onlyinputSchema Zod 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 the serialize_value Packed* arms and the save.read / script.read paging fields) surface transparently through callAndWrap (registration/toolDispatch.ts) → stableStringify (key-sort only, shared/schemaMin.ts) → message.result (transport/channel.ts). The bidirectional symmetry is therefore the toolkit’s contract/coerce.gd contract; 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/*.gd modules (including the commands/{editor,playtest,tileset}/ subdirs), each a static register(registry, server), named domain.verb (e.g. scene.create, node.set_property). Param schemas are the JSON shapes each handler reads from params (including the C7 tagged types). The naming convention domain.verbdomain_verb (snake_case tool name) is what the server mirrors.
  • Server consumer: the projected surface is the catalogueALL_TOOL_DEFS (registration/catalogue.ts), the single deduplicated SSOT of every ToolDef {name (snake_case), method (dotted), inputSchema, annotations?, godotMin/MaxVersion?, successHint?, pathParams?} (shared/types.ts), spread from tools/*.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 in index.ts / groups.tsoutside ALL_TOOL_DEFS; on-demand group tools are absent (no stubs) until discover_tools activates them. Every registration funnels through the single registerToolWrapped choke 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) and animationtree.list (commands/animation_commands.gd) are standalone read tools, not sub-actions of an *.edit verb.
  • Whole-file vs surgical write split: script.write (whole-file create/overwrite) and script.edit (surgical old_stringnew_string replacement) are kept as separate mutating verbs — the MCP analogue of native Write vs Edit — 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, EditorFileSystem reindex, and inline .gd diagnostics as a whole-file write, plus a replacements count. script.edit reports NOT_FOUND (no match), NOT_UNIQUE (multiple matches without replace_all), or INVALID_PARAMS (no-op / empty old_string).
  • Uniform pagination contract: read/cap tools return one self-describing envelope, built through the shared Modules.Pagination class (contract/pagination.gd) — returned (this page’s size), a canonical total_<unit> (total_classes, total_nodes, total_cells, total_assets, total_dependencies, total_lines, total_matches, total_bytes), and has_more always 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 the errorContract.ts crash-context fallback, via shared PAGE_FIELD consts (ADR 0020 · code-standards B5).
  • Event-batch vocabulary: input_simulate (input.simulate, runtime/mcp_runtime_server.gd) exposes an enumerated event_type set as part of its public param schema — key | mouse_button | mouse_motion | action | click | click_node | send_text. send_text is a convenience synthesizer (a string → N InputEventKey(unicode) events delivered via Viewport.push_input + an optional submit Enter) returning focus_target / focus_source / text_changed / text_after (secret-redacted) / chars_sent / hint. The server mirrors the vocabulary in the event_type zod enum (twice — single-object and array-item variants); event_data forwards untyped (z.record), so the per-field shape is the toolkit’s contract.
  • Screenshot foreground levers: editor_screenshot (editor.screenshot) reads an optional force_foreground_editor: bool (default false) and runtime_screenshot (runtime.screenshot, runtime/mcp_runtime_server.gd) an optional force_foreground_game: bool (default false). When set, the tool un-minimizes + raises/focuses the target window before capturing (then discloses via remediation, C3) instead of returning the EDITOR_VIEWPORT_UNAVAILABLE / RUNTIME_WINDOW_MINIMIZED signal (C4); default-off so an interactive user’s window is never yanked. The server declares both as z.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") and save_path: string (the .png destination for disk/both; auto-named when omitted). editor.screenshot’s save_path allows res:// or user://screenshots/; runtime.screenshot’s allows user://screenshots/ only (pathParams prefixes ["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 optional image_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 applied image_detail + a returned “WxH” (C3). editor.screenshot’s former size {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 stays node_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 on GODOT_MCP_READ_ONLY. It consumes its own read_only flag only for (1) concurrency routing (is_read_only()needs_serialization read-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.delete carry destructiveHint:true.
  • Known annotation caveats: game.start / game.stop are marked read_only (masked in practice by exclusive execution), and debugger.get_log is read-only yet can stop_playing_scene. These are minor accuracy edges, not contract guarantees.
  • Server consumer: (a) layer = filter at registrationisExcludedByReadOnly(readOnly, annotations) (security/profiles.ts) skips the tool so it is never registered → absent from tools/list (reg sites across registration/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 iff readOnlyHint:true ∧ ¬destructiveHint; an un-annotated tool → excluded (safe), so an un-annotated mutating tool is hidden. The server adds one server-local rule: readOnlyHint ∧ destructiveHint is a contradiction → treat-as-mutating + stderr warn. (c) test: groups.test.ts read-only-filter contract + profiles unit coverage.

C10 — Environment variables & CLI flags · public

  • Toolkit-consumed: GODOT_MCP_TOKEN_PATH (overrides get_token_path(), security/auth.gd); the listen-port vars, all resolved by the export-clean transport/port_config.gd and consumed by transport/mcp_server.gd (editor) + runtime/mcp_runtime_server.gd (runtime): GODOT_MCP_EDITOR_PORT / GODOT_MCP_RUNTIME_PORT pin an exact listen port (bind-exact-or-fail — never scans elsewhere, bounded same-port grace then a loud error), and GODOT_MCP_EDITOR_PORT_MIN/_MAX / GODOT_MCP_RUNTIME_PORT_MIN/_MAX relocate 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_UNAVAILABLE is reserved/unused. (The former GODOT_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 in index.ts, threaded into createBridge; 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 (?? cwd for registry lookup + LSP dispatch — index.ts, groups/groupToolHandlers.ts); GODOT_MCP_CONFIG_VERSION (set by the template, a migration marker). The _MIN/_MAX band vars are listen-side only — the server never reads them (registry discovery covers the scanned case). GODOT_MCP_TOKEN_PATH is also the server-side operator override (C18, transport/tokenPath.ts). The dead GODOT_MCP_PROFILE / --lite deprecation 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 --help and --tools-count.
  • Desync cross-check: because a pin can reach one side only (e.g. .mcp.json sets 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.template sets GODOT_MCP_CONFIG_VERSION but not GODOT_MCP_TOKEN_PATH; with it unset the toolkit globalizes user://…/mcp_token to an absolute path before publishing token_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) — reads mcpServers.<key>.env (string-coerced), keying on a godot-mcp-toolkit / godot-mcp substring. write (ui/mcp_json_sync.gd, invoked via the ui/mcp_json_write_flow.gd confirm helper from the dock/wizard/tool-menu) — builds the mcpServers.<key> entry per-OS (macOS: npx; Windows: cmd /c npx; Linux: npx) from the template’s env skeleton, preserving an existing file’s GODOT_MCP_* env keys; regenerated on demand.
  • Server consumer: the MCP client (Claude Code) reads .mcp.json to launch the server with the right command + env. The server itself re-reads it on config-reload: readMcpJsonEnv (startup/configReload.ts) finds the godot-mcp* server entry (mirroring the toolkit’s _find_server_key) and applyEnvUpdate reconciles only GODOT_MCP_* keys (deletes removed, sets new — never touches unrelated env), driven by the debounced handleConfigReload (startup/reconcile.ts). The config_version migration is a client/server matter, outside this cell’s wire shape.

C12 — Tool group names · semi-public

  • Toolkit shape: each command may declare a group via the options builder (contract/mcp_toolkit_command_options.gd); the registry publishes that per-command group (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 in groups/builtinGroups.ts, per-group defs in groups/defs/*.ts; each {name, description, tools: string[], keywords: string[]}) + the derived GROUP_TOOL_NAMES / RUNTIME_TOOLS / LSP_TOOLS index sets (groups/groupCatalogue.ts). discover_tools keyword-searches/scores/activates these group names (groups/groupMatch.ts scoring + groups/groupActivation.ts); group membership holds tool names resolved against ALL_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 an MCPToolkit*-prefixed .cs (C#) with one virtual register(registry, server). The registry facade (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 passes ctx when the command is is_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.
  • Server consumer: the server projects each discovered extension command into an MCP tool. discoverExtensions() (extensions/extensionDiscovery.ts) reads the toolkit’s extensions.list commands → builds {readOnlyHint/destructiveHint/idempotentHint ?? false} annotations, applies the C9 read-only skip, and registers via registerToolWrapped (extensions/extensionRegistrar.ts); grouped extension commands defer to a dynamic extension-group (groups/extensionGroups.ts addExtensionGroup). 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 that hasToolRef misses pre-registration) OR hasToolRef, checked at all 4 ext-registration sites (helper registration/extensionCollision.ts). A colliding extension name is skipped + warned; the incumbent (built-in / first-registered) always wins, never crashes. The MCP SDK’s registerTool throws 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.gd is a thin facade; the work lives in extensions/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.changed broadcast (on hot-reload add/remove/modify): {"notification":"extensions.changed","params":{"commands":[…],"removed":[…method names]}}, sent to all authed peers via server.broadcast_notification (extension_watcher.gdtransport/mcp_server.gd).
  • Server consumer: the server declares capabilities.tools.listChanged: true (index.ts) and emits exactly one tools/list_changed per change via batchToolRegistration (registration/toolRegistry.ts, the monkey-patch-then-fire-once primitive). On the toolkit’s extensions.changed broadcast, handleExtensionsChanged() (extensions/extensionChanges.ts) reconciles the dynamic extension surface — re-adding via the idempotent addExtensionGroup (groups/extensionGroups.ts, dedup-by-method) + re-registering ungrouped cmds — inside one batch. extensions_refresh is the always-on meta-tool (extensions/extensionRegistrar.ts) the LLM calls to force a manual re-sync.
  • Platform caveat: tools/list_changed is 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.ts EAGER_TOOLS) compensate.
  • Extension read pattern: extension-provided read commands follow the same uniform pagination envelope as built-ins (returned + canonical total_<unit> + has_more, plus a resume field or cursor-less nav where applicable; C8). Like every extension response it surfaces transparently (REFLECT) — the server forwards message.result verbatim (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 the editor.set_lsp_status command (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 inside ensureLsp (lsp/lspSession.ts) — both de-duped by state:host:port and sent through sendLspStatuseditor.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 unique entries/<hash>.json (registry/registry_client.gd); a locked rebuild (_rebuild_projects_jsonregistry/store/registry_projection.gd merge_by_path) fans in to projects.json for 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.gd build_entry). Atomic writes (entry tmp→rename; registry two-phase tmp→.bak→target). GC = port-conflict pruning only (PID-GC removed — Windows is_process_running false-positives).
  • Server consumer: readRegistry() reads exactly registryPath() = …/godot-mcp-toolkit/projects.json (registry.ts) — never the per-instance entries/ dir, so any runtime-side entry split is purely toolkit-internal. The server’s RegistryEntry type (registry.ts) matches the toolkit build_entry writer field-for-field, with godot_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 (discoverLspEndpoint returns null on lsp_port==null); ping-pong port transitions are absorbed tear-down-before-connect by diffAndNotify. The path-normalization parity (normalizePath ↔ toolkit canonical()) 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.gd current_hash).
  • Server consumer: the bridge does NOT recompute the sha256 hash. by_path is keyed by _key = the canonical project PATH (registry/store/registry_projection.gd merge_by_path; the frozen contract in paths/project_key.gd — the normalized path is the _key the TypeScript bridge reads, while the 12-char hash names the per-instance entry file). The server forms the same key by normalizePath(projectPath) (registry.ts, used by lookupProject) — 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); the token_path published in the registry entry is the GLOBALIZED ABSOLUTE pathMCPAuth.get_published_token_path() = ProjectSettings.globalize_path(get_token_path()) (security/auth.gd), used at the editor publish sites. In-engine readers keep the user:// form (get_token_path(), unchanged); the runtime autoload’s empty-token_path writer is untouched.
  • Server consumer: the bridge looks up entry.token_path via lookupProject and structurally validates it before opening — it does NOT recompute sha256(path) or re-derive the path. readToken(projectPath) (transport/tokenPath.ts, consumed by transport/channel.ts performAuth on 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_PATH is an operator override read directly (absolute + existing-file; bypasses the suffix check). Every failure throws a distinct, actionable AUTH_FAILED (no silent stale-open). Because globalize_path reads use_custom_user_dir live, a relocated user:// is honored. See ADR 0009 / 0011.

C19 — LSP endpoint publishing · internal

  • Toolkit shape: the editor resolves the endpoint via resolve_lsp_endpoint (EditorSettings network/language_server/remote_host / remote_port; default 127.0.0.1:6005) — the logic lives in paths/lsp_publisher.gd, delegated from transport/mcp_server.gd — and passes the resolved lsp_host/lsp_port into register(). registry/registry_client.gd stays export-clean (it receives params, never touches EditorInterface).
  • Server consumer: the LSP client reads entry.lsp_host / lsp_port from projects.json via discoverLspEndpoint(projectPath) (registry.ts) — the middle tier of the 3-tier resolver (GODOT_MCP_LSP_PORT env → 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 by started_at, else {conflict}LSP_PORT_CONFLICT) with PID-liveness via isPidAliveprocess.kill(pid,0) (reliable on Windows, unlike the toolkit’s OS.is_process_running). Then it verifies the live endpoint and reports the verdict → C15. The null-lsp_port runtime-only case returns null (no LSP), per C16.

C20 — Runtime discovery · internal

  • Toolkit shape: a live game writes runtime_port/runtime_pid into its registry entry on bind (registry/registry_client.gd set_runtime, read-merge); both are cleared on stop.
  • Server consumer: the bridge reads runtime_port via discoverRuntime / getCachedRuntimePort (registry.ts) to reach the running game (Mode B, callRuntime) vs the edited scene (Mode A, call); diffAndNotify (registry.ts) watches only runtime_port transitions (null→port / port→null / port→port) to re-target the runtime channel — editor port / godot_version / lsp_port changes do not flow through it (by design — the watcher exists for playtest discovery). Liveness asymmetry: the runtime path does not gate on isPidAlive(runtime_pid) (the LSP path does), so it can return a stale runtime_port from a crashed playtest; the bridge’s connect-failure absorbs it.
  • Constraint: a config/name rename currently re-publishes via register, which nulls runtime_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.gd wrap, across script / scene_tree / user-file / resource / project_settings / console / animation / audiobus / game_log read paths) and the server forwards it verbatimcallAndWrap (registration/toolDispatch.ts) → stableStringify (shared/schemaMin.ts) → message.result (transport/channel.ts), no double-wrap, no unwrap/parse. The server has its own untrustedWrap (security/untrusted.ts, an exact TS mirror — same envelope + scrub) used in exactly one place: tools/lsp.ts wraps lsp_hover text, 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 (audit audit/enabled default-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) and mcp_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/*. The ws_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; the save.read cap surfaced via response fields). The contract that the toolkit denies LLM writes to mcp_toolkit/* / mcp/* / editor/* is toolkit-enforced. Listed for completeness; no server consumer applies.

This site uses Just the Docs, a documentation theme for Jekyll.