How figma-mcp-go uses Go to give AI agents full read/write access to Figma designs through the MCP protocol — and why the architecture is worth studying even if you don't care about Figma.

Building a Figma MCP Server in Go


Most Figma MCP integrations hit Figma’s REST API directly, run into rate limits within minutes, and then everyone wonders why their AI agent keeps stalling. figma-mcp-go sidesteps that problem entirely, and the way it does it taught me a few things about structuring Go servers that sit between two very different protocols.

If you haven’t encountered MCP (Model Context Protocol) yet — it’s Anthropic’s open standard for giving AI models a structured way to call tools, read data, and take actions outside the chat window. Think of it as an API contract between an AI agent and the outside world.

figma-mcp-go is a Go server that implements this protocol for Figma. Agents can read designs, create components, modify layouts, generate code from Figma files. But instead of calling Figma’s REST API, it talks to a Figma plugin over WebSocket. The plugin runs inside Figma’s desktop app with full read/write access to the document. No rate limits.

The Go code itself is what I want to focus on. The architecture has a server speaking MCP over stdio on one side and maintaining a persistent WebSocket connection on the other, all while multiplexing concurrent tool calls through a single socket. That’s a fun concurrency problem.

How the architecture works

The flow is linear but the execution isn’t:

AI Agent → MCP Protocol → Go Server → WebSocket → Figma Plugin → Figma Document

The Go server does two things at once: it reads MCP requests from stdin and writes responses to stdout (that’s how MCP clients like Claude Desktop communicate), and it manages a long-lived WebSocket connection to the Figma plugin running locally. Each tool call from the AI agent becomes a command sent over that socket, and the response flows back the same way.

Go’s goroutines make this feel natural. You don’t need to think about thread pools or callback hell. But there are real synchronization concerns — multiple tool calls can be in flight simultaneously, all sharing one WebSocket connection. More on that in a moment.

The Go MCP server setup

The project uses mcp-go to handle protocol details. If you’ve ever registered HTTP handlers in Go, this will feel familiar — define a tool, attach a handler function:

package main

import (
	"log"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

func main() {
	s := server.NewMCPServer(
		"figma-mcp-go",
		"1.0.0",
		server.WithToolCapabilities(true),
	)

	// Register a tool that reads the current Figma selection
	getSelectionTool := mcp.NewTool(
		"get_selection",
		mcp.WithDescription("Get the currently selected nodes in Figma"),
	)

	s.AddTool(getSelectionTool, handleGetSelection)

	// Start the stdio transport
	if err := server.ServeStdio(s); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

func handleGetSelection(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	// Send command to Figma plugin via WebSocket
	// Wait for response
	// Return result to AI agent
	return mcp.NewToolResultText("selection data here"), nil
}

mcp.NewTool defines the tool’s name and description. AddTool binds it to a handler. The stdio transport means the server reads MCP requests from stdin and writes responses to stdout, which is how MCP servers talk to clients like Claude Desktop or Cursor.

Straightforward stuff. The interesting part is what happens inside those handlers.

WebSocket communication with the Figma plugin

This is where I spent the most time reading the code. The server uses coder/websocket to maintain the connection to the Figma plugin, and it needs to solve a specific problem: multiple MCP tool calls arrive concurrently, but they all funnel through one WebSocket connection. Each command needs to be matched with its response.

Here’s a pattern similar to what the project uses:

package ws

import (
	"net/http"
	"sync"

	"github.com/coder/websocket"
)

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true // Figma plugin connects locally
	},
}

type FigmaConn struct {
	mu   sync.Mutex
	conn *websocket.Conn

	// pending tracks requests waiting for responses
	pending map[string]chan []byte
	pendMu  sync.Mutex
}

func (fc *FigmaConn) HandleWS(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}

	fc.mu.Lock()
	fc.conn = conn
	fc.mu.Unlock()

	// Read loop: match responses to pending requests
	go fc.readLoop()
}

func (fc *FigmaConn) readLoop() {
	for {
		_, msg, err := fc.conn.ReadMessage()
		if err != nil {
			return
		}

		// Parse the message, extract the request ID,
		// and send the response to the waiting channel
		id := extractID(msg)

		fc.pendMu.Lock()
		if ch, ok := fc.pending[id]; ok {
			ch <- msg
			delete(fc.pending, id)
		}
		fc.pendMu.Unlock()
	}
}

func (fc *FigmaConn) SendCommand(id string, command []byte) ([]byte, error) {
	ch := make(chan []byte, 1)

	fc.pendMu.Lock()
	fc.pending[id] = ch
	fc.pendMu.Unlock()

	fc.mu.Lock()
	err := fc.conn.WriteMessage(websocket.TextMessage, command)
	fc.mu.Unlock()
	if err != nil {
		return nil, err
	}

	// Block until the Figma plugin responds
	resp := <-ch
	return resp, nil
}

This is request-response multiplexing, and it’s one of those patterns that comes up constantly once you recognize it. Each outgoing command gets a unique ID and a buffered channel. The readLoop goroutine runs forever, reading messages off the socket and routing them to the right channel based on ID. The caller blocks on its channel until the response arrives.

A few things I like about this approach:

