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.
- ✓Write correct
.claude/settings.jsonconfigs for both stdio and HTTP MCP servers - ✓Locate and edit
claude_desktop_config.jsonon 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
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.
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.
.claude/settings.json (project-scoped) or ~/.claude/settings.json (global, applies to every project).
{
"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}"
}
}
}
}
"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.
${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.
a query
reads settings.json
subprocess
negotiated
in chat
stdio Server Examples
"command": "python" config above.
#!/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()
#!/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
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
"command" to "url".
#!/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)
#!/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"));
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.
| OS | Config 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 |
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
{
"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"
}
}
}
}
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
python -m json.tool claude_desktop_config.json or paste into jsonlint.com. A single misplaced comma silently prevents all servers from loading.~/Library/Logs/Claude/mcp-server-<name>.log (macOS) or %APPDATA%\Claude\Logs\ (Windows). Server startup errors appear here.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
npm install -g @modelcontextprotocol/inspectormcp dev server.pymcp dev -- tsx server.tsThe Four Panels
tools/call request; the server returns a response with a content array. response body.file:///path/to/{filename}), click Read, and see the resource text or blob content returned.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.
.vscode/settings.json or User settings. Only available in Copilot Chat mode.~/.continue/config.json. — open-source AI extension. Config in ~/.continue/config.json. Supports both stdio and HTTP MCP servers.Option 1 — GitHub Copilot
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.
{
"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}"
}
}
}
}
"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
{
"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'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
{
"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"]
}
}
}
"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.
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.
6. Build: Personal Productivity MCP Server
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:
search_codebase(path="../../etc/passwd") and your server would happily comply. The resolve() + relative_to() check ensures every path stays inside PROJECTS_ROOT.
#!/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()
#!/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);
- path traversal blocked — every tool resolves paths through
_safe_path()/safePath(), which callsresolve()and checks the result starts withPROJECTS_ROOT. Path tricks like../../../etcraisePermissionError. - 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 hidden —
check_envreturns only key names, never values. Claude knowsSTRIPE_SECRET_KEYexists; it doesn't learn its value. - SQL read-only — SQLite is opened in
mode=roURI 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
No tools appear in Claude's tool menu. No lightning bolt icon (Claude Desktop).
python -m json.tool .claude/settings.json. A single trailing comma or unclosed brace prevents the entire mcpServers block from parsing.mcp dev server.py. If it fails here, the problem is in your server code, not the client config./mcp restart or reopening the project.Problem 2 — Tools not showing after server connects
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).python server.py directly and check stderr.Problem 3 — Tool call failing at runtime
Problem 4 — Environment variables not loading
${VAR} in settings.json headers; VS Code Copilot uses ${env:VAR}. Mixing these passes the literal string to the server."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
/mcp restart personal-tools- JSON valid? →
python -m json.tool settings.json - Server runs standalone? →
python server.py(should block, no errors) - Tools load in Inspector? →
mcp dev server.py - Correct config key for your client? (Claude Code vs Copilot vs Continue.dev)
- Restart performed? →
/mcp restartor 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?
${MCP_TOKEN} syntax is not supported in headers — you must hardcode the value.MCP_TOKEN environment variable was not exported in your shell before launching Claude Code, so the literal string "Bearer ${MCP_TOKEN}" was sent.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?
defaults import to apply config changes.~/Library/Preferences/, not Application Support.3. In the personal productivity server, why does check_env return only variable names and never values?
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?
"type": "stdio" (or "type": "sse") field — Copilot's schema requires an explicit type field unlike Claude Code."mcp": true to the server config block."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?
_safe_path() raises a PermissionError._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._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.