Labsco
LeszczynskiKarol logo

Claude.ai MCP Server (Self-hosted)

β˜… 1

from LeszczynskiKarol

Self-hosted MCP server for Claude.ai. Give Claude direct access to your AWS, SSH, local shell, GitHub, PostgreSQL and PM2 β€” without Claude Code. Your keys never leave your machine.

πŸ”₯πŸ”₯πŸ”₯βœ“ VerifiedFreeAdvanced setup

mcp-server

Self-hosted MCP server for Claude.ai

Give Claude direct access to your AWS account, SSH into your servers, run shell commands on your laptop, query your databases, and manage PM2 processes β€” all from a Claude chat. No Claude Code subscription needed. Your keys never leave your machine.

All 10 tools available as a Claude.ai custom connector

All 10 tools live in your Claude.ai sidebar - no Desktop install, no separate client, just a custom connector.


What is this?

A small Node.js MCP server you run on your own machine (laptop, desktop, VPS) and connect to Claude.ai (or any MCP client) as a Custom Connector. It exposes a configurable set of tools that let Claude do real work on your infrastructure:

  • Run AWS CLI commands using your local profile
  • SSH into your servers using your .pem keys
  • Execute shell commands on your local machine
  • Write files directly on your local disk (full UTF-8, no shell quoting)
  • Hit the GitHub REST API with your Personal Access Token
  • Query PostgreSQL databases on remote hosts via SSH
  • Inspect PM2 processes on remote servers
  • Iterate over very long documents (book editing) that exceed the context window

Architecture:

  Claude.ai  ──HTTPS──►  nginx + cert  ──HTTP──►  frps  ──tunnel──►  frpc + node
   (cloud)               on a VPS         :8080    (vhost)            (your PC)
                                                                          β”‚
                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                β–Ό               β–Ό               β–Ό         β–Ό         β–Ό
                            AWS CLI       ssh -i *.pem      cmd.exe    psql via   pm2 list
                            (local)       user@host         git/npm      SSH      (remote)

SSH keys, GitHub PAT and AWS credentials never leave your local machine. The tunnel only carries MCP requests and their results.


Why self-host this?

The MCP ecosystem mostly does two things today:

  1. MCP servers as npm packages that run via stdio and require Claude Desktop.
  2. Hosted MCP services behind someone else's auth and API limits.

This project is the third option: your own MCP server, your keys, your servers, accessible from web Claude.ai (where you already work). It's a single ~1000-line server.js file that you can read end-to-end in 20 minutes and extend in 5.

Killer features:

  • βœ… No Claude Code subscription needed β€” works with regular Claude.ai (web)
  • βœ… OAuth 2.1 with PKCE β€” proper Claude.ai integration, not a hacky workaround
  • βœ… Your keys stay yours β€” SSH keys, GitHub PATs, AWS creds never leave your machine
  • βœ… Self-hostable β€” Windows, Linux, Mac, anything that runs Node 18+
  • βœ… Configurable via JSON β€” add a new server or new key without touching code
  • βœ… One file to read β€” no framework magic, no hidden config
  • βœ… Hardened OAuth β€” PKCE S256, client_secret enforcement, refresh token rotation, RFC 7009 revocation, dynamic IP allowlist with auto-enroll, anti-clickjacking

Tools

ToolWhat it does
aws_cliRun any aws <command> using your local AWS CLI profile
ssh_execSSH into any host using a key defined in hosts.json
local_execRun any shell command on the local machine (cmd.exe on Windows, /bin/sh elsewhere)
write_fileWrite text content to a local file (overwrite or append). Full UTF-8, no shell quoting issues β€” preferred over local_exec for any non-trivial file write
github_apiMake REST API requests to GitHub using your Personal Access Token
postgres_queryRun psql queries via SSH (sudo -u postgres) on a host from hosts.json
pm2_statusShow pm2 list and optional logs on a remote server
sftp_downloadStream a file from a remote host to the local filesystem (uses the persistent SSH pool). Essential when working from Claude.ai web β€” the cloud sandbox has no native scp
sftp_uploadStream a local file to a remote host. Optional POSIX mode flag (e.g. 0755). Parent dir must exist on remote
book_splitSplit a large text file into ~3000-word chunks
book_chunkRead one chunk from a directory created by book_split
book_noteManage JSON notes for iterative work on long documents

