⌂ Home MCP Track — Building with the Model Context Protocol ⚡ MODULE 06 of 8 · MCP Track
~65 min Intermediate Config + Lab
MODULE 06 · Claude Code & IDE Integration

Claude Code & IDEs: Wiring MCP into Your Dev Environment

Configure MCP servers in Claude Code, Claude Desktop, VS Code, and three editor extensions. Build a personal productivity server with 6 tools that makes Claude meaningfully smarter for daily coding — in under 30 minutes.

After this module you will:
  • Write correct .claude/settings.json configs for both stdio and HTTP MCP servers
  • Locate and edit claude_desktop_config.json on macOS, Windows, and Linux
  • Use MCP Inspector to test every tool before connecting to any LLM client
  • Configure MCP in GitHub Copilot, Continue.dev, and Cline for VS Code
  • Build a complete personal productivity MCP server with 6 hardened tools (Python + TypeScript)
  • Diagnose the 5 most common IDE MCP failures with exact steps

1. Why Wire MCP into Your IDE

Analogy — Before / Pain / Mapping

BEFORE: Imagine working as a telephone switchboard operator in 1965. A caller (Claude) asks for database records — you manually relay the request to a second person, wait, then relay the answer back. Everything runs through you: copy-paste from the terminal, paste into the chat, copy back the answer. You are the integration layer.

PAIN: Context is lost at every relay. You describe what a file says rather than showing it; you paraphrase error messages; you forget to mention the current git branch. Claude is reasoning on summaries of reality, not reality itself. The conversation degrades as the gap between "what Claude can see" and "what is actually true in your project" widens.

MAPPING: MCP IDE integration removes the switchboard entirely. Claude has direct phone lines — to your filesystem, your test runner, your git history, your database. When you ask "why are my tests failing?", Claude calls run_tests() directly, reads the raw output, correlates it with get_git_log(), and reasons on live data. You become the decision-maker, not the data courier.

Why It Matters

Teams that integrate MCP into their coding workflow report cutting context-setup time by 60–80% per session. Instead of spending the first 10 minutes of each conversation pasting in files, error messages, and git diffs, the MCP server surfaces them on demand. Over a 40-hour week, that's 4–8 hours returned to actual work.

The rest of this module is practical configuration. You'll learn exactly where each config file lives, how to validate every server before touching any LLM client, and how to build the six-tool server that makes Claude genuinely useful for day-to-day development.

2. Claude Code — .claude/settings.json

Claude Code reads MCP server configuration from .claude/settings.json inside your project root, or from the global config at ~/.claude/settings.json. The key is the "mcpServers" object — a map from a friendly server name to a transport config.

There are two transport types: stdiostdio (Standard I/O subprocess) — Claude Code launches the server as a child process and communicates via stdin/stdout pipes. No network port needed. Best for local tools and dev servers where security boundaries don't matter. for local processes and HTTP/SSE for remote servers.

WHAT
The complete settings.json shape. Both server types shown side by side. This file lives at .claude/settings.json (project-scoped) or ~/.claude/settings.json (global, applies to every project).
JSON — .claude/settings.json
{
  "mcpServers": {
    "my-tools": {
      "command": "python",
      "args": ["tools/server.py"],
      "env": {
        "DB_URL": "sqlite:///local.db",
        "LOG_LEVEL": "INFO"
      }
    },
    "remote": {
      "url": "https://api.example.com/mcp/sse",
      "headers": {
        "Authorization": "Bearer ${MCP_TOKEN}"
      }
    }
  }
}
WHY — stdio vs HTTP
Use "command" + "args" for local servers (stdio) — zero network overhead, no firewall rules needed. Use "url" for remote servers — the URL must point to an SSE endpoint. Env vars in the "env" block are passed to the subprocess; they are NOT inherited from your shell.
GOTCHA — ${VAR} interpolation
The ${MCP_TOKEN} syntax in "headers" is evaluated from your shell environment at Claude Code startup. If the variable is not set, Claude Code passes the literal string "Bearer ${MCP_TOKEN}" — the server will receive an invalid token and reject the request. Always export MCP_TOKEN=... in your shell profile before launching Claude Code.

Project-level vs Global Scoping

The same mcpServers key works identically in both locations. Precedence rules:

  • .claude/settings.json (project root) — only active when Claude Code is opened in that directory. Commit this file to share the server config with teammates.
  • ~/.claude/settings.json (global) — active in every project. Best for personal servers that aren't project-specific (e.g., your personal productivity server).
  • When both files define a server with the same key, the project-level config wins.
Animation 1 — IDE Integration Flow: Query → Config → Spawn → Tool Available
You type
a query
Claude Code
reads settings.json
Spawns server
subprocess
Tool list
negotiated
Tool available
in chat

stdio Server Examples

WHAT
A minimal stdio MCP server that exposes one tool. This is exactly the kind of server that gets pointed to by the "command": "python" config above.
Python — tools/server.py (stdio)
#!/usr/bin/env python3
"""Minimal stdio MCP server — entry point for .claude/settings.json."""
import os
from mcp.server.fastmcp import FastMCP

