Skip to main content

Are Local MCP servers safe?

·8 mins

A few months ago I was helping some engineers at work set up the, now archived, Github MCP server when something happened that caught me off guard.

As we went to put the following into the mcp.json file, one of our mobile engineers asked “Should we be worried about this? I’ve read some scary stuff about MCP security issues”.

{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-github"
      ],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
      }
    }
  }
}

It dawned on me in that moment that there’s a lot of mystery around MCP, even amongst technical folk. I was in the same boat just a couple months before when I set my first MCP server up within Cursor. You’re telling me, I just insert these 6 lines of JSON into a file and then voila! Cursor now suddenly has access to my local Postgres database or my Github repositories?

While I love when things just work, the magic of it all left me feeling a bit uneasy. Couple that with all the news about security issues, and I can’t blame the engineer at work who felt the same level of unease about the safety of running these local MCP servers.

Once I dug in just a bit deeper, and I’m talking literally just below the surface, it became pretty clear to me that these local MCP servers are actually very simple and have a level of risk that most technical people are already very comfortable with. I’m hoping this will demystify these MCP servers for you and help you see that they’re just as safe to run as running a python script.

The “Aha!” Moment: Let’s build a very simple MCP server #

I want to do this a bit differently. Let’s first start by actually building a quick MCP server! With just the following 8 lines of python, you’ll have your first MCP server up and running.

Copy this Python code and save it as simple_mcp.py:

#!/usr/bin/env python3
from fastmcp import FastMCP

# FastMCP makes setting up an MCP server so easy
mcp = FastMCP("Demo Server")

# This is the only function we're going to expose as part of our simple server.
# What kind of blog post would this be without a classic Hello World example?
@mcp.tool()
def say_hello() -> str:
    """Says hello to demonstrate MCP"""
    return "Hello from MCP! 👋"

if __name__ == "__main__":
    mcp.run()

Run the script using uv or pip (I prefer uv):

uv run --with fastmcp simple_mcp.py

That’s it, you now have your first MCP server running!