See it in action

Real examples from a real Claude.ai chat using this MCP server.

Server health snapshot over SSH

"SSH into my matury server and show me: disk usage (df -h), memory usage (free -h), and uptime. Use a single command and present the output nicely."

Claude composes a server health snapshot with disk, memory and uptime

Claude composes a single SSH command, parses the multi-section output, and renders disk, memory and uptime as a clean snapshot with key numbers highlighted.

List EC2 instances in any region

"Using AWS CLI, list all my EC2 instances in eu-central-1 with their IDs, types, and state. Format the result as a clean table."

EC2 instance inventory rendered as a clean table

Claude calls aws_cli with a describe-instances --query ... filter, then parses the JSON and renders it as a markdown table with running/stopped status indicators.

Query PostgreSQL databases over SSH

"Run a SQL query on my 'panel' PostgreSQL database to count the total users, then on database 'smart_edu' count rows in the users table, and tell me how my system is doing."

Claude orchestrates ssh_exec and postgres_query in parallel

Two tools work together here: ssh_exec to enumerate databases when the first guess misses, then postgres_query against the right one.

Database list and user counts across multiple databases

Claude lists every database on the host, identifies the ones with a users table, and runs COUNT(*) queries in parallel.


Exposing publicly (Claude.ai)

Claude.ai requires HTTPS. The recommended setup uses FRP (Fast Reverse Proxy) + nginx + Let's Encrypt on a small VPS.

1. DNS record

mcp.your-domain.com    A    <VPS_IP>    TTL 300

2. nginx vhost on VPS

/etc/nginx/sites-available/mcp.your-domain.com:

server {
    server_name mcp.your-domain.com;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # SSE / long-lived MCP connections
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
    listen 80;
}
sudo ln -s /etc/nginx/sites-available/mcp.your-domain.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d mcp.your-domain.com

3. FRP server (frps) on VPS

/etc/frp/frps.toml:

bindPort = 7000
vhostHTTPPort = 8080
auth.method = "token"
auth.token = "<shared token>"

4. FRP client (frpc) on your local machine

frpc-mcp.toml:

serverAddr = "<VPS_IP>"
serverPort = 7000
auth.method = "token"
auth.token = "<shared token from frps>"

[[proxies]]
name = "mcp"
type = "http"
localPort = 4500
customDomains = ["mcp.your-domain.com"]

5. Add the connector in Claude.ai

  1. Open Settings β†’ Connectors β†’ Add custom connector
  2. URL: https://mcp.your-domain.com/mcp (with /mcp suffix!)
  3. OAuth Client ID/Secret: leave empty
  4. Click Connect β†’ a login form appears β†’ enter MCP_USER and MCP_PASS from your .env
  5. In a chat: + β†’ Connectors β†’ toggle this MCP on β†’ start a new conversation (tools attach at chat start)

The first request from a new IP will trigger an OAuth re-login, which adds your /24 subnet to the allowlist for 30 days. This is intentional β€” see Security.


Even with the right tools installed, Claude will sometimes waste tokens on common failure modes: re-reading files after writing them, copying files into the sandbox "to edit them", lying that a file is on disk when only an artifact was created.

CLAUDE_PREFERENCES.md is a curated set of user preferences that closes those holes. Paste them into Settings -> Profile -> Personal preferences in Claude.ai. They're battle-tested against the exact failure modes that prompted writing some of the tools in this server (especially write_file).