# FastMCP handles JSON-RPC negotiation and stdio transport automatically
mcp = FastMCP("my-tools")

@mcp.tool()
def ping(message: str) -> str:
    """Echo a message back with server identity. Useful for testing connectivity."""
    db_url = os.environ.get("DB_URL", "not set")  # env var from settings.json
    return f"pong: {message} (db={db_url})"

if __name__ == "__main__":
    # run() starts the stdio loop — blocks until Claude Code closes the process
    mcp.run()
TypeScript — tools/server.ts (stdio)
#!/usr/bin/env tsx
/**
 * Minimal stdio MCP server — entry point for .claude/settings.json.
 * Run with: tsx tools/server.ts
 */
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "my-tools", version: "1.0.0" });

server.tool(
  "ping",
  "Echo a message back with server identity. Useful for testing connectivity.",
  { message: z.string().describe("The message to echo") },
  async ({ message }) => {
    const dbUrl = process.env.DB_URL ?? "not set"; // env var from settings.json
    return { content: [{ type: "text", text: `pong: ${message} (db=${dbUrl})` }] };
  }
);

// StdioServerTransport wires stdin/stdout to the JSON-RPC loop
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Server running on stdio"); // stderr only — stdout is reserved for MCP
What Just Happened?

You registered a tool called ping with a typed message parameter. Claude Code will negotiate this tool via the MCP initialize handshake and expose it in every conversation opened inside this project. The server process stays alive for the session duration, not per-request.

HTTP/SSE Remote Server Example

WHAT
The same server running over HTTP with SSE transport — suitable for a shared team server or a cloud-deployed tool. The config just changes from "command" to "url".
Python — HTTP/SSE server
#!/usr/bin/env python3
"""MCP server over HTTP/SSE — use url: in settings.json to connect."""
import os
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("remote-tools")

@mcp.tool()
def status() -> dict:
    """Return deployment status. Callable from any IDE pointing at this URL."""
    return {"status": "ok", "version": "1.0.0"}

if __name__ == "__main__":
    # sse transport starts an HTTP server; point settings.json "url" at /sse
    mcp.run(transport="sse", host="0.0.0.0", port=8080)
TypeScript — HTTP/SSE server
#!/usr/bin/env tsx
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const server = new McpServer({ name: "remote-tools", version: "1.0.0" });

server.tool("status", "Return deployment status.", {}, async () => ({
  content: [{ type: "text", text: JSON.stringify({ status: "ok", version: "1.0.0" }) }],
}));

const app = express();
const transports = new Map<string, SSEServerTransport>();

app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  transports.set(transport.sessionId, transport);
  await server.connect(transport);
});

app.post("/messages", express.json(), async (req, res) => {
  const transport = transports.get(req.query.sessionId as string);
  if (!transport) { res.status(404).send("Session not found"); return; }
  await transport.handlePostMessage(req, res);
});

app.listen(8080, () => console.log("MCP server on :8080/sse"));
With Claude Code configuration covered, let's look at Claude Desktop — the GUI client that has its own config file location on every OS.

3. Claude Desktop — claude_desktop_config.json

Claude Desktop uses a separate JSON config file (not .claude/settings.json). The schema is identical — the same mcpServers key, the same transport options — but the file path differs per OS.

OSConfig File Location
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json (typically C:\Users\<you>\AppData\Roaming\Claude\)
Linux~/.config/Claude/claude_desktop_config.json
Restart Required

Claude Desktop reads the config file at startup only. After every edit to claude_desktop_config.json, you must fully quit and relaunch Claude Desktop — not just reload the window. On macOS, right-click the Dock icon and choose Quit. Confirmed by the MCP server status indicator disappearing from the UI between restarts.

Multiple Servers Config

WHAT
A realistic config with three servers: a local Python tools server, a shared team server over HTTP, and a Node.js TypeScript server. All three run simultaneously; Claude can call any tool from any server in a single conversation.
JSON — claude_desktop_config.json (multiple servers)
{
  "mcpServers": {
    "personal-tools": {
      "command": "python",
      "args": ["/Users/you/mcp-servers/personal/server.py"],
      "env": {
        "PROJECTS_ROOT": "/Users/you/projects"
      }
    },
    "team-shared": {
      "url": "https://mcp.internal.example.com/sse",
      "headers": {
        "Authorization": "Bearer ${TEAM_MCP_TOKEN}"
      }
    },
    "ts-tools": {
      "command": "node",
      "args": ["/Users/you/mcp-servers/ts-tools/dist/server.js"],
      "env": {
        "NODE_ENV": "production"
      }
    }
  }
}
GOTCHA — absolute paths in Claude Desktop
Unlike Claude Code (which resolves relative paths from the project root), Claude Desktop has no project context. Always use absolute paths in args. A path like "tools/server.py" will fail silently because the working directory is the Claude Desktop app bundle, not your home directory.

Debugging Claude Desktop MCP Issues

