Skip to content

MCP Server

Surface Labs integrates with your favorite LLM services by shipping with a small Model Context Protocol (MCP) server. Standard assistants and agents can use tools and API built into Surface Labs to drive many aspects of material authoring.

Examples:

  • “Place a slope blur after the perlin noise in the dirt heightmap and expose its intensity to the global variables.”
  • “Using these references - rework the wood grain pattern to something more realistic.”
  • “Using the Headless CI, re-export the textures for Unreal Engine instead of Unity and upscale them to 4k.”

The server utilizes the same type validation as the graph editor - so be confident that the AI cannot create loops, create invalid connections, or otherwise leave your project in a state where the save cannot be loaded.

There is one implementation but two ways to run it. They are for different jobs.

Live (in-app)Headless (standalone)
HostSurface Labs AppCLI process via terminal
TransportLoopback HTTPstdio, or loopback HTTP with --http
ProjectPresently open.surfacelabs files from disk
Live ViewRealtime on the canvasNo GUI
RenderingAlways in appRequires surface_gpu native core
Good forLive authoring and assistanceBatch runs, CI, agent-only work

When working in live mode, the server allows access directly the currently open project and no others. Changes to the graph render and evaluate using the standard flow, and nothing is written to disk until project save.

Undo reaches an assistant’s edits, but not one at a time. The canvas records an undo point before each edit you make; an assistant’s edits add none of their own. Undo therefore rewinds to your last canvas action and takes every assistant edit since with it, and does nothing at all if you have not touched the canvas since the project opened.

Two things are deliberately the artist’s decision. The server will refuse them with an explanation while in live mode.

Creating a project. Project creation is a dedicated flow within the project hub. The server will not create new projects for you - nor will it remove them. For this functionality, it’s recommended to use the command line server instead.

Closing the project. The assistant cannot close projects either - it is told to use project save instead, so work is not lost by accident. project open is allowed in live mode, however it switches the whole editor to that file. The outgoing project is autosaved to its .autosave companion, and the server waits for the new one to finish loading.

  1. Open Settings and select the MCP Server tab.
  2. Select Start. The status line changes to Listening on http://127.0.0.1:4319/mcp.
  3. Connect a client using one of the options below.
SettingWhat it doesDefault
ServerStart and Stop. While running, the count of handled requests sits beside it.Stopped
Start with appStarts the server automatically each time Surface Labs launches.Off
Read onlyThe assistant can look at the project but not change it. Toggling it restarts the server.Off
PortThe loopback port. Change it if another program already uses it; the server restarts on the new one and the config snippets update.4319
TokenThe secret a client must present. Reveal shows it, Copy puts it on the clipboard, New issues a fresh one and locks out anything holding the old one.Generated on first run, 32 random characters

If the port is unavailable the page says so and names the likely cause, which is usually another Surface Labs window or another program already on that port.

Below the connection snippets the page lists what an assistant can currently do, names the project it is editing, and keeps a live Activity feed: the most recent requests, newest first, each with the method, the tool it called and how long it took. Errors are marked. It is a status readout rather than a log file, so it holds the last thirty and resets when the server restarts.

  • Endpoint: http://127.0.0.1:4319/mcp, or your chosen port.
  • Transport: streamable HTTP, one JSON-RPC message or a batch array per POST. Notifications get 202 with no body. GET /mcp opens the specification’s optional server-to-client SSE stream, which is how the server’s log notifications reach a client; a client that never opens it simply gets none. DELETE /mcp ends the session named in the Mcp-Session-Id header, and no other.
  • GET /health is an unauthenticated liveness probe reporting the server name, transport and request count.
  • Authentication is a bearer token: Authorization: Bearer <token>. A client that can only send a flat header map may use X-Mcp-Token: <token> instead.