Covers, among other things:

  • File-tool hierarchy: always write_file for new files, PowerShell for surgical edits, never base64-chunks through cmd.exe
  • "Trust the write" -- don't re-read a file just to confirm it saved
  • Sandbox isolation -- the bash sandbox has no access to your local disk; don't bridge it via public file hosts (uguu.se, transfer.sh, etc.)
  • Artifacts vs. MCP file writes -- "Created a file" tiles in the chat UI are NOT saved to your disk
  • Raw content rule -- write_file content must be raw, not JSON-escaped
  • Anti-loop limits -- stop after 3 failures on the same problem
  • Non-ASCII character handling on Windows (cmd.exe code page gotcha)

Extending

Adding a new host

Edit hosts.json:

{
  "hosts": {
    "production": {},
    "new-server": {
      "ip": "5.6.7.8",
      "user": "ubuntu",
      "key": "main",
      "description": "New server"
    }
  }
}

The server reads hosts.json at boot, so kill node (taskkill /F /IM node.exe /T) and the restart loop will pick up the new config in 5 seconds. In Claude.ai, disconnect and reconnect the connector so it sees the new host in the host parameter dropdown.

Adding a new SSH key

{
  "keys": {
    "main": "/path/to/main.pem",
    "client-x": "~/keys/client-x.pem"
  }
}

Adding a new tool

In server.js:

server.tool(
  "your_tool_name",
  "Clear description of when Claude should use this tool",
  {
    param: z.string().describe("what this parameter does"),
  },
  async ({ param }) => {
    // your logic here
    return { content: [{ type: "text", text: "result" }] };
  },
);

After saving, taskkill /F /IM node.exe /T to let the restart loop pick up the change. Disconnect and reconnect the connector in Claude.ai to see the new tool.


Security