1
Check the MCP status indicator — in Claude Desktop, a lightning bolt icon appears in the bottom-left of the input box when at least one MCP server is connected. If it's missing after restart, no servers loaded.
2
Validate JSON syntax first — run python -m json.tool claude_desktop_config.json or paste into jsonlint.com. A single misplaced comma silently prevents all servers from loading.
3
Read the log file — Claude Desktop writes MCP stderr to ~/Library/Logs/Claude/mcp-server-<name>.log (macOS) or %APPDATA%\Claude\Logs\ (Windows). Server startup errors appear here.
4
Use MCP Inspector first — before connecting to Claude Desktop, run mcp dev server.py and confirm all tools load in the browser. If they don't work in Inspector, they won't work in Claude Desktop.

4. MCP Inspector Deep Dive

MCP InspectorMCP Inspector — an official browser-based debugging tool for MCP servers. It launches your server and provides a UI to call tools, browse resources, preview prompts, and watch raw JSON-RPC messages in real time. Think Postman, but specifically for MCP. is your first line of defense before connecting any server to a real LLM client. It lets you call every tool, fill arguments, and see the exact JSON-RPC response — all without writing a single line of client code.

Install and Launch

Terminal
npm install -g @modelcontextprotocol/inspector
# After install, two commands are available: # mcp dev — launch inspector + your server together (recommended) # mcp — raw CLI for scripted testing
Terminal — Launch Inspector with your server
mcp dev server.py
Starting MCP Inspector... Server process: python server.py Inspector UI: http://localhost:5173 Proxy: http://localhost:3000 Connected to server: my-tools v1.0.0 Tools discovered: 6 Resources discovered: 2 Prompts discovered: 1
Terminal — TypeScript server variant
mcp dev -- tsx server.ts
Starting MCP Inspector... Server process: tsx server.ts Inspector UI: http://localhost:5173
Animation 2 — MCP Inspector: 4 Panels Cycling with Mock Content
Tools Panel

The Four Panels

