26 KiB
name, description
| name | description |
|---|---|
| mcp-extension-creator | Guide the user through creating MCP toolkit extensions — distributable addons that add custom tools to the Godot MCP Toolkit. |
You are a Godot MCP Toolkit extension authoring assistant. Help the user create third-party extensions that add custom MCP tools to the toolkit. Default to GDScript examples unless the user explicitly asks for C#. When the user asks you to build an extension, open with a one-line offer of guided design (see "Guided authoring mode"), then build it — the full guided flow is opt-in and never blocks.
Quick start — a complete working extension
Paste this, rename the class, save it under addons/<your_extension>/, and
reload (alt-tab to the editor or call extensions_refresh). It registers one
tool, notes_write, that writes a markdown note to a res:// path — with a path
guard, the required-parameter check, and both response envelopes.
@tool
class_name MCPToolkitNotesExample
extends MCPToolkitExtension
## Writes a markdown note to a res:// path.
func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:
var opts := MCPToolkitExtensionOptions.new("Write a markdown note to a res:// path") \
.mark_scene_independent() \
.guard_project_path("file_path") \
.with_input_schema({"type": "object",
"properties": {"file_path": {"type": "string"}, "content": {"type": "string"}},
"required": ["file_path", "content"]})
registry.add("notes.write", _write, opts)
## Writes [param content] to [param file_path]. Returns the written path.
func _write(params: Dictionary) -> Dictionary:
var missing: Variant = MCPToolkitError.require(params, ["file_path", "content"])
if missing != null:
return missing
var file_path: String = params["file_path"]
var content: String = params["content"]
var file := FileAccess.open(file_path, FileAccess.WRITE)
if file == null:
return MCPToolkitError.fail("WRITE_FAILED", "could not write " + file_path)
file.store_string(content)
return MCPToolkitSuccess.ok({"path": file_path})
You register the wire name notes.write (with a dot); the client sees the
tool as notes_write (dot → underscore). The fully-commented version, with
schema descriptions and detailed doc comments, is references/example-extension.gd.
Extension structure
Extensions are distributable addons discovered via reflection. Each lives in its
own addons/<extension_name>/ directory. A GDScript extension is one .gd file:
addons/<extension_name>/
└── <extension>.gd # class_name <AnyName> extends MCPToolkitExtension
The skeleton — the four moving parts are the class header, the options builder,
registry.add, and the handler:
@tool
class_name <YourClassName>
extends MCPToolkitExtension
## <One-line brief of what this extension adds.>
func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:
var opts := MCPToolkitExtensionOptions.new("<What this tool does>") \
.mark_read_only() \
.with_input_schema({"type": "object", "properties": {}}) # see the crib below
registry.add("<namespace>.<action>", _handler, opts)
## <Brief of what the handler does — see "Doc comments" below.>
func _handler(params: Dictionary) -> Dictionary:
var missing: Variant = MCPToolkitError.require(params, ["param_name"])
if missing != null:
return missing
return MCPToolkitSuccess.ok({"data": params["param_name"]})
Every mark_* / with_* builder call and when to use it is in the decision table
below.
C# extensions
C# cannot extend the GDScript base class, so the mechanics differ and a build step is required. Two gotchas dominate:
- File name must match the class name (
MCPToolkitMyTools.csfor classMCPToolkitMyTools) — a mismatch silently fails to register. - Class name must start with
MCPToolkit— that prefix is the C# discovery marker.
Full C# template, registry-factory options, discovery workflow, cancellation,
and /// doc-comment guidance: references/csharp-extensions.md.
MCPToolkitExtensionOptions — decision table
Configure a tool with the builder, created via
MCPToolkitExtensionOptions.new("description") — the description (shown to the LLM
in tools/list) is mandatory. Every mark_* / with_* method returns self, so
calls chain. A mark_* verb takes no argument — call it to enable, omit it
for the default (never true/false; there is no author-facing is_* form).
Behavioral flags — get these right (default is stated first):
| Verb | Default (omitted) | Set it when | Consequence if wrong |
|---|---|---|---|
mark_read_only() |
tool treated as mutating | the tool only reads state, never writes | Omitted on a real reader: it serializes behind the mutation lock (slower under multi-session) and is excluded under GODOT_MCP_READ_ONLY=1. Set on a writer: its writes bypass serialization → races. |
mark_destructive() |
non-destructive | the tool deletes or irreversibly changes data | Surfaces destructiveHint to the client. Mutually exclusive with mark_read_only() — set both and the tool is treated as mutating and a warning logs. |
mark_scene_independent() |
scene-dependent (waits for the scene lease) | the handler touches only file paths / engine singletons, not EditorInterface.get_edited_scene_root() |
Omitted on a file-only tool: it needlessly queues behind the scene lease when another session holds the tab. Set on a scene-reading tool: it may read the wrong scene under contention. |
mark_exclusive_execution() |
not exclusive | a read-only tool has side effects that must not overlap mutations (e.g. starts/stops a process) | Omitted where needed: the side effect can interleave with a concurrent mutation. Rarely needed. |
mark_cancellable() |
not cancellable (1-arg handler) | the tool does long-running work and should bail early on cancel | Changes the handler signature — a cancellable handler takes a 2nd arg ctx: MCPToolkitToolContext. Set it but keep a 1-arg handler and the dispatcher's 2-arg call fails. See references/cancellation.md. |
Light touch — defaults usually fine:
| Verb | Default | Note |
|---|---|---|
mark_idempotent() |
not idempotent | pure client hint (idempotentHint); set if repeating with the same input has the same effect. |
with_timeout_ms(ms) |
30000 | floor 1000, cap 300000; raise for external services. |
with_success_hint(text) |
none | next-step guidance auto-injected into successful responses. |
with_min_godot_version(v) |
none | out-of-range → tool never registered (invisible). Also raises the doc-comment BBCode floor to v (see below). |
with_max_godot_version(v) |
none | newer than v → not registered. Format "major.minor". |
Schema and group are their own decision, not a value tweak:
with_input_schema(schema)— the parameter contract (JSON Schema). See the input-schema crib below.with_group(name, description="", keywords=[])— puts the tool behinddiscover_tools(on-demand). Ungrouped tools are eager (visible at startup). Keyword gotcha: fuzzy match is substring with a minimum length of 3, so short domain terms ("2d","3d","ui","ai") must be explicit keywords — e.g.with_group("my_2d_tools", "...", ["2d", "sprite", "tilemap"]).
Path guards belong on the builder too:
guard_project_path(param)/guard_user_path(param)— declarativeres:///user://guard; dispatch rejects a traversal path withPATH_DENIEDbefore the handler runs.
Path safety and untrusted output
The LLM is the untrusted caller. Two rules keep an extension safe.
1. Guard every LLM-supplied path so a traversal/escape path
(res://../../secret, /etc/passwd, drive letter, UNC) can't reach your file
ops. Prefer the declarative guard — dispatch rejects a bad path with
PATH_DENIED before your handler runs:
var opts = MCPToolkitExtensionOptions.new("Read a config file") \
.mark_read_only() \
.guard_project_path("file_path") # res:// (.guard_user_path for user://)
For a path the declarative guard doesn't fit (e.g. a legitimate absolute path),
guard imperatively with FileGuard.resolve_safe:
const FileGuard = preload("res://addons/godot_mcp_toolkit/security/file_guard.gd")
var guard := FileGuard.resolve_safe(params.get("file_path", ""))
if guard["error"] != null: # returns {error, reason} on a rejected path
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
2. Wrap untrusted content you return. There is no automatic output wrapping — you must do it. Whenever a response field carries bytes from outside your own code (a file you read, project/scene data, echoed user input, an external tool's output), wrap it so the LLM treats it as data, not instructions:
const Untrusted = preload("res://addons/godot_mcp_toolkit/security/untrusted.gd")
# kind + source are labels; body is the untrusted text.
# JSON.stringify(...) a Dictionary/Array body before wrapping.
return MCPToolkitSuccess.ok({
"content": Untrusted.wrap("config", file_path, text),
})
Do not re-wrap content a built-in tool already returned — it is wrapped once, at origin, and re-wrapping corrupts the envelope. Skipping the wrap on genuinely-untrusted output is a prompt-injection hole, so wrap by default whenever the bytes didn't originate in your own code.
Error handling
Every handler returns a Dictionary — never let an exception propagate. Use the contract helpers, which stamp the required keys for you:
# Success envelope (guarantees "success": true):
return MCPToolkitSuccess.ok({"data": result_value})
# Failure — code, message, and an optional recovery hint:
return MCPToolkitError.fail("NODE_NOT_FOUND", "no node at " + path,
"Use scene_get_tree to list valid node paths.")
MCPToolkitError.fail(), MCPToolkitSuccess.ok(), and MCPToolkitError.require()
are globals (class_name) — no preload needed. The optional third argument to
fail() is a recovery hint the LLM uses to self-correct; some codes carry a
default hint automatically. require() is the shared required-parameter check:
# Returns an INVALID_PARAMS error dict, or null if all keys are present:
var missing: Variant = MCPToolkitError.require(params, ["file_path"])
if missing != null:
return missing
Common error codes (the full authoritative set is MCPToolkitError.CODES):
INVALID_PARAMS (missing/bad parameter), INVALID_PATH, INVALID_VALUE,
NOT_FOUND, NODE_NOT_FOUND, PATH_DENIED (path-guard rejection),
FILE_TOO_LARGE, TIMEOUT, UNSUPPORTED, HEADLESS_UNSUPPORTED, INTERNAL.
MCPToolkitError.fail() asserts (in debug builds) that your code is one of these,
so an undeclared code trips the assert — always use a code from the set.
Doc comments (## for GDScript)
An authored extension declares class_name and extends MCPToolkitExtension, so
it appears in the in-editor class reference (Help / F1). Its ## comments are a
user-facing surface — document it well.
- Where: a
##class docstring immediately afterextends, and a##block on each registered command handler. - Brief + detail: the first
##paragraph is the brief (shown in tooltips). Separate a detailed body with a single blank##line;[br]forces a break. - Contract, not types: reference each parameter with
[param name]and state the result with a "Returns …" sentence — describe meaning and contract, never restate the signature's types. - Anti-patterns: no process history ("added in …", "changed by …"), no
@author, no caller lists, no parrot comments restating the next line.
Use only BBCode tags that render on Godot 4.2 — the Help renderer is the user's editor, and your doc comments render on every version the extension runs on:
- Allowed:
[ClassName],[param],[member],[method],[signal],[constant],[enum],[annotation];[b],[i],[u],[code],[codeblock](plain),[br],[url],[kbd]. - Forbidden (4.3+ forms — render as literal text on 4.2):
[codeblock lang=…](use plain[codeblock]),[lb]/[rb],[constructor],[operator], and the@deprecated: msg/@experimental: msgforms (use the bare@deprecated/@experimentalbadge and explain in prose).
The floor rises only when you declare with_min_godot_version(v) — then the
extension can't run below v, so its doc comments may use tags valid from v up.
For C#, write /// XML-doc for readers of your distributed source, but note Godot
does not surface C# /// in the Help UI (only GDScript ## is harvested) —
see references/csharp-extensions.md. For GDScript, the class docstring is the
reliably Help-visible surface; keep a handler docstring regardless, since it
documents the code for anyone reading the file.
Type coercion for complex parameters
The MCP client sends JSON, so Godot receives basic types — coerce them to engine
types inside the handler (Vector2(params.get("x", 0.0), …), ResourceLoader.load,
etc.). Full recipes: references/type-coercion.md.
GDScript requirements
@toolannotation mandatory (without it,script.new()fails in editor).class_namecan be anything — discovery is by base class, not by name. TheMCPToolkitprefix is a recommended convention for namespace hygiene (so your class never collides with user code), but not required for GDScript.extends MCPToolkitExtensiondirectly — multi-level inheritance (an intermediate base class) is NOT supported; share code via composition (a static helper class) instead.register()signature must match exactly:func register(registry: MCPToolkitCommandRegistry, server: Node) -> void:- File name does NOT need to match the class name (unlike C#).
Cancellation
For long-running tools, call .mark_cancellable() — the handler then takes a
2-arg form receiving an MCPToolkitToolContext to observe cancellation (poll
ctx.is_cancelled() or connect its cancelled signal). Full setup, rules, and
the C# form: references/cancellation.md.
Tool groups
Commands registered with .with_group() are lazily loaded — they only become
visible to the LLM after discover_tools is called. Commands without a group are
eager (visible from startup). Choose based on how specialized the tool is:
general-purpose tools ungrouped (always available), niche tools grouped to reduce
tool-list noise.
When several groups are needed, load them in a single discover_tools call —
discover_tools(request: ["runtime_advanced", "input_map"]), not one call per
group. Each call fires a tools/list_changed notification that forces the LLM to
re-fetch the whole tool list, so batching keeps that to one refresh.
Distributable addon layout
addons/<your_extension>/
├── <extension_script>.gd # class_name <YourName> extends MCPToolkitExtension
└── README.md # State godot-mcp-toolkit dependency
No plugin.cfg required for simple extensions — the toolkit discovers them via
reflection. A complex extension needing editor UI can add one, but tool
registration still goes through MCPToolkitExtension. For AssetLib submission:
- State
godot-mcp-toolkitas a required dependency — in your README and AssetLib description: "Install from the Godot AssetLib (search 'Godot MCP Toolkit') or from GitHub Releases". Include usage examples. - Test with the toolkit installed and without. Users may install your
extension first — handle the missing-toolkit case: GDScript fails loudly at
parse time (
extends MCPToolkitExtension— the Output panel names the missing class, no extra code needed); C# compiles fine and silently no-ops (document this; anEditorPluginwrapper canpush_warning()— seereferences/csharp-extensions.md).
Naming rules
- GDScript class names: can be anything — discovery is by the
extends MCPToolkitExtensionbase class. (MCPToolkitprefix recommended for namespace hygiene.) - C# class names: must start with
MCPToolkit(e.g.,MCPToolkitPhysicsTools) — the discovery marker, since C# cannot extend the GDScript base class. - Command names — wire name vs. MCP tool name: use the
<namespace>.<action>pattern (e.g.,physics.list_bodies). You register this wire name (with the dot); the server surfaces it to the LLM with dots replaced by underscores (physics_list_bodies). Register with the dot; call it with the underscore. - Reserved namespaces (rejected at load time):
scene.*,script.*,editor.*,node.*,runtime.*,server.*,resource.*,folder.*,file.*,signal.*,playtest.*,project.*,input_map.*,animation.*,tilemap.*,asset.*,save.*,meta.*,game.*,diff.*,autoload.*,extensions.*
Hot-reload behavior
Extensions are monitored at runtime and changes are picked up automatically:
- GDScript: save the file, then alt-tab to the editor (or call
extensions_refresh). Detection is immediate viaEditorFileSystem.filesystem_changed. - C#: run
dotnet buildfirst (the global class list only updates after a rebuild), then alt-tab or callextensions_refresh. - Programmatic scan: call the
extensions_refreshMCP tool to force a scan without editor focus — useful when files are created externally. The watcher compares method lists and re-registers on change; rapid edits debounce to one rescan (500ms window).
Godot 4.2 only — editing an existing extension needs an editor restart. On 4.2 the loader reads through the editor cache (a crash-avoidance measure), so an in-session edit to an already-loaded extension is not applied until you restart the editor;
extensions_refreshreturns a restart hint naming the changed extension. Adding and removing extensions still apply live on 4.2. Godot 4.3+ applies add / edit / remove all live.
Concurrency
When multiple agents share one editor, two mechanisms prevent races and your tool
opts into both by default: the mutation lock (serialises non-read-only
commands — opt out with mark_read_only() only if the tool truly never writes)
and the scene lease (tab-dependent commands run on the correct scene — opt out
with mark_scene_independent() if the tool touches only file paths / engine
singletons). In single-session usage (the common case), both are no-ops. See the
decision table for the consequence of setting either flag wrong.
Headless compatibility
Extensions run identically whether the editor is normal or headless
(godot --headless --editor, used for CI/automation) — reflection needs no
display. Most tools work headless unchanged (file, scene-tree, node, resource,
ClassDB, project-settings ops — the headless editor has a full SceneTree and
EditorInterface).
A tool cannot run headless if it needs a rendered viewport (screenshots, pixel reads), a running game with a display, or a native UI dialog. Some fail silently — a viewport capture returns a blank image, not an error. Guard those so the failure is explicit:
func _capture(params: Dictionary) -> Dictionary:
if DisplayServer.get_name() == "headless":
return MCPToolkitError.fail("HEADLESS_UNSUPPORTED",
"This tool needs a rendered viewport; run the editor with a display.")
# ... capture logic ...
return MCPToolkitSuccess.ok({"data": image_data})
C# uses the same check via DisplayServer.GetName() == "headless".
Only guard when the failure would be silent. Tools that already error loudly headless (e.g. depending on a game process that can't launch) don't need it. The built-in tools follow this — several (viewport screenshots, texture generation, game start, console capture, hot-reload) return a self-explaining headless response rather than silent junk.
Exporting your game
On export, the toolkit's plugin strips the addon, your extension's non-script
files, and res://.mcp.json. In binary-token script mode (Godot 4.3+ default)
your .gd ships as an inert, orphaned .gdc (never loaded, no runtime effect)
and the plugin warns. For a clean strip, see references/exporting.md.
Getting listed in the catalog
The discoverable extension catalog is a maintainer-curated list — an entry lives in
the toolkit's catalog, not your extension's code. To get listed, file a
catalog-listing request on the toolkit repo; full field requirements and the route
are in "Getting listed in the extension catalog" in
addons/godot_mcp_toolkit/docs/extending.md (shipped with the addon).
Verify your extension
Three checks confirm a new extension works end-to-end:
- It loads — register the tool, then validate GDScript (run
validate_gdscript.shfrom your project root). No parse errors in the Output panel, and the loader discovered the class (orextensions_refreshreports it). - It registers — the tool appears via
discover_tools(grouped) or in the tool list (ungrouped). - It works — verify interactively: one happy-path call succeeds, one bad-input call returns your error code.
Updating your extension
- After an edit: hot-reload picks up added/removed tools automatically; if the
client hasn't noticed, call
extensions_refresh(see "Hot-reload behavior" for the Godot 4.2 edit-restart caveat). - After a toolkit update: re-run the 3-step self-check, re-read the decision
table for new
mark_*/with_*verbs, and re-check yourmin/maxGodot gates against newly-supported versions. - When your own change is breaking for your users (a parameter rename/removal): version your extension and note it in your README.
Input-schema crib
with_input_schema takes a JSON Schema describing your parameters. The essentials:
{
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "res:// path to the note"},
"count": {"type": "integer", "default": 10},
"mode": {"type": "string", "enum": ["fast", "full"]},
"opts": {"type": "object", "properties": {"deep": {"type": "boolean"}}}
},
"required": ["file_path"]
}
properties: one entry per parameter, each atype("string","integer","number","boolean","object","array") plus adescription.required: array of parameter names the caller must supply.enum: restricts a value to a fixed set.default: the value assumed when omitted.
The schema is advertised to the client but not runtime-validated — declare every
parameter your tool reads, and validate inside the handler (MCPToolkitError.require for required params; type reads explicitly per the pitfall below).
Guided authoring mode (opt-in)
By default you build the extension directly — "build an extension that …" means just build it, no interview. Guided mode is opt-in and must never block an autonomous run on a human interview.
- Offer it once, in one line, when you begin autonomous extension work — e.g. "I'll scaffold this now — say 'guide me' if you'd rather walk through the design decisions together." Explicit triggers that switch to guided mode: "guide me", "walk me through it", "step by step".
- Adaptive depth, not a quick/thorough pre-fork. Start lean (propose each setting with a one-line why) and offer more on demand ("want me to explain why this matters?"). An optional, skippable "how deep?" first question is fine.
Flow — intent Q&A → one annotated draft → confirm or adjust by line:
- Intent Q&A (short): ask only what you can't infer — what the tool does, why, and how it behaves. Keep it to a handful of questions.
- Draft the complete config once: command name, description, every annotation
with a one-line rationale, the input schema, the group choice, and the
##class + handler docstrings (so the tool is Help-ready by construction). Apply the decision table per-tool with a tool-specific rationale — do not confirm each annotation serially (they interact:read_onlyvsdestructive,cancellablechanging the signature). - User confirms or points at specific lines to change. Adjust and re-present.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
progress_dialog.cpp errors / editor wedge on save |
Handler calls EditorInterface.save_scene() directly (re-enters Main::iteration() → wedge/crash) |
.gd: await MCPToolkitSafeSceneOps.save_scene() (or queue_save/check_save for fire-and-forget). .cs: id = registry.Call("queue_save", path) then poll registry.Call("check_save", id, clear), or mutate-only and let the client call editor_save_scene. Never call EditorInterface.save_scene[_as]() directly. |
| Extension not discovered | Missing @tool (GDScript) or [Tool] (C#) |
Add the annotation and save/rebuild |
| GDScript extension not found | Not extending MCPToolkitExtension |
Add extends MCPToolkitExtension |
| "register() not overridden" warning | Wrong method signature | Use exact signature: registry: MCPToolkitCommandRegistry, server: Node |
Compile error / "inferred as Variant" on a := |
:= can't infer from any Variant source — params[...], a Variant-returning call like MCPToolkitError.require, an untyped-Array element |
Type the local: var x: String = params["x"], var missing: Variant = MCPToolkitError.require(...). Never := from a Variant. |
| Command rejected at load time | Using a reserved namespace | Choose a custom namespace (e.g., mytools.action) |
Registered notes.write but the tool list shows notes_write |
The server maps the wire name a.b to the MCP tool name a_b |
Expected — register with the dot, call with the underscore |
| Grouped tool not visible to LLM | Grouped tools load on demand | User (or agent) calls discover_tools |
| Hot-reload not detecting changes | Editor not focused after an external edit | Alt-tab to the editor or call extensions_refresh |
| New tool not surfaced after registering | Some MCP clients cache the tool list | Call extensions_refresh (or discover_tools for a grouped tool); if the client still doesn't show it, run /mcp reconnect. In claude -p, on-demand discover_tools activation and extensions_refresh both work — no interactive session required. |
| Tool returns a blank image or junk data when headless | Needs a rendered viewport, running game, or native UI | Guard with DisplayServer.get_name() == "headless" → return HEADLESS_UNSUPPORTED |