For a full list of what the server enforces, see SECURITY.md. The headline features:

  • OAuth 2.1 with PKCE (S256 only) and Dynamic Client Registration
  • client_id match on /oauth/token β€” code can only be redeemed by its issuing client (RFC 6749 Β§4.1.3)
  • client_secret enforcement on /oauth/token and /oauth/revoke β€” secrets issued at registration are actually checked
  • Refresh token validation and rotation β€” the old refresh token is invalidated on every use, a fresh pair is issued, and only the owning client can rotate
  • Persistent OAuth state in oauth-state.json β€” node restart no longer forces re-authorization in Claude.ai
  • Token revocation endpoint at /oauth/revoke (RFC 7009)
  • Dynamic IP allowlist with auto-enroll β€” the /24 subnet of every successful OAuth login is allowlisted for 30 days. Unknown IPs get 401 + WWW-Authenticate, so Claude.ai silently re-runs OAuth and the new subnet is added. Static IPs/CIDRs can be configured via MCP_ALLOWED_IPS
  • Anti-clickjacking on the OAuth login form via helmet: X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'
  • Rate limit on /oauth/*: 30 requests / 15 minutes / IP
  • Prototype pollution prevention in book_note (__proto__, constructor, prototype keys rejected)
  • Token values redacted in logs β€” only client_id and the first 8 chars of any token are written

For AI assistants (Claude, Cursor, Cline, Aider, etc.)

This repo includes CLAUDE.md with critical instructions for any AI assistant working on this codebase. Read it first. It documents:

  • The single correct way to edit files on the Windows host (write_file tool)
  • Anti-patterns that have wasted real tokens (sandbox-as-bridge, base64 chunks, uploading files to public hosts to transfer them back to the owner's disk)
  • Anti-loop rules: 3 failures β†’ stop and propose alternatives
  • Server restart workflow that breaks MCP sessions

If you're a human deploying this MCP server for your own Claude.ai account:

  1. Copy the rules from CLAUDE.md into Settings β†’ Profile β†’ Personal preferences in Claude.ai. That's the only mechanism in the web UI that loads instructions at session start.
  2. (Optional) Copy CLAUDE.md into every repo you'll edit via this server. AI tools running outside claude.ai (Claude Code, Cursor, Cline) will read it automatically.
  3. Future Anthropic Agent Skills support in claude.ai web may eventually load ~/.claude/skills/*.md automatically. Until then, preferences + per-repo CLAUDE.md is the working pattern.

Behind the FRP + nginx topology described above:

MCP_TRUST_PROXY=loopback   # frpc connects to node over 127.0.0.1
MCP_AUTO_ENROLL=true       # let Claude.ai's egress IP enroll itself on first login
MCP_ALLOWED_IPS=           # leave empty unless you have a fixed office/VPN IP
MCP_PASS=<random 20+ chars>

MCP_TRUST_PROXY=true is permissive and will be rejected by express-rate-limit because it would allow any client to spoof X-Forwarded-For and bypass rate limiting. Use loopback (or a comma-separated list of trusted proxy IPs) instead.

Hardening you can apply on top

  1. Read-only AWS profile for aws_cli if you don't need mutations β€” create dedicated IAM credentials with ReadOnlyAccess
  2. Command whitelist for aws_cli / local_exec / ssh_exec if you trust Claude less than the AWS console
  3. Pin SSH host keys β€” remove StrictHostKeyChecking=no from ssh_exec and pre-populate ~/.ssh/known_hosts
  4. Audit log to a file β€” Task Scheduler setup writes to logs/mcp.log; persistent rotation is up to you
  5. Per-tool ACL β€” restrict which client (e.g. work vs personal Claude.ai account) can call which tool with a custom dispatcher in front of server.tool
  6. Check .gitignore β€” must include .env, hosts.json, oauth-state.json, logs/, mcp-task.generated.xml, start-mcp-hidden.vbs

Project files

mcp-server/
β”œβ”€β”€ server.js              # MCP server (Express + StreamableHTTP + OAuth)
β”œβ”€β”€ package.json
β”œβ”€β”€ .env                   # (gitignored) secrets
β”œβ”€β”€ .env.example
β”œβ”€β”€ hosts.json             # (gitignored) server list
β”œβ”€β”€ hosts.example.json
β”œβ”€β”€ oauth-state.json       # (gitignored) persisted OAuth state
β”œβ”€β”€ .gitignore
β”œβ”€β”€ README.md              # English (this file)
β”œβ”€β”€ README.pl.md           # Polish translation
β”œβ”€β”€ LICENSE
β”œβ”€β”€ CHANGELOG.md
β”œβ”€β”€ CONTRIBUTING.md
β”œβ”€β”€ SECURITY.md
β”œβ”€β”€ setup.bat              # quick start for Windows
β”œβ”€β”€ setup.sh               # quick start for Linux/Mac
β”œβ”€β”€ start-mcp.bat          # node restart loop (Windows autostart)
β”œβ”€β”€ install-task.bat       # wrapper that runs install-task.ps1
β”œβ”€β”€ install-task.ps1       # registers the "MCP Server" scheduled task
└── logs/                  # (gitignored) mcp.log

Compatibility

MCP ClientStatus
Claude.ai (web)βœ… Primary target β€” fully tested
Claude Desktopβœ… Tested - same connector URL works (custom connectors with OAuth)
Custom MCP client (with OAuth 2.1)βœ… Standard implementation
Custom MCP client (without OAuth)❌ Requires modification β€” OAuth is mandatory in current code

Roadmap

Ideas for future versions (PRs welcome):

  • Persistent OAuth state so node restart doesn't kill connections (done in 1.1.0)
  • Rate limiting on /oauth/* (done in 1.1.0)
  • Token revocation endpoint (done in 1.1.0)
  • Audit log to file (audit.log with rotation)
  • Per-tool ACL (which client can use which tool)
  • Read-only mode for AWS / SSH (whitelist of safe commands)
  • Docker Compose for one-command deployment
  • More tools: S3 upload, CloudWatch logs, Sentry, Stripe
  • Multi-user authentication (OIDC integration: Google / GitHub login)