╭─ FastMCP 2.0 ──────────────────────────────────────────────────────────────╮
│                                                                            │
│        _ __ ___ ______           __  __  _____________    ____    ____     │
│       _ __ ___ / ____/___ ______/ /_/  |/  / ____/ __ \  |___ \  / __ \ │      _ __ ___ / /_  / __ `/ ___/ __/ /|_/ / /   / /_/ /  ___/ / / / / /    │
│     _ __ ___ / __/ / /_/ (__  ) /_/ /  / / /___/ ____/  /  __/_/ /_/ /     │
│    _ __ ___ /_/    \__,_/____/\__/_/  /_/\____/_/      /_____(_)____/      │
│                                                                            │
│                                                                            │
│                                                                            │
│    🖥️  Server name:     Demo Server                                         │
│    📦 Transport:       STDIO                                               │
│                                                                            │
│    📚 Docs:            https://gofastmcp.com                               │
│    🚀 Deploy:          https://fastmcp.cloud                               │
│                                                                            │
│    🏎️  FastMCP version: 2.11.3                                              │
│    🤝 MCP version:     1.13.0                                              │
│                                                                            │
╰────────────────────────────────────────────────────────────────────────────╯


[08/16/25 05:38:07] INFO     Starting MCP server 'Demo Server' with transport 'stdio'

Now perform the handshake by typing the following two commands directly into your running terminal. Run these one by one:

{"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {"clientInfo": {"name": "test", "version": "1.0"}, "protocolVersion": "0.1.0", "capabilities": {}}}

{ "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} }

Now ask the server what tools are available:

{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }

You should see your “say_hello” tool in the response.

{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"say_hello","description":"Says hello to demonstrate MCP","inputSchema":{"properties":{},"type":"object"},"outputSchema":{"properties":{"result":{"title":"Result","type":"string"}},"required":["result"],"title":"_WrappedResult","type":"object","x-fastmcp-wrap-result":true},"_meta":{"_fastmcp":{"tags":[]}}}]}}

And now call the tool!

{"jsonrpc": "2.0", "method": "tools/call", "id": 3, "params": {"name": "say_hello", "arguments": {}}}

You should see “Hello from MCP! 👋” in the response.

{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Hello from MCP! 👋"}],"structuredContent":{"result":"Hello from MCP! 👋"},"isError":false}}

Congratulations. You just built and operated an MCP server. That’s it! That’s all that’s happening when a client like Cursor or Claude Desktop talks to an MCP server. JSON in through stdin, JSON out through stdout. No network. No magic. Just reading and writing text to standard input/output - the same thing that happens when you pipe commands in your terminal.

So What Is Actually Happening? #

When you configure an MCP server in Claude Desktop or Cursor, here’s the entire process:

  1. The client spawns your script as a subprocess - exactly like running python simple_mcp.py. This is usually done as an npx command or a docker command. More on this below.
  2. The client sends JSON messages to its stdin - like you just did manually
  3. Your script sends JSON responses to stdout - which the client reads
  4. That’s it

No network ports. No HTTP servers. No external connections. The same as piping data between commands:

echo "some data" | python your_script.py | grep "result"

Except instead of plain text, it’s JSON messages. And instead of echo, it’s Cursor or Claude sending requests.

“But I Heard About Security Issues!” #

You did. And they’re real. But here’s what those articles are actually talking about:

  1. Running malicious MCP servers - Someone sends you a “cool MCP server” that steals your API keys
  2. Supply chain attacks - Bad actors publishing malicious MCP packages to npm
  3. Command injection - Poorly written servers with vulnerabilities like os.system(user_input)
  4. Prompt injection - Hidden instructions in data that trick the AI into doing bad things

Notice something? These are all just “don’t run untrusted software” problems. They’re not about MCP’s protocol or architecture. When you run a local MCP server, the security question is simple: Do you trust who wrote it?

The protocol itself - JSON over stdin/stdout - is not the security issue. It’s actually more secure than most alternatives because:

  • No network exposure (can’t be accessed remotely)
  • No ports to misconfigure
  • No authentication to mess up
  • Process isolation by default
  • Dies when Cursor/Claude closes

Think about it this way: Running a local MCP server is exactly as dangerous as running any Python script or CLI tool. If you wouldn’t run curl https://random-site.com/script.sh | bash, don’t run an MCP server from the same source.

Here’s my security checklist for running a local MCP server:

  1. Is it from a trusted source? (Official repos, known developers)
  2. Can I read/understand what it does? (Most are under 200 lines)
  3. Does it ask for credentials? (If so, make sure I know why)

That’s it. Basically the same checklist I use for any open source software or CLI tool I use.

Let’s look at a real MCP server now #

Here’s what that Github MCP server we were installing is actually doing. This one does ask for a credential (a Github Personal Access Token) but remembering our security checklist item #3 from above we can look a bit deeper to understand why.

We can see in the index.ts file all of the various tool calls that are being set up:

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "create_or_update_file",
        description: "Create or update a single file in a GitHub repository",
        inputSchema: zodToJsonSchema(files.CreateOrUpdateFileSchema),
      },
      {
        name: "search_repositories",
        description: "Search for GitHub repositories",
        inputSchema: zodToJsonSchema(repository.SearchRepositoriesSchema),
      },
      {
        name: "create_repository",
        description: "Create a new GitHub repository in your account",
        inputSchema: zodToJsonSchema(repository.CreateRepositoryOptionsSchema),
      },
      ...

Looking at the implementation of create_or_update_file tool, it’s clear that all it’s doing is calling Github’s API under the hood. That’s what the Github PAT is needed for.

export async function createOrUpdateFile(
  ...
) {
  ...

  const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;
  const body = {
    message,
    content: encodedContent,
    branch,
    ...(currentSha ? { sha: currentSha } : {}),
  };

  const response = await githubRequest(url, {
    method: "PUT",
    body,
  });

  return GitHubCreateUpdateFileResponseSchema.parse(response);
}

So Cursor is communicating with the MCP server via stdin/stdout. The server is running code that is calling Github’s API. No different than running your own script that calls Github’s API.

The 7 line NPX/Docker Thing Isn’t Special Either #

When you look at the MCP configuration that goes into your client:

"github": {
    "command": "npx",
    "args": [
      "-y",
      "@modelcontextprotocol/server-github"
    ],
    "env": {
      "GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
    }
  }
}

It’s the same as running the npx or Docker command directly

npx -y @modelcontextprotocol/server-github

or

docker run -i --rm -e GITHUB_PAT mcp/github

That uses either NPX or Docker to download and run the MCP server as either a Node server or a Docker container. This makes it more of a “download-and-run once” type command rather than asking you to pull the source code and run the server yourself.

The Bottom Line #

Local MCP servers are just small programs that read JSON from stdin and write JSON to stdout.

When you run one locally:

  • It’s a subprocess of your client (Claude/Cursor, etc)
  • It can only do what you could do
  • It can’t accept network connections and is not exposed to the outside world
  • It can call external APIs the same way you would if you wrote a program yourself
  • It dies when Claude/Cursor closes

There are security issues but they’re mostly the same as the security issues you deal with when choosing open source software or they’re more relevant if you’re looking to deploy a public facing MCP server.

The whole thing is beautifully, boringly simple. No magic. No mystery. Just JSON through pipes. Did you feel that? That was that uneasy feeling you had before reading this just vanishing. Now go take advantage of the amazing MCP servers that exist!