Building Local MCP Servers for VS Code & Cursor with FastMCP

Quick Answer / TL;DR

A local MCP server is just a Python process that exposes functions as tools your editor's AI can call. With FastMCP you write a plain function, decorate it with @mcp.tool(), and call mcp.run(). You then register the server in a small JSON file: .vscode/mcp.json for VS Code, .cursor/mcp.json for Cursor. One thing to be careful about is the top-level key: VS Code uses servers, Cursor and Claude Desktop use mcpServers. The most common way to break a working server is to print to standard output: over stdio, stdout is the protocol channel, so every log line must go to stderr instead.

What a local MCP server actually is

The Model Context Protocol is a small standard for letting an AI client discover and call external tools. A server advertises a set of capabilities, a client (VS Code's Copilot agent, Cursor, Claude Desktop) launches the server, asks what it can do, and calls it when the model decides a tool is useful. A local server is the simplest case: the client starts your server as a child process and talks to it over the process's standard input and output. There is no network, no port, no authentication to configure.

The transport for this is called stdio. The client and server exchange JSON-RPC messages, one per line, over a single stream: the server reads requests from its standard input and writes responses to its standard output.

How a tool call flows through an MCP serverthe model calls a decorated Python function; the library handles the JSON-RPC wire formatAI clientVS Code / CursorClaude Desktopthe model decidesto call a toolrequestresponsestdioFastMCP server(your Python process)mcp.run() routes to a tool@mcp.tool()search_files(directory, query)@mcp.tool()read_s3_object(bucket, key)libraries & your systemspathlib / stdlibboto3 → S3 / databaseOnly JSON-RPC crosses stdio: your logs must go to stderr, or the stream breaks.
The whole orchestration at a glance: the client sends a JSON-RPC request over stdio, FastMCP's dispatcher routes it to the matching decorated function, that function runs your ordinary Python (standard library, an SDK like boto3, your own systems), and the return value goes back as a JSON-RPC response. You write the tools; the library handles the rest.

Setup

There are two packages that both give you the same decorator API. The standalone fastmcp package is the actively maintained community standard and is what we use here. The Model Context Protocol's own Python SDK also ships a compatible layer, usually imported as from mcp.server.fastmcp import FastMCP; note that the 2.0 release of that SDK removed the old import path, so if you install the bare mcp package you may find the class has moved. In this cookbook, we will stick to the standalone package. Install it into a virtual environment so the editor can find a stable interpreter:

python -m venv .venv
source .venv/bin/activate 
pip install fastmcp

python -c "import fastmcp; print(fastmcp.__version__)"

Note the absolute path to this environment's Python; you will need it in the config file.

A minimal working server

Here is a complete server that exposes one simple local tool: a search over text files in a directory. You create a server object with a name, decorate a normal typed function with @mcp.tool(), and the function's signature and docstring become the tool's schema and description: the client shows those to the model so it knows when and how to call the tool.

# server.py
import sys
from pathlib import Path
from fastmcp import FastMCP

mcp = FastMCP("local-tools")

@mcp.tool()
def search_files(directory: str, query: str, extension: str = ".py") -> list[str]:
    """Search text files under 'directory' for 'query'. Returns 'path:line: text' matches."""
    matches: list[str] = []
    for path in Path(directory).rglob(f"*{extension}"):
        try:
            for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
                if query in line:
                    matches.append(f"{path}:{line_number}: {line.strip()}")
        except (UnicodeDecodeError, OSError):
            continue          # skip binary or unreadable files
    return matches[:100]      # cap the result so we don't flood the model's context

if __name__ == "__main__":
    print("local-tools MCP server starting", file=sys.stderr)   # NB: stderr, not stdout
    mcp.run()                 # stdio transport by default

In this case, the type hints are not decorations. FastMCP reads them to build the tool's input schema, so a client knows that directory and query are required strings and extension is an optional one. Return a plain Python value and it is serialized automatically. Everything about the JSON-RPC framing, the request parsing, the response envelope, is handled by mcp.run(), which defaults to stdio because that is what local editor clients expect.

A Common Bug

Because the server writes its JSON-RPC responses to standard output, standard output belongs entirely to the protocol. Every byte you write there must be valid JSON-RPC. A single stray print() (a startup banner, a debug line, a progress message, even a deprecation notice printed by some dependency you imported) lands in the middle of the stream, the client tries to parse it as JSON, fails, and drops the connection. The symptom is maddening because hard to notice: the server runs fine when you launch it in a terminal, because you read the mixed output as text, and it fails only inside the editor, where the client is strict. The error you see is usually a parse error on a non-JSON token, or a bare connection-closed code.

The fix is simple: all diagnostics go to standard error, never standard output. Standard error is not part of the transport, so the editor simply surfaces it as server logs.

import sys

# WRONG - this corrupts the JSON-RPC stream and kills the connection
@mcp.tool()
def broken(x: int) -> int:
    print(f"called with {x}")               # -> stdout -> breaks the protocol
    return x * 2

# CORRECT - diagnostics go to stderr, invisible to the transport
@mcp.tool()
def working(x: int) -> int:
    print(f"called with {x}", file=sys.stderr)
    return x * 2

If you use the logging module, configure its handler to write to sys.stderr and be wary of libraries that print on import. When a previously working server suddenly disconnects, the first thing to search your code and dependencies for is anything that writes to stdout.

Registering the server in VS Code

VS Code reads MCP server definitions from an mcp.json file, either per-workspace at .vscode/mcp.json or in your user profile. Create the workspace file and give it a single server entry. The command is the interpreter to launch and args is the path to your script; use the absolute path to the virtual environment's Python from the setup step.

// .vscode/mcp.json
{
  "servers": {
    "local-tools": {
      "type": "stdio",
      "command": "/abs/path/to/project/.venv/bin/python",
      "args": ["/abs/path/to/project/server.py"]
    }
  }
}

The type field is stdio for a local server. After saving, use the command palette's list-servers command to start and inspect the server, then switch Copilot chat to agent mode and confirm your tool appears.

Registering the server in Cursor

Cursor uses the same idea with one difference: the top-level key is mcpServers, not servers. This is also a common setup mistake: copying a VS Code config into Cursor, or the reverse, and leaving the wrong key, which silently registers nothing. Cursor reads .cursor/mcp.json in your project (or a global file in your home directory).

// .cursor/mcp.json
{
  "mcpServers": {
    "local-tools": {
      "command": "/abs/path/to/project/.venv/bin/python",
      "args": ["/abs/path/to/project/server.py"]
    }
  }
}

Debugging when it does not connect

Two failure are the most commons. The first is the stdout corruption above; if you see a JSON parse error or a connection-closed code, look for stray output before anything else. The second is the launch itself: the editor could not start the process. That is almost always a path problem: a command that is not an absolute path and is not on the environment the editor hands the process, or a script path that is wrong. Reproduce the exact command from your config in a terminal and confirm it starts and waits for input.

Beyond that, the most useful tool is the MCP Inspector, which launches your server and lets you list and call its tools directly, outside any editor, so you can separate a server bug from a client configuration bug. In VS Code, the MCP output channel and the list-servers command surface the server's stderr and its connection state; in Cursor, the MCP settings panel shows connection status and errors. Because your logs now go to stderr, they will actually appear there.

Common questions

Do I need to understand JSON-RPC to build a server?

No. FastMCP hides the wire format entirely: you write decorated Python functions and the library translates them into spec-compliant messages. The one place the transport leaks through is the stdout rule, which you now know about. Understanding that stdout carries JSON-RPC is enough; you never have to write a JSON-RPC message by hand.

Why does my server work in the terminal but fail in the editor?

Almost always the stdout problem. In a terminal you read the server's mixed output as plain text and notice nothing wrong. The editor's client reads standard output expecting only JSON-RPC and breaks on the first non-JSON byte. The server is running correctly; the transport is contaminated. Move every diagnostic to stderr.

Can one config work for both VS Code and Cursor?

The server code is identical; only the registration file differs, and only in the top-level key. VS Code's .vscode/mcp.json uses servers and Cursor's .cursor/mcp.json uses mcpServers. Keep both files if you use both editors; they point at the same script.

References

  • FastMCP documentation — gofastmcp.com (decorator API, transports, running a server).
  • VS Code MCP configuration reference — code.visualstudio.com (the servers key, workspace vs user config, input variables).
  • Model Context Protocol specification — modelcontextprotocol.io (stdio transport: all non-protocol output must go to stderr).

Versions move quickly in this ecosystem, the FastMCP package and the editors' config formats both may change, so treat the import path and the mcp.json schema as things to confirm against your installed versions, and run the server once through the MCP Inspector before wiring it into your editor.

Related Cookbooks