Tools
Lists every tool your server registered. Click a tool name to expand its input schema. Fill the form, hit Call, and see the raw JSON-RPCJSON-RPC — a lightweight remote procedure call protocol that uses JSON for encoding. MCP uses JSON-RPC 2.0 as its wire format. Each tool call is a tools/call request; the server returns a response with a content array. response body.
Resources
Shows resource URI templates your server exposed. Fill in URI variables (e.g. file:///path/to/{filename}), click Read, and see the resource text or blob content returned.
Prompts
Lists server-registered prompt templates. Fill in argument values, click Get, and preview the exact message array that would be injected into the LLM conversation — before any LLM ever sees it.
Logs
A real-time feed of every JSON-RPC message in both directions: client → server requests and server → client responses. Essential for diagnosing malformed tool schemas or serialization errors.
Workflow Tip

The Inspector proxy runs on port 3000 and your UI on port 5173. If you have other services on those ports, set custom ports: MCP_PROXY_PORT=3001 MCP_UI_PORT=5174 mcp dev server.py.

5. VS Code Integration — Three Options

VS Code does not natively support MCP as of mid-2025, but three popular AI extensions do. The good news: your MCP server code is completely identical regardless of which client you use. The only difference is the config file location and schema.

GitHub Copilot
Native MCP support added in VS Code 1.89+. Config in .vscode/settings.json or User settings. Only available in Copilot Chat mode.
Continue.dev
Continue.devContinue.dev — an open-source VS Code / JetBrains extension for AI-assisted coding. Supports chat, autocomplete, and MCP tool use. Config lives in ~/.continue/config.json. — open-source AI extension. Config in ~/.continue/config.json. Supports both stdio and HTTP MCP servers.
Cline
ClineCline — an autonomous AI coding agent extension for VS Code. Formerly known as Claude Dev. Supports MCP tool use alongside file editing and terminal access. — autonomous agent extension. MCP config managed through Cline's own settings panel or config file.

Option 1 — GitHub Copilot

WHAT
Add the github.copilot.chat.mcp.servers key to your VS Code settings.json. Open with Ctrl/Cmd + , → click the JSON icon top-right → add this block.
JSON — .vscode/settings.json (GitHub Copilot MCP)
{
  "github.copilot.chat.mcp.servers": {
    "personal-tools": {
      "type": "stdio",
      "command": "python",
      "args": ["${workspaceFolder}/tools/server.py"],
      "env": {
        "DB_URL": "sqlite:///local.db"
      }
    },
    "remote-tools": {
      "type": "sse",
      "url": "https://api.example.com/mcp/sse",
      "headers": {
        "Authorization": "Bearer ${env:MCP_TOKEN}"
      }
    }
  }
}
GOTCHA — Copilot uses "type" field + ${env:VAR}
Copilot's schema requires an explicit "type": "stdio" or "type": "sse" field (unlike Claude Code which infers type from the key presence). Env var interpolation uses ${env:VAR} syntax (not ${VAR}). Also note ${workspaceFolder} — a VS Code variable that resolves to the open project root.

Option 2 — Continue.dev

JSON — ~/.continue/config.json (Continue.dev MCP)
{
  "models": [
    {
      "title": "Claude 3.5 Sonnet",
      "provider": "anthropic",
      "model": "claude-sonnet-4-5",
      "apiKey": "${env:ANTHROPIC_API_KEY}"
    }
  ],
  "mcpServers": [
    {
      "name": "personal-tools",
      "command": "python",
      "args": ["/home/you/mcp-servers/personal/server.py"],
      "env": {
        "PROJECTS_ROOT": "/home/you/projects"
      }
    },
    {
      "name": "remote-tools",
      "url": "https://api.example.com/mcp/sse",
      "requestOptions": {
        "headers": {
          "Authorization": "Bearer ${env:MCP_TOKEN}"
        }
      }
    }
  ]
}
Continue.dev Note

Continue.dev's mcpServers is an array (not an object), and servers are identified by a "name" property instead of object keys. Everything else follows the same stdio/HTTP pattern.

Option 3 — Cline

JSON — Cline MCP config (via Cline sidebar → MCP Servers tab)
{
  "mcpServers": {
    "personal-tools": {
      "command": "python",
      "args": ["/home/you/mcp-servers/personal/server.py"],
      "env": {
        "PROJECTS_ROOT": "/home/you/projects"
      },
      "disabled": false,
      "alwaysAllow": ["run_tests", "search_codebase"]
    }
  }
}
WHAT — alwaysAllow
Cline adds an optional "alwaysAllow" array — tools listed here bypass Cline's per-call approval prompt. Use it for read-only tools you trust fully. Never add write or destructive tools to this list.
Key Takeaway

Your server.py or server.ts is completely unchanged across all three clients. The only difference is where you paste the config. Build one server, deploy to all three clients.

Now that you know where to point the config, let's build the server worth pointing at — a personal productivity server that makes Claude 10× more useful for daily coding work.

6. Build: Personal Productivity MCP Server

Practical Note

This personal productivity server takes 30 minutes to build and immediately makes Claude 10x more useful for daily dev work. Instead of describing your test failures, Claude reads them. Instead of copy-pasting git history, Claude fetches it. The delta between "Claude knowing about your context" and "Claude having direct access to your context" is enormous in practice.

The server exposes six tools, each with security guardrails:

run_tests
Runs pytest or jest in a specific directory and returns stdout + exit code
search_codebase
ripgrep wrapper — find patterns across files, with path restriction
read_docs
Fetches URL as plain text — docs, readmes, changelogs, man pages
get_git_log
Returns last N commit messages + hashes from the current repo
check_env
Lists env var names matching a pattern — never values, names only
run_sql
Read-only SQLite queries (SELECT only, no mutations)
WHAT — the full Python implementation
All 6 tools, path traversal protection, SELECT-only SQL enforcement, and environment variable redaction. Annotated in chunks below.
WHY — path traversal protection
Without it, Claude could ask search_codebase(path="../../etc/passwd") and your server would happily comply. The resolve() + relative_to() check ensures every path stays inside PROJECTS_ROOT.
Python — personal_server.py (complete)
#!/usr/bin/env python3
"""
Personal Productivity MCP Server — 6 hardened dev tools.
Configure in ~/.claude/settings.json or claude_desktop_config.json:

  "personal-tools": {
    "command": "python",
    "args": ["/absolute/path/to/personal_server.py"],
    "env": { "PROJECTS_ROOT": "/home/you/projects" }
  }
"""
import os
import re
import subprocess
import sqlite3
import urllib.request
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("personal-tools")

# ── Security baseline ──────────────────────────────────────────────────────
PROJECTS_ROOT = Path(os.environ.get("PROJECTS_ROOT", Path.home() / "projects")).resolve()
MAX_OUTPUT = 20_000  # chars — prevent context flooding

def _safe_path(raw: str) -> Path:
    """Resolve path and assert it stays inside PROJECTS_ROOT."""
    p = (PROJECTS_ROOT / raw).resolve()
    try:
        p.relative_to(PROJECTS_ROOT)  # raises ValueError if outside
        return p
    except ValueError:
        raise PermissionError(f"Path '{raw}' escapes PROJECTS_ROOT. Allowed root: {PROJECTS_ROOT}")

def _truncate(text: str) -> str:
    if len(text) > MAX_OUTPUT:
        return text[:MAX_OUTPUT] + f"\n\n[... truncated at {MAX_OUTPUT} chars ...]"
    return text

# ── Tool 1: run_tests ──────────────────────────────────────────────────────
@mcp.tool()
def run_tests(
    directory: str,
    framework: str = "pytest",
    extra_args: str = "",
) -> str:
    """
    Run pytest or jest in a project directory and return stdout + exit code.
    directory: path relative to PROJECTS_ROOT.
    framework: 'pytest' or 'jest'.
    extra_args: additional flags (e.g. '-k test_login' or '--testPathPattern=auth').
    """
    target = _safe_path(directory)
    if not target.is_dir():
        return f"ERROR: '{directory}' is not a directory inside PROJECTS_ROOT."

    if framework == "pytest":
        cmd = ["python", "-m", "pytest", "--tb=short", "-q"] + (extra_args.split() if extra_args else [])
    elif framework == "jest":
        cmd = ["npx", "jest", "--no-coverage"] + (extra_args.split() if extra_args else [])
    else:
        return f"ERROR: unknown framework '{framework}'. Use 'pytest' or 'jest'."

    try:
        result = subprocess.run(
            cmd,
            cwd=str(target),
            capture_output=True,
            text=True,
            timeout=120,
        )
        output = (result.stdout + result.stderr).strip()
        return _truncate(f"Exit code: {result.returncode}\n\n{output}")
    except subprocess.TimeoutExpired:
        return "ERROR: Test run timed out after 120 seconds."
    except FileNotFoundError as e:
        return f"ERROR: Command not found — {e}. Is {framework} installed?"

# ── Tool 2: search_codebase ────────────────────────────────────────────────
@mcp.tool()
def search_codebase(
    pattern: str,
    directory: str = ".",
    file_glob: str = "",
    max_results: int = 50,
) -> str:
    """
    Search files using ripgrep (rg). Returns matching lines with file:line context.
    pattern: regex or literal string to search.
    directory: path relative to PROJECTS_ROOT (default: PROJECTS_ROOT itself).
    file_glob: optional glob filter e.g. '*.py' or '**/*.ts'.
    max_results: cap on returned matches (default 50).
    """
    target = _safe_path(directory)
    cmd = ["rg", "--line-number", "--no-heading", f"--max-count={max_results}", pattern, str(target)]
    if file_glob:
        cmd += ["--glob", file_glob]

    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode == 1:  # rg exit 1 = no matches (not an error)
            return f"No matches for pattern '{pattern}' in '{directory}'."
        if result.returncode > 1:
            return f"ERROR running ripgrep: {result.stderr.strip()}"
        return _truncate(result.stdout.strip())
    except FileNotFoundError:
        return "ERROR: 'rg' (ripgrep) not found. Install: https://github.com/BurntSushi/ripgrep"
    except subprocess.TimeoutExpired:
        return "ERROR: Search timed out after 30 seconds."

# ── Tool 3: read_docs ──────────────────────────────────────────────────────
@mcp.tool()
def read_docs(url: str) -> str:
    """
    Fetch a URL and return its text content (HTML stripped to plain text).
    url: must start with http:// or https://.
    """
    if not url.startswith(("http://", "https://")):
        return "ERROR: URL must start with http:// or https://"

    try:
        req = urllib.request.Request(url, headers={"User-Agent": "mcp-personal-tools/1.0"})
        with urllib.request.urlopen(req, timeout=15) as resp:
            raw = resp.read().decode("utf-8", errors="replace")
        # Strip HTML tags crudely — good enough for docs
        text = re.sub(r"<[^>]+>", " ", raw)
        text = re.sub(r"\s{3,}", "\n\n", text).strip()
        return _truncate(text)
    except urllib.error.URLError as e:
        return f"ERROR fetching '{url}': {e}"
    except Exception as e:
        return f"ERROR: {type(e).__name__}: {e}"

# ── Tool 4: get_git_log ────────────────────────────────────────────────────
@mcp.tool()
def get_git_log(directory: str = ".", n: int = 20) -> str:
    """
    Return the last N git commit messages with hashes and authors.
    directory: path relative to PROJECTS_ROOT.
    n: number of commits to return (default 20, max 100).
    """
    target = _safe_path(directory)
    n = min(max(1, n), 100)  # clamp 1..100

    try:
        result = subprocess.run(
            ["git", "log", f"-{n}", "--pretty=format:%h %ae %s", "--no-merges"],
            cwd=str(target),
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode != 0:
            return f"ERROR: git log failed — {result.stderr.strip()}"
        return result.stdout.strip() or "No commits found."
    except FileNotFoundError:
        return "ERROR: git not found in PATH."
    except subprocess.TimeoutExpired:
        return "ERROR: git log timed out."

# ── Tool 5: check_env ─────────────────────────────────────────────────────
@mcp.tool()
def check_env(pattern: str = "") -> str:
    """
    List environment variable NAMES matching a pattern.
    Returns names only — never values. Safe to call freely.
    pattern: case-insensitive substring filter (empty = return all names).
    """
    names = sorted(os.environ.keys())
    if pattern:
        names = [n for n in names if pattern.upper() in n.upper()]
    if not names:
        return f"No environment variables match pattern '{pattern}'."
    return "\n".join(names) + f"\n\n({len(names)} variable(s) found — values intentionally hidden)"

# ── Tool 6: run_sql ───────────────────────────────────────────────────────
@mcp.tool()
def run_sql(db_path: str, query: str) -> str:
    """
    Run a read-only SQL query against a SQLite database file.
    db_path: path relative to PROJECTS_ROOT.
    query: must start with SELECT (no mutations allowed).
    """
    safe_db = _safe_path(db_path)
    if not safe_db.exists():
        return f"ERROR: Database '{db_path}' not found inside PROJECTS_ROOT."

    # Enforce read-only: allow SELECT and WITH (CTEs) only
    stripped = query.strip().upper()
    if not (stripped.startswith("SELECT") or stripped.startswith("WITH")):
        return "ERROR: Only SELECT queries are permitted. No INSERT/UPDATE/DELETE/DROP/CREATE."

    try:
        con = sqlite3.connect(f"file:{safe_db}?mode=ro", uri=True)
        con.row_factory = sqlite3.Row
        cursor = con.execute(query)
        rows = cursor.fetchmany(500)  # hard cap
        if not rows:
            return "Query returned 0 rows."
        headers = list(rows[0].keys())
        lines = [" | ".join(headers), "-" * 60]
        for row in rows:
            lines.append(" | ".join(str(v) for v in row))
        con.close()
        return _truncate("\n".join(lines))
    except sqlite3.OperationalError as e:
        return f"SQL ERROR: {e}"
    finally:
        try:
            con.close()
        except Exception:
            pass

if __name__ == "__main__":
    mcp.run()
TypeScript — personal_server.ts (complete)
#!/usr/bin/env tsx
/**
 * Personal Productivity MCP Server — 6 hardened dev tools (TypeScript).
 * Run: tsx personal_server.ts
 * Config: { "command": "tsx", "args": ["/abs/path/personal_server.ts"] }
 */
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { spawnSync, execSync } from "child_process";
import { existsSync, statSync } from "fs";
import path from "path";
import Database from "better-sqlite3";

const PROJECTS_ROOT = path.resolve(process.env.PROJECTS_ROOT ?? path.join(process.env.HOME ?? "~", "projects"));
const MAX_OUTPUT = 20_000;

// ── Security baseline ──────────────────────────────────────────────────────
function safePath(raw: string): string {
  const resolved = path.resolve(PROJECTS_ROOT, raw);
  if (!resolved.startsWith(PROJECTS_ROOT + path.sep) && resolved !== PROJECTS_ROOT) {
    throw new Error(`Path '${raw}' escapes PROJECTS_ROOT. Allowed: ${PROJECTS_ROOT}`);
  }
  return resolved;
}
function truncate(text: string): string {
  return text.length > MAX_OUTPUT ? text.slice(0, MAX_OUTPUT) + `\n\n[... truncated at ${MAX_OUTPUT} chars ...]` : text;
}

const server = new McpServer({ name: "personal-tools", version: "1.0.0" });

// ── Tool 1: run_tests ──────────────────────────────────────────────────────
server.tool("run_tests",
  "Run pytest or jest in a project directory and return stdout + exit code.",
  {
    directory: z.string().describe("Path relative to PROJECTS_ROOT"),
    framework: z.enum(["pytest", "jest"]).default("pytest"),
    extra_args: z.string().default("").describe("Additional CLI flags"),
  },
  async ({ directory, framework, extra_args }) => {
    const target = safePath(directory);
    if (!existsSync(target) || !statSync(target).isDirectory()) {
      return { content: [{ type: "text", text: `ERROR: '${directory}' is not a directory.` }] };
    }
    const extras = extra_args ? extra_args.split(" ") : [];
    const cmd = framework === "pytest"
      ? ["python", "-m", "pytest", "--tb=short", "-q", ...extras]
      : ["npx", "jest", "--no-coverage", ...extras];
    const result = spawnSync(cmd[0], cmd.slice(1), { cwd: target, encoding: "utf8", timeout: 120_000 });
    const output = ((result.stdout ?? "") + (result.stderr ?? "")).trim();
    return { content: [{ type: "text", text: truncate(`Exit code: ${result.status}\n\n${output}`) }] };
  }
);

// ── Tool 2: search_codebase ────────────────────────────────────────────────
server.tool("search_codebase",
  "Search files using ripgrep. Returns matching lines with file:line context.",
  {
    pattern: z.string().describe("Regex or literal string"),
    directory: z.string().default(".").describe("Path relative to PROJECTS_ROOT"),
    file_glob: z.string().default("").describe("e.g. '*.ts' or '**/*.py'"),
    max_results: z.number().int().min(1).max(500).default(50),
  },
  async ({ pattern, directory, file_glob, max_results }) => {
    const target = safePath(directory);
    const cmd = ["rg", "--line-number", "--no-heading", `--max-count=${max_results}`, pattern, target];
    if (file_glob) cmd.push("--glob", file_glob);
    const result = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8", timeout: 30_000 });
    if (result.status === 1) return { content: [{ type: "text", text: `No matches for '${pattern}'.` }] };
    if ((result.status ?? 0) > 1) return { content: [{ type: "text", text: `ERROR: ${result.stderr}` }] };
    return { content: [{ type: "text", text: truncate(result.stdout.trim()) }] };
  }
);

// ── Tool 3: read_docs ──────────────────────────────────────────────────────
server.tool("read_docs",
  "Fetch a URL and return plain text content.",
  { url: z.string().url().describe("Must be http:// or https://") },
  async ({ url }) => {
    try {
      const res = await fetch(url, { headers: { "User-Agent": "mcp-personal-tools/1.0" }, signal: AbortSignal.timeout(15_000) });
      const raw = await res.text();
      const text = raw.replace(/<[^>]+>/g, " ").replace(/\s{3,}/g, "\n\n").trim();
      return { content: [{ type: "text", text: truncate(text) }] };
    } catch (e) {
      return { content: [{ type: "text", text: `ERROR fetching '${url}': ${e}` }] };
    }
  }
);

// ── Tool 4: get_git_log ────────────────────────────────────────────────────
server.tool("get_git_log",
  "Return the last N git commit messages with hashes and authors.",
  {
    directory: z.string().default("."),
    n: z.number().int().min(1).max(100).default(20),
  },
  async ({ directory, n }) => {
    const target = safePath(directory);
    const result = spawnSync("git", ["log", `-${n}`, "--pretty=format:%h %ae %s", "--no-merges"], {
      cwd: target, encoding: "utf8", timeout: 10_000,
    });
    if (result.status !== 0) return { content: [{ type: "text", text: `ERROR: ${result.stderr}` }] };
    return { content: [{ type: "text", text: result.stdout.trim() || "No commits found." }] };
  }
);

// ── Tool 5: check_env ─────────────────────────────────────────────────────
server.tool("check_env",
  "List environment variable NAMES matching a pattern. Returns names only — never values.",
  { pattern: z.string().default("").describe("Case-insensitive substring filter") },
  async ({ pattern }) => {
    let names = Object.keys(process.env).sort();
    if (pattern) names = names.filter(n => n.toUpperCase().includes(pattern.toUpperCase()));
    if (!names.length) return { content: [{ type: "text", text: `No env vars match '${pattern}'.` }] };
    return { content: [{ type: "text", text: names.join("\n") + `\n\n(${names.length} variable(s) — values intentionally hidden)` }] };
  }
);

// ── Tool 6: run_sql ───────────────────────────────────────────────────────
server.tool("run_sql",
  "Run a read-only SQL query against a SQLite database. SELECT only.",
  {
    db_path: z.string().describe("Path relative to PROJECTS_ROOT"),
    query: z.string().describe("SELECT query only"),
  },
  async ({ db_path, query }) => {
    const safeDb = safePath(db_path);
    if (!existsSync(safeDb)) return { content: [{ type: "text", text: `ERROR: '${db_path}' not found.` }] };
    const stripped = query.trim().toUpperCase();
    if (!stripped.startsWith("SELECT") && !stripped.startsWith("WITH")) {
      return { content: [{ type: "text", text: "ERROR: Only SELECT/WITH queries are allowed." }] };
    }
    try {
      const db = new Database(safeDb, { readonly: true });
      const rows = db.prepare(query).all() as Record<string, unknown>[];
      if (!rows.length) return { content: [{ type: "text", text: "Query returned 0 rows." }] };
      const headers = Object.keys(rows[0]);
      const lines = [headers.join(" | "), "-".repeat(60), ...rows.slice(0, 500).map(r => headers.map(h => String(r[h])).join(" | "))];
      db.close();
      return { content: [{ type: "text", text: truncate(lines.join("\n")) }] };
    } catch (e) {
      return { content: [{ type: "text", text: `SQL ERROR: ${e}` }] };
    }
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
What Just Happened?
  • path traversal blocked — every tool resolves paths through _safe_path() / safePath(), which calls resolve() and checks the result starts with PROJECTS_ROOT. Path tricks like ../../../etc raise PermissionError.
  • output capped — a 20,000-character truncation prevents a single tool call from flooding Claude's context window with a 500KB test log.
  • env values hiddencheck_env returns only key names, never values. Claude knows STRIPE_SECRET_KEY exists; it doesn't learn its value.
  • SQL read-only — SQLite is opened in mode=ro URI mode (Python) / readonly: true (TypeScript). Even if a SELECT query somehow triggered a write, the connection would reject it at the driver level.

7. Debugging Common MCP Problems

MCP failures are almost always configuration or environment issues, not server code bugs. Here are the five most common problems with exact diagnostic steps.

Problem 1 — Server not appearing in client

Symptom

No tools appear in Claude's tool menu. No lightning bolt icon (Claude Desktop).

1
Validate JSON syntax — run python -m json.tool .claude/settings.json. A single trailing comma or unclosed brace prevents the entire mcpServers block from parsing.
2
Test with MCP Inspector — run mcp dev server.py. If it fails here, the problem is in your server code, not the client config.
3
Check restart — Claude Desktop requires a full quit/relaunch. Claude Code requires running /mcp restart or reopening the project.

Problem 2 — Tools not showing after server connects

1
Check the initialize response in Inspector Logs panel — the initialize response must include a tools key with at least one entry. If it's empty, your server registered no tools (a decorator typo or import error before registration).
2
Look for startup exceptions — in Python, an exception during import silently kills the server before any tool is registered. Run python server.py directly and check stderr.

Problem 3 — Tool call failing at runtime

1
Reproduce in MCP Inspector — go to the Tools panel, find the failing tool, fill in the same arguments Claude used, and click Call. The Inspector Logs panel shows the full error response and server stderr.
2
Check working directory — a stdio server's working directory is wherever Claude Code was launched from. Relative paths in your server code resolve from there, not from the script location.

Problem 4 — Environment variables not loading

1
Verify ${VAR} interpolation syntax — Claude Code uses ${VAR} in settings.json headers; VS Code Copilot uses ${env:VAR}. Mixing these passes the literal string to the server.
2
Use the env block for subprocess vars — env vars in the "env" block are injected directly into the subprocess. Vars in your shell are NOT automatically inherited. Add them explicitly.

Problem 5 — Server needs restart after code changes

Claude Code — restart a specific MCP server
/mcp restart personal-tools
Restarting MCP server: personal-tools Stopped process (PID 12345) Starting: python /home/you/mcp-servers/personal/server.py Server ready. Tools: 6 discovered.
Quick Diagnostic Checklist
  1. JSON valid? → python -m json.tool settings.json
  2. Server runs standalone? → python server.py (should block, no errors)
  3. Tools load in Inspector? → mcp dev server.py
  4. Correct config key for your client? (Claude Code vs Copilot vs Continue.dev)
  5. Restart performed? → /mcp restart or full app quit

8. Quiz

Five questions covering configuration, debugging, and the personal server. Immediate feedback on every answer.

1. In .claude/settings.json, you add "Authorization": "Bearer ${MCP_TOKEN}" to a remote server's headers. You start Claude Code and the remote server returns 401 Unauthorized. What is the most likely cause?

A
The ${MCP_TOKEN} syntax is not supported in headers — you must hardcode the value.
B
The MCP_TOKEN environment variable was not exported in your shell before launching Claude Code, so the literal string "Bearer ${MCP_TOKEN}" was sent.
C
Remote servers do not support custom headers in Claude Code.
D
The server URL must include the token as a query parameter instead.
Correct. Variable interpolation happens at Claude Code startup from your shell environment. If MCP_TOKEN is not set at that moment, the literal placeholder is forwarded and the server receives an invalid bearer token.
Not quite. The ${VAR} syntax is supported in headers, but the variable must be exported in your shell environment before launching Claude Code. If it isn't set, the literal placeholder string is passed through.

2. You edit claude_desktop_config.json on macOS to add a new MCP server. After saving, you click the Reload Window option inside Claude Desktop. The server still does not appear. Why?

A
macOS requires you to run defaults import to apply config changes.
B
The config file must be placed in ~/Library/Preferences/, not Application Support.
C
Claude Desktop reads the config file only at application startup. You must fully quit and relaunch the app, not just reload the window.
D
The server command must be added to the macOS PATH before Claude Desktop can find it.
Correct. Claude Desktop is a desktop app that reads the config at cold-start time. Reloading the window refreshes the UI but does not re-read configuration or restart MCP server processes. Use Quit → relaunch.
Not quite. The problem is simpler: Claude Desktop reads its config file only at application startup. A window reload does not trigger a config re-read or server restart.

3. In the personal productivity server, why does check_env return only variable names and never values?

A
MCP tools cannot return more than 256 characters, so values would be truncated anyway.
B
Environment variables are encrypted by the OS and not accessible to Python subprocesses.
C
To prevent secrets (API keys, passwords, tokens) from appearing in Claude's context window and potentially in conversation history or logs.
D
Values are returned separately via a secure resource URI, not as tool output.
Correct. Env vars commonly contain secrets — API keys, database passwords, OAuth tokens. If values were returned, they would appear in Claude's context and could be included in conversation exports, logs, or accidental screenshots. Names are safe; values are not.
Not quite. The reason is a security design choice: env var values commonly contain secrets. Exposing them in Claude's context risks leaking them into conversation logs or history.

4. You have a working MCP server tested with MCP Inspector. You add it to GitHub Copilot's config in .vscode/settings.json but tools don't appear. You check: the JSON is valid and you've reloaded VS Code. What should you check next?

A
Whether you included the required "type": "stdio" (or "type": "sse") field — Copilot's schema requires an explicit type field unlike Claude Code.
B
Whether you're using Python 3.10 or later, as Copilot requires a minimum Python version.
C
Whether you registered the server in the Copilot marketplace before local use.
D
Whether you added "mcp": true to the server config block.
Correct. GitHub Copilot's MCP schema requires an explicit "type" field set to "stdio" or "sse". Claude Code infers the type from which keys are present (command vs url), but Copilot does not. Missing this field causes the server entry to be silently ignored.
Not quite. The key difference between Copilot's config schema and Claude Code's is that Copilot requires an explicit "type": "stdio" or "type": "sse" field. Claude Code infers transport type from the keys present.

5. A teammate runs your personal server and reports that run_sql raises an error even for a simple SELECT * FROM users LIMIT 5. You know the database path is correct. What is the most likely security check failing?

A
The query length exceeds the 20,000-character limit set by the truncate function.
B
The MCP protocol does not support binary return types needed for SQLite blobs.
C
The database file path resolves outside PROJECTS_ROOT on your teammate's machine (their PROJECTS_ROOT env var points to a different directory than yours), so _safe_path() raises a PermissionError.
D
SQLite read-only mode requires the database to be owned by the current user.
Correct. The _safe_path() check compares the resolved absolute path against PROJECTS_ROOT. If your teammate's PROJECTS_ROOT env var is set differently (or not set, defaulting to ~/projects), a database path that worked on your machine resolves to a path outside their allowed root and is blocked.
Think about the path traversal protection. The _safe_path() function checks that the resolved database path is inside PROJECTS_ROOT. If the teammate's PROJECTS_ROOT env var differs, a path valid on your machine may resolve outside their allowed root.