The mutex on conn is necessary because a shared WebSocket connection still needs serialized writes. Multiple tool handlers run in their own goroutines, so without that lock you’d get corrupted frames. The separate pendMu mutex for the pending map avoids holding the connection lock longer than needed.

The buffered channel (make(chan []byte, 1)) is a small detail that matters. If the response arrives before the sender starts listening (unlikely but possible with scheduling), a buffered channel won’t block the read loop. If you’ve worked with Go’s concurrency patterns before, this is a familiar defensive choice.

And the read loop running in its own goroutine means sending a command doesn’t block on receiving. The server can fire off a command and immediately go handle another MCP request. Responses get routed asynchronously.

Tool registration: from Figma actions to MCP tools

The project exposes a big set of tools — reading node properties, creating frames, adding text, exporting to code. Each tool defines a JSON Schema for its inputs so the AI agent knows what parameters to pass.

Here’s a more realistic example with parameters:

func registerCreateTextTool(s *server.MCPServer, fc *FigmaConn) {
	tool := mcp.NewTool(
		"create_text",
		mcp.WithDescription("Create a text node in the Figma document"),
		mcp.WithString("content",
			mcp.Description("The text content"),
			mcp.Required(),
		),
		mcp.WithNumber("x",
			mcp.Description("X position"),
			mcp.Required(),
		),
		mcp.WithNumber("y",
			mcp.Description("Y position"),
			mcp.Required(),
		),
		mcp.WithNumber("fontSize",
			mcp.Description("Font size in pixels"),
		),
	)

	s.AddTool(tool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		content, _ := req.Params.Arguments["content"].(string)
		x, _ := req.Params.Arguments["x"].(float64)
		y, _ := req.Params.Arguments["y"].(float64)
		fontSize, _ := req.Params.Arguments["fontSize"].(float64)

		if fontSize == 0 {
			fontSize = 16
		}

		cmd := CreateTextCommand{
			Action:   "create_text",
			Content:  content,
			X:        x,
			Y:        y,
			FontSize: fontSize,
		}

		cmdBytes, err := json.Marshal(cmd)
		if err != nil {
			return nil, fmt.Errorf("marshal command: %w", err)
		}

		resp, err := fc.SendCommand(uuid.New().String(), cmdBytes)
		if err != nil {
			return nil, fmt.Errorf("send to figma: %w", err)
		}

		return mcp.NewToolResultText(string(resp)), nil
	})
}

mcp.WithString and mcp.WithNumber define the JSON Schema for the tool’s inputs. The schema gets sent to the AI agent so it knows what arguments to provide. The handler extracts those arguments, builds a command struct, marshals it, sends it over the socket, waits for the response.

One gotcha worth calling out: the type assertions like req.Params.Arguments["x"].(float64). JSON doesn’t distinguish between integers and floats, so everything numeric arrives as float64 in Go’s interface{} world. If you’re building something similar, write helper functions for extraction and validation. Scattering bare type assertions across every handler gets old fast, and a failed assertion panics unless you use the two-value form.

Running it with an AI agent

Configuration is minimal. Point your MCP client at the Go binary:

{
  "mcpServers": {
    "figma": {
      "command": "go",
      "args": ["run", "/path/to/figma-mcp-go/main.go"],
      "env": {}
    }
  }
}

Launch the Figma plugin (it connects to the Go server over WebSocket), and your AI agent can start calling tools — get_selection, create_text, create_frame, whatever’s registered.

The design-to-code workflow is the flashy one. The agent reads the Figma design tree through the server, understands the layout and styling, and generates frontend code. The actual code generation happens in the AI model. The Go server’s job is to provide structured data about the design, nothing more.

What’s worth stealing from this project

I keep coming back to this repo for patterns that apply well beyond Figma:

MCP servers in Go are easy to stand up. The mcp-go library handles the protocol plumbing. You register tools, write handlers, done. If you want to connect any Go service to an AI agent, this is a good template.

WebSocket request-response multiplexing is reusable everywhere. Chat systems, game servers, any bidirectional protocol where you need to correlate requests with responses over a single connection. The channel-per-request pattern shown here is clean and I’ve used variations of it in three different projects.

Protocol bridging is a natural fit for Go. This server sits between stdio-based MCP and WebSocket. Translating between protocols is something Go programs do well — small binary, easy to deploy, goroutines handle the concurrent I/O without drama. If you’re interested in protocol bridging more broadly, the post on using gRPC in Go covers another approach to structured communication.

The concurrency is real-world, not textbook. Managing a single WebSocket connection shared across multiple goroutine-based tool handlers is exactly the kind of problem you’ll face in production Go code. The mutex + channel approach here is worth studying. For more on this, the post on wait groups in Go covers related territory.

Where to go from here

figma-mcp-go is worth reading through even if you never touch Figma. The patterns — multiplexed WebSocket communication, protocol bridging, concurrent access to shared connections — show up in all kinds of Go systems.

The MCP ecosystem is growing quickly, and Go fits it well: small binaries, trivial deployment, goroutines that make bidirectional I/O feel natural. If you’ve been looking for a concrete project to learn MCP through, connecting it to a tool your team already uses is a better starting point than building yet another toy example.