Each client gets its own session. One id is minted per initialize, so several clients can be connected at once and one shutting down does not disconnect the rest. They still share the one open project. Sixteen ids are remembered; the oldest is dropped past that, which at worst asks a long-abandoned client to reinitialize. A request carrying a session id the server does not know gets 404, the streamable-HTTP cue to send a fresh initialize, which is how a connected client recovers by itself after the app restarts underneath it, instead of wedging until you restart the client.

The settings page gives you three copy-ready options, each with a Copy (with token) button that fills the token in for you.

Claude Code. Run the shown command once in a terminal:

claude mcp add --transport http surface-labs http://127.0.0.1:4319/mcp --header "Authorization: Bearer <token>"

Claude Desktop. Use the one-click extension export. See Claude Desktop below.

Other MCP clients. A generic block for any client supporting streamable HTTP with custom headers:

{
"mcpServers": {
"surface-labs": {
"type": "http",
"url": "http://127.0.0.1:4319/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}

Select Stop, or quit the app. With Start with app off, which is the default, nothing is listening until you switch it on again.

Claude Desktop launches MCP servers over stdio, so it cannot talk to a loopback HTTP endpoint directly. Surface Labs solves this with a one-click extension export: a .mcpb bundle you drag into Desktop.

  1. Start the server (Settings → MCP Server → Start).
  2. Select Save Claude Desktop extension…. A native save dialog appears suggesting surface-labs.mcpb. Save it anywhere; the extension is added if you leave it off.
  3. Drag the saved file into Claude Desktop’s Settings → Extensions.
  4. Desktop shows a settings form asking for the Server token, a masked and securely stored field, and optionally the Server port, pre-filled with the port the app was using when you exported. Paste the token from the Token row in Surface Labs.
  5. Desktop connects to Surface Labs whenever the app is running with the server started.

A .mcpb is a zip containing a manifest and a small dependency-free Node script. The script bridges Desktop’s stdio connection to the app’s loopback HTTP endpoint: one JSON-RPC line in, one POST out, one line back. It needs Node 18 or newer. Claude Desktop supplies a Node runtime for node-type extensions, so if the extension fails to launch, a missing runtime is worth checking.

The bundle contains no secret. The token lives in Desktop’s own settings form, not in the file. Two consequences:

  • You can reuse the same .mcpb after pressing New to regenerate the token. Just update the token in Desktop’s extension settings.
  • Sharing the file leaks nothing.

“Surface Labs refused the token…” means the token in the extension’s settings no longer matches. Copy the current one from the Token row in Surface Labs.

“Surface Labs is not reachable on 127.0.0.1:<port>…” means the app is closed or the server is stopped. Open the app and press Start.

The server exposes nine capability tools. Seven take an action argument selecting what to do; edit_graph takes an ordered operations list and import_image takes a path. Every tool returns both human-readable content and machine-readable structured JSON, and failures carry a stable code alongside the message, so a client can branch on the code rather than the prose. The full set:

no_project_open not_found node_not_found
unknown_node_type wrong_node_type no_output_node
not_an_output_node unknown_action unknown_operation
invalid_argument invalid_operation shader_does_not_compile
gpu_unavailable io_error read_only

A typical loop the server suggests to clients:

node_types -> project open -> graph describe -> edit_graph
-> render node -> graph validate -> export run -> project save

Manages the open .surfacelabs project. One project is open at a time, and every other tool operates on it.

ActionParametersWhat it does
infoName, path, unsaved-changes flag, resolution, bit depth, node and connection counts, materials, output nodes, exposed parameters, load warnings, unsupported nodes, and gpu_available. The recommended starting point.
openpath, include_nodes?Opens a file, replacing the open project and discarding its unsaved changes. include_nodes adds full node and connection lists. In live mode this switches the whole editor.
createname?, resolution?, bit_depth?Starts an empty project in memory; no file exists until save. Refused in live mode.
savepath?Writes the archive. Defaults to the opening path; an in-memory project needs an explicit path. .surfacelabs is appended if missing.
closeCloses the project, discarding unsaved changes. Refused in live mode.
set_settingsname?, resolution?, bit_depth?Project-wide defaults every node inherits. A resolution change marks every node dirty.

resolution is either a number (square) or {"width": …, "height": …}. Powers of two from 256 to 8192 are the usual authoring sizes. bit_depth is 8, 16 or 32.

Each material is its own node graph. Only the active one is loaded, and every graph tool operates on it.

ActionParametersWhat it does
listEvery material, with the active index.
switchindexMakes another material active. The live graph is serialized back into its entry and the target’s graph loads.
addnameAppends an empty material. It is not made active, so follow with switch.
renameindex, nameRenames a material.
deleteindexRemoves an inactive material and its graph. The active material and the last remaining one cannot be deleted. Not undoable through this server.

The catalog of node types this build can place, and their contracts. Read-only, needs no open project and no GPU.

ActionParametersWhat it does
listcategory?, tag?, search?, include_deprecated?, include_subgraphs?Browse or search. Returns id, label, category, tags and port ids. search matches label, type id, category, description and tags. Deprecated types are excluded by default; subgraphs included.
describetype_idOne type in full: every parameter with kind, default, range and enum options, plus input and output port ids. Some types add authored_params and a usage note.
tagsThe built-in job tags (noise, erosion, mask, height) with per-tag counts, usable as list’s tag filter.

Read describe before adding or configuring a node. Parameter names and enum values are validated against it.

authored_params are the node-shaped values that are not ordinary parameters and so never appear in params: a Gradient Map’s color stops, a Curves node’s points, a Trim Sheet’s strips, an Image node’s path, a Text node’s text and font, a Custom Shader’s source, a Draw node’s strokes. Each entry carries the format it accepts and, for the structured ones, an example. Most are written with edit_graph set_params; a few name the tool that owns them instead, so an image goes through import_image and a shader source through custom_shader. The usage note is longer how-to guidance, present on the types that are easiest to misuse.

Category examples: Generators, Filters, Blend, Normal, Transform, Output. Type id examples: noise, blend, pbr_output, or subgraph:<id>.

Read-only views of the active material’s node graph.

ActionParametersWhat it does
describedetailed?The whole graph: every node with id, type, label, position and parameter values, plus every connection, every comment box (geometry, text, and the node ids it encloses) and every jump marker. detailed adds per-port wiring, parameter definitions and resolved resolution and bit depth, and is much larger.
nodenode_idOne node in full: values alongside their definitions, what feeds each input, what each output feeds, resolved resolution and bit depth, evaluation state.
findtype_id?, label?, tag?, unconnected_inputs_only?Locate nodes without reading the whole graph. label is a case-insensitive substring.
boundsnode_ids?The world-space box enclosing those nodes, or every node when omitted, as x, y, width, height over their real canvas footprints. Feed it to edit_graph add_comment to frame a section with padding you choose.
validateStructural check before render or export: required inputs left unconnected, unknown or deprecated types, types the headless renderer cannot evaluate, missing output node. Cheap, needs no GPU, returns an ok flag and per-issue severity.
subgraphsThe project’s reusable subgraph definitions, each placeable via edit_graph add_node with type id subgraph:<definition id>.

Applies an ordered list of edits in one call. Operations run in order and stop at the first failure. The result reports every operation that was applied plus the failure, so the state is never a mystery. In live mode the edits land on the canvas as they are made, and the canvas’s Undo rolls them back.

A node created earlier in the same call can be referenced by $N, where N is the zero-based index of the operation that created it. For a duplicate_nodes operation that is the first copy it made.

OperationFieldsWhat it does
add_nodetype_id, label?, x?, y?, params?Places a node with the definition’s ports and defaults, exactly as a canvas drop does. Returns the generated node id.
delete_nodesnode_idsRemoves nodes and every wire touching them. Destructive.
duplicate_nodesnode_ids, offset_x?, offset_y?, connect_inputs?Copies nodes with their parameter values, seeds and per-node overrides. A wire whose two ends were both copied is recreated between the copies; wires from outside are recreated onto the copies unless connect_inputs is false. Returns id_map, original to copy.
update_nodenode_id, label?, x?, y?, seed?, resolution?, resolution_mode?, bit_depth?, bit_depth_mode?Node identity and per-node overrides. Parameter values go through set_params.
connectfrom_node_id, from_port_id?, to_node_id, to_port_idWires an output into an input, replacing whatever fed it. from_port_id defaults to the source’s primary output. Inputs take one wire; outputs fan out. Rejects self-connections, wrong directions and cycles.
disconnectconnection_id, or to_node_id + to_port_idRemoves a wire.
set_paramsnode_id, paramsParameter values by name, validated and coerced against the node type. Numbers are range-checked, enums accept the index or the option label case-insensitively, booleans accept 0 and 1. Authored params take either a structured list or their raw encoded string.
set_exposednode_id, param, exposed?, label?Promotes a parameter to a project-level variable in the Global Variables panel, or demotes it.

Six more operations author the canvas itself, so a graph an assistant builds reads the way one a person laid out does:

OperationFieldsWhat it does
add_commenttext?, around?, padding?, x?, y?, width?, height?, color?, opacity?, with_jump?, jump_label?A comment box. text is Markdown. Pass around (a list of node ids, $N refs allowed) to size it as a frame hugging those nodes with padding (32 by default); otherwise it uses x/y/width/height. color is #rrggbb. A frame also gets a jump marker at its top-left unless with_jump is false. Returns comment_id, the geometry, encloses and jump_id.
update_commentcomment_id, text?, x?, y?, width?, height?, color?, opacity?, move_enclosed?Changes a comment. Moving it carries the nodes it encloses unless move_enclosed is false.
delete_commentcomment_idRemoves a comment. Its nodes stay.
add_jumplabel?, x?, y?A named landmark.
update_jumpjump_id, label?, x?, y?Relabels or moves one.
delete_jumpjump_idRemoves one.

A node whose top-left sits inside a comment belongs to it and moves with it, which is the same containment rule the canvas uses. The server’s own conventions ask an assistant to frame each logical section and drop a jump marker at the frame’s corner, so add_comment with around is the operation it reaches for.

Noise blurred into a normal map, in one call:

[
{"op":"add_node","type_id":"noise","params":{"scale":8}},
{"op":"add_node","type_id":"quick_blur","x":320},
{"op":"add_node","type_id":"normal_from_height","x":640},
{"op":"connect","from_node_id":"$0","to_node_id":"$1","to_port_id":"input"},
{"op":"connect","from_node_id":"$1","to_node_id":"$2","to_port_id":"input"}
]

Evaluates the graph on the render core. This is the feedback loop: edit, render, look, adjust. Requires the render tier.

ActionParametersWhat it does
nodenode_id, port_id?, max_size?, save_to?, full_resolution?, include_image?Renders one node and returns its output as an inline PNG, downscaled to max_size (default 512 px, minimum 16, hard cap 2048). save_to also writes a PNG to disk at full render resolution unless full_resolution is false. include_image: false returns metadata only. port_id picks an output on a multi-output node.
evaluateforce?Renders every dirty node and waits for the readbacks, without producing an image. Reports elapsed time, nodes the headless renderer had to skip, and nodes that failed. Worth running before a long export. force re-renders everything.
check_tilingnode_id, port_id?Measures whether the output actually tiles, instead of assuming it. Runs at full render resolution, returns no image.

Every render node reply also carries preview_path, a single file the inline preview is mirrored to. It is overwritten each render rather than accumulated, and it exists because some MCP hosts drop image content blocks: a client that cannot show the inline PNG can open that path instead.

check_tiling compares the wrap-around seam against the sharpest edge inside the image and returns a ratio per axis. About 1 means the seam is no sharper than detail the texture already contains; above 4 is a visible line. A hard-edged pattern such as a checker scores about 1, because its wrap edge is a legitimate tile boundary: what the check looks for is continuous-tone content sliced mid-feature, which is what a bad cell count produces. It costs a full-resolution pass, roughly half a second at 4K, so it belongs before an export rather than inside the tweak-and-look loop.

Engine-style file naming, channel packing (ORM, MetallicSmoothness, Mask Map), per-preset normal Y-flip, resolved per-channel bit depth.

ActionParametersWhat it does
presetsThe engine presets, workflows, formats and packed-map layouts export understands, plus the project’s current settings. The catalog part needs no project.
configureengine?, format?, material_type?, prefix?, write_packed_map?, write_split_maps?, normal_orientation?, disabled_channels?Sets the project’s export preset. Saved with the project, and the same preset the app’s Export dialog shows.
plannode_id?, prefix?, format?The file list an export would write: names, channels, packed layout, per-channel bit depths and a bit-depth warning, without rendering and without a GPU. Use it to confirm naming before committing to a 4K export.
rundirectory?, prefix?, format?, node_id?, all_materials?Evaluates and writes the texture set. Requires the render tier. Overwrites files of the same name. all_materials exports every material, suffixing files with the material name.

Value sets:

  • engine: generic, unreal, unityUrp, unityHdrp, godot, gltf
  • format: png, tga, tiff, bmp, exr
  • material_type: metallicRoughness, specularGlossiness
  • normal_orientation: openGl, directX
  • disabled_channels: channel ids such as baseColor, roughness, metallic, normal, height, ao, opacity

There is no JPEG: lossy compression damages texture maps. presets reports each format’s extension and the deepest bit depth its container carries.

node_id for plan and run must be an output node (PBR Output, Custom Output or Trim Sheet). run defaults to every output node; plan defaults to the first.

Reads, validates and writes Custom Shader node sources. A source is an SL declaration header, covering ports and Properties controls, above a GLSL body.

ActionParametersWhat it does
referenceThe full authoring contract: header syntax, how each parameter type appears in the body, wrapper rules, tiling and the quarantine lifecycle. Meant to be read once before writing any source.
getnode_idThe node’s current source plus its full diagnosis.
validatesourceDry run through the header parser, the emitter and the native compiler where available, touching no node. Line and column positions are relative to the source you passed. Iterate here until it compiles, then apply.
setnode_id, source, force?Replaces the node’s source. Rejected with the error unless force: true, since a broken node keeps its last good render. Ports are re-derived from the declarations, and connections into ports that vanished are dropped and reported.
trustnode_idLifts the quarantine on the node’s current source.

get and validate return the same diagnosis: compiles, the first error with its line and column, native_checked (whether naga actually judged the body, or the native library was absent), vm_capable and vm_instruction_count (which tier the source landed in and how much of the 96-instruction budget it uses), and trust. set adds dropped_connections and, when the source is under quarantine, a warning that it will not render natively until trust is called or the source is edited.

Several mappings surprise a vanilla-GLSL author. param int and seed read as float, and an @color parameter stores as three separate <name>Red, <name>Green and <name>Blue scalars. Read reference first.

Embeds a bitmap file in the project and returns its media:<name> reference.

ParameterWhat it does
pathPath to the image. PNG, JPEG, TGA, BMP, GIF and TIFF are supported. A path outside the standalone server’s --root directories is refused with a message naming the allowed roots.
node_id?An Image node to point at the import, setting its path parameter in the same call.

Embedded media travels inside the .surfacelabs archive, so the project stays portable.

A client can pull these without a tool call:

  • surfacelab://node-registry, the whole node catalog as JSON
  • surfacelab://project, the open project’s active material as JSON
  • surfacelab://validation, the same structural report graph validate returns
  • surfacelab://conventions, the invariants a graph must respect: seamless tiling and how to measure it, normal orientation, how bit depth travels downstream, one wire per input, headless-unsupported node types, and the expectation that every section is framed and labeled
  • surfacelab://shader-language, the SL v2 Custom Shader authoring contract

Two are templates, so a client can address one thing rather than the whole catalog. Their variables complete as you type, which is what makes them worth having:

  • surfacelab://node-type/{type_id}, the payload node_types describe returns
  • surfacelab://node/{node_id}, one node of the active material in full

Three packaged workflows a client can offer you directly.

PromptArgumentsWhat it does
create-materialdescription (required), resolution (default 1024)Builds a complete tileable PBR material from a text description, iterating with renders until it looks right.
debug-graphsymptom (optional)Diagnoses why the open material renders wrong or not at all, explains the cause, then applies the smallest fix.
write-custom-shadereffect (required)Authors a Custom Shader node for an effect the built-in library does not cover.

Rendering runs on a native core (surface_gpu, Rust and wgpu). Whether that core is present splits every tool into two tiers.

TierNeeds the native coreWhat is in it
GraphNonode_types, project, materials, graph, edit_graph, import_image, custom_shader, export presets, export configure, export plan
RenderYesrender node, render evaluate, render check_tiling, export run

project info reports gpu_available. When it is false, render-tier tools fail with the code gpu_unavailable and a message saying the native core is not available in this process. They do not write empty files or return black images. This failure is intentionally annoying.

In live mode the render tier is always available. The app renders through its own path when the native core is absent, so gpu_available is true whenever the app is hosting. Quality can differ, and the settings page treats a missing native core as a fidelity note rather than a gate.

In headless mode, if the core is missing you can still open projects, read and edit graphs, validate, plan an export and save. That split is deliberate: a CI box with no display can check and edit graphs all day.

A few nodes need the app’s own rasterizer. In a headless run they are reported as skipped rather than rendered wrong, and any map fed by one is missing from the output:

  • Draw nodes
  • Text nodes
  • Custom Shader nodes the native path declined: a source that does not compile there, or one the quarantine is holding off the GPU. In the app those fall back to the SL interpreter; a headless process has no interpreter to fall back to.

They can still be created, wired and configured headlessly. Only evaluation needs the app, so open the project in Surface Labs to export those. A Custom Shader that compiles for the native core renders headlessly like any other node, whichever entry point it uses.

Trim Sheets are not on this list. They compose through an ordinary fragment shader, so a sheet evaluates and exports headlessly and from the command line like anything else.

One project at a time. The core keeps the open project in process-wide stores, so opening another closes the first and discards its unsaved changes.

Requests serialize. Messages are dispatched one at a time, whatever the transport and however many clients are connected. Every tool acts on the one open project, so two tool calls interleaving would edit the same graph from two places at once.

Renders serialize. The native core is one device with one render thread, so concurrent render requests queue.

Live mode blocks the editor for the duration of a long request, because requests are served on the UI thread. A large export freezes the canvas until it finishes.

Inline previews are capped at 2048 px on the longest edge, 512 by default. Full-resolution pixels go to disk through save_to or export run, never through the transport.

Deletions are not undoable through the server in headless mode. That covers edit_graph delete_nodes and materials delete. In live mode a deleted node comes back with the canvas’s Undo, along with everything else the assistant did since your last canvas action. See Live Mode above.

Loopback only. The HTTP transport binds 127.0.0.1. There is no option to bind a routable address, so nothing outside your machine can reach it.

Token-gated. Any local process can reach a loopback port, so the app generates a 32-character token and compares it in constant time. New regenerates it and locks out anything holding the old one.

Origin-checked. A browser page on another origin can POST to localhost without triggering CORS, which is the DNS-rebinding case the MCP specification warns about. A request carrying a non-local Origin is refused with 403 before any tool runs.

Opt-in. The server is off until you press Start, and Start with app is off by default.

No outbound side. The server does not reach the internet. It is a door into the app, not out of it.