Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 16: IDEs & Developer Tools

MCP feels especially natural inside developer tools because the working context is already structured: repositories, terminals, test suites, issue trackers, documentation, and local runtimes all sit within reach. A coding assistant with tools for these surfaces can search a codebase, run tests, file issues, query a staging database, and commit code in a single conversation.

That proximity to power is also what makes the developer-tool surface the most dangerous MCP integration you will build. A weak tool in a coding client can mutate a repository, execute shell commands against the wrong directory, expose secrets from the local machine, run an expensive CI pipeline in a loop, or silently overwrite files the user was not expecting to change. Every design decision in this chapter exists to get the productivity benefits without the expensive mistakes.

This chapter’s rule: assume the user has turned on auto-approve, and make every tool safe to run unattended; the prompt the editor may or may not show is not your safety mechanism.

Currency note. Editor behaviour in this chapter (config file locations, supported primitives, approval modes) was checked against each vendor’s documentation on 2026-09-27. Editors ship MCP changes frequently. The linked docs are the source of truth for those facts; the design decisions here do not depend on them.

What Makes Developer-Tool Clients Different

Chat clients put tools beside a conversation. Developer-tool clients (IDEs, coding agents, terminal assistants) put them inside the user’s working context, which changes what matters.

Project roots. The user’s mental model is “this conversation is about this repository.” A server that scopes file access, search and mutation to the project root feels natural. One that reaches across the filesystem feels hazardous, and is.

Native knowledge. The editor already has symbol indices, type information and diagnostics. A server that duplicates them adds noise. A server that reaches systems the editor can’t (CI, the issue tracker, a staging database) adds capability.

Command execution. Running tests, builds and scripts is the most useful thing a coding agent does and the easiest way to cause damage: the wrong command, in the wrong directory, with side effects that are hard to undo.

Long-running work. Test suites, builds and migrations take minutes. Clients that support Tasks (since 2026-07-28 an optional extension, io.modelcontextprotocol/tasks) or their own background execution handle them. Clients that expect sub-second answers time out.

Approval behaviour. Some clients confirm every call; some offer an auto-approve or auto-run mode that skips confirmation. Your server has to be safe in the second kind, and its descriptions have to be true in both. A description written for one mode misleads the model in the other:

# Before: true only when the editor prompts
@mcp.tool()
def delete_branch(name: str) -> dict:
    """Delete a git branch. The user will be asked to confirm before anything happens."""

# After: true in every mode, because the server does the confirming
@mcp.tool(annotations=ToolAnnotations(title="Delete a branch", destructiveHint=True))
def delete_branch(name: str, confirm: bool = False) -> dict:
    """Delete a local git branch that is fully merged into main. Without confirm=true,
    returns the branch's unmerged commits (if any) and deletes nothing."""

The first description promises a safety step the server doesn’t own. In an editor that prompts, it happens to be true. In auto-run mode the model reads “the user will be asked” as permission to call freely, and the branch is gone before anyone sees it. The second describes only what the server itself does, so it stays accurate whichever mode the user picked. Never describe the host’s behaviour in a tool description: you don’t know which host, or which mode, will read it.

Designing for the Working Set

A coding session collects tools fast: shell-like tools, overlapping file tools, issue and CI integrations, a database tool, a docs search. The model sees sixty tools and has to pick three. Past the Part V opener’s ~20-tool line, measure selection; past ~40, split or defer. In an editor, the count that matters is every connected server’s tools plus the editor’s own built-ins.

Four rules keep the working set small and each tool safe:

One server per system. A server for your CI pipeline, another for your issue tracker, another for the staging database. One server per domain, not one per team.

Reads and writes are separate, and named so. search_issues and create_issue; list_files and edit_file; query_staging and run_migration. The model and the approval UI should never have to guess which one mutates.

Don’t duplicate the editor. If the editor has file search, symbol navigation and diagnostics, your server doesn’t need them. A second find_symbol costs context and splits the model’s attention between two tools that do the same thing.

Give the model a constrained tool, never a shell. A run_tests tool with a fixed command is safer and more predictable than a run_shell_command tool that accepts arbitrary input. Here is what “constrained” means in code, in the style of Chapter 5’s server:

import os, re, subprocess
from pathlib import Path
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

mcp = MCPServer("devtools")
PROJECT_ROOT = Path(os.environ["DEVTOOLS_PROJECT_ROOT"]).resolve()  # from config, never cwd; KeyError = refuse to start
SECRET = re.compile(r"(?i)(api[_-]?key|token|secret|password)=\S+|https://[^\s:@]+:[^\s@]+@")
MAX_CHARS = 8_000

@mcp.tool(annotations=ToolAnnotations(title="Run tests", readOnlyHint=False,
                                      destructiveHint=False, idempotentHint=True))
def run_tests(test_path: str = "tests") -> dict:
    """Run pytest on one path inside the project. Returns pass/fail and the last lines of output."""
    target = (PROJECT_ROOT / test_path).resolve()
    if not target.is_relative_to(PROJECT_ROOT):
        raise ToolError(f"test_path must be inside the project; got '{test_path}'.")
    argv = ["python", "-m", "pytest", "-q", str(target)]          # a list, never a shell string
    try:
        proc = subprocess.run(argv, cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=300)
    except subprocess.TimeoutExpired:
        raise ToolError("Tests exceeded 300 s and were stopped. Run a narrower test_path.")
    output = SECRET.sub("[REDACTED]", proc.stdout + proc.stderr)
    return {"passed": proc.returncode == 0, "exit_code": proc.returncode,
            "output": output[-MAX_CHARS:], "truncated": len(output) > MAX_CHARS}

Each guard answers a specific failure:

  • An argv list, not a shell string, so a test_path of tests; rm -rf ~ is a path pytest can’t find, not two commands.
  • The root comes from configuration, not the working directory. Clients start servers in different directories, and Path.cwd() in one editor is the user’s home in another. is_relative_to stops ../../ from walking out.
  • A timeout, because a hung test blocks the conversation and, in an agent loop, gets retried.
  • Truncation from the end, because the failure summary is at the bottom of pytest’s output and the model needs it more than the dots.
  • Redaction, because test output echoes environment variables and connection strings, and coding conversations get logged, shared and pasted into issues.

The annotation says readOnlyHint=False (tests write caches and reports) and destructiveHint=False (nothing a user would miss). That is honest, and it means an auto-approving editor will run it, which is fine, because the tool is safe to run.

Take away the shortcut. A constrained tool only helps if the model can’t route around it. When the editor’s own terminal tool is enabled, a model that finds run_tests too narrow will run pytest (or anything else) through the terminal instead. Any client with per-tool toggles lets you close that door. Zed’s agent profiles are the clearest example: a profile names exactly which built-in tools and which server tools a thread may use. This one keeps the editor’s read tools, removes the terminal and the file-editing tools, and exposes two tools from each server:

"agent": {
  "profiles": {
    "test-and-notes": {
      "name": "Test and notes",
      "tools": {
        "read_file": true, "grep": true, "find_path": true, "diagnostics": true,
        "terminal": false, "edit_file": false, "delete_path": false
      },
      "enable_all_context_servers": false,
      "context_servers": {
        "devtools": { "tools": { "run_tests": true } },
        "notes":    { "tools": { "search_notes": true, "create_note": true } }
      }
    }
  }
}

In that thread, the only way to run a test is run_tests, with its root, timeout and redaction, and the only way to touch notes is to search or add one; delete_note isn’t offered at all. VS Code’s tool picker and Cursor’s per-tool toggles do the same job with less precision. Where the client supports it, ship the recommended profile or tool set alongside the server, and name it after the task, not the server.

The Server Is the Backstop

Cursor’s auto-run mode, Claude Code’s permissive permission modes and VS Code’s auto-approve all do the same thing: the client stops asking. At that point, your annotation is the last line the client checks, and annotations are hints (Chapter 6). Back every destructive tool with something the model can’t skip:

  • A read-only credential by default. query_staging connects as a role with SELECT only. The write path is a different tool with a different credential.
  • A confirm argument the tool refuses without. run_migration(name, confirm=False) returns the plan and the SQL it would run; only confirm=True executes it. The model has to make two calls, and the first one puts the plan in front of the user.
  • A dry-run default. deploy_preview builds and reports; deploy_preview(apply=True) ships.

The second pattern, on the same devtools server:

@mcp.tool(annotations=ToolAnnotations(title="Run a staging migration",
                                      destructiveHint=True, idempotentHint=False))
def run_migration(name: str, confirm: bool = False) -> dict:
    """Apply one migration to STAGING. Without confirm=true, returns the SQL and changes nothing."""
    migration = load_migration(PROJECT_ROOT / "migrations", name)   # ToolError if unknown
    if not confirm:
        return {"applied": False, "migration": name, "sql": migration.sql,
                "next": "Show this SQL to the user. Call again with confirm=true to apply."}
    with connect(os.environ["STAGING_MIGRATOR_DSN"]) as db:          # the only write credential
        db.execute(migration.sql)
    return {"applied": True, "migration": name}

Only the confirmed branch opens a connection with the migrator credential; every other tool on the server uses the read-only role. The next field is written for the model: it says what to do with the result, which is more reliable than hoping the description was read. Be clear about what confirm buys. A model can pass confirm=true on its first call, so the flag doesn’t stop a determined agent; it makes the write a separate, explicit, logged step whose SQL was on screen first in the normal case. The credential split is the part that holds regardless. None of these patterns rely on the client, and all of them still work when the user clicks “always allow.”

What Each Editor Changes

Rather than a feature matrix that would be stale within a release cycle, here is what each client changes for a server author. The opener’s capability table covers which primitives each supports.

VS Code with GitHub Copilot

VS Code supports tools, resources, prompts and MCP Apps, configured in a workspace .vscode/mcp.json or a user-profile mcp.json. It has an MCP server gallery in the Extensions view (search @mcp) and can reuse configurations from other apps such as Claude Desktop.

The detail that changes server design: with remote development, workspace-config servers run on the remote host and user-profile servers run locally. A server that reads local files or credentials behaves differently depending on which file configured it, over SSH, in a Dev Container or under WSL. Document which one your server expects.

Cursor

Cursor supports tools, prompts, resources, roots, elicitation and MCP Apps, over stdio, SSE and Streamable HTTP, configured in mcp.json with an mcpServers key. It accepts static OAuth client credentials in the config, which helps with providers that require registered redirect URLs; test your OAuth flow in Cursor specifically. Cursor has its own marketplace and the community cursor.directory for discovery.

Cursor’s agent mode calls tools in a loop, and its auto-run mode drops per-call confirmation. That is the case the backstop section is for.

Zed

Zed supports tools and prompts, installed as extensions or configured as custom servers, including remote servers with OAuth. It handles notifications/tools/list_changed, so your server can add or remove tools without a restart. Its profiles are the working-set tool described above.

Claude Code

Claude Code’s MCP setup, scopes and limits are in Chapter 14. One point is specific to it as a developer tool: it runs in a terminal, so confirmations and results appear inline, and a 2,000-line listing scrolls the conversation away. Return bounded, scannable output (the run_tests tail, not the full log).

JetBrains IDEs

JetBrains IDEs add MCP through AI Assistant. You paste the same JSON server definition other clients use into the IDE’s MCP settings (there is no project mcp.json), or import a Claude Desktop configuration. If your users write Java or Kotlin, add one JetBrains IDE to your test hosts.

Native Extension or MCP Server?

Every editor also has a native extension system, which makes the integration choice strategic.

Use MCP when portability matters (one server works in VS Code, Cursor, Zed and Claude Code), when the capability is tool or data access, or when the logic is a service rather than a UI component.

Use a native extension when the value is deep UI (panels, decorations, specialised views) that MCP Apps can’t express, or when the capability is a low-level editor API: AST manipulation, language-server features, the debugger.

Use both when you need portable tools and rich editor UI. The extension provides the UI and calls the MCP server underneath; other clients still get the server.

One .mcp.json for the Whole Team

A configuration file committed to the repository makes the project’s tools reviewable. A new team member reads it and knows which external systems the agent can touch. When something goes wrong (a tool edited the wrong file, a query hit the wrong environment), the file’s history tells the incident review exactly which servers and tools were available that day.

The formats have converged enough for one file. Claude Code reads a project-root .mcp.json, and VS Code reads the same portable file alongside its own .vscode/mcp.json. Cursor reads .cursor/mcp.json in the same mcpServers shape, so the entries copy across unchanged:

{
  "mcpServers": {
    "notes": {
      "url": "https://notes.example.com/mcp"
    },
    "devtools": {
      "command": "devtools-server"
    }
  }
}

Variable interpolation is where the formats still differ (Claude Code expands ${VAR}, Cursor ${env:NAME}), so the portable file uses none. The remote notes server is a URL; each developer signs in through the client’s OAuth flow, so no credential is in the file. The local devtools server is installed once with uv tool install, reads DEVTOOLS_PROJECT_ROOT from the developer’s environment, and refuses to start without it rather than guessing from the working directory. Nothing secret is committed, and a reviewer can see in one diff when someone adds a server that can write.

Failure Modes

Catalog bloat in coding sessions. Three or four servers at 10–15 tools each, plus the editor’s built-ins, and the model is past the opener’s split line with overlapping descriptions. Wrong tools get called, or none. Count across all connected servers and prune ruthlessly.

Working directory confusion. The server assumes the process’s working directory is the project root; the client started it elsewhere; commands run in the wrong place. Resolve the root from configuration, as run_tests does.

Secret leakage through tool responses. A command’s output includes an API key from the environment or a credential in a URL, and the conversation is logged or shared. Redact before returning.

Auto-approve and destructive tools. The user turns on auto-approve; a destructive tool with no server-side backstop runs unattended. The annotation was correct, and it didn’t matter.

The model routes around the constrained tool. run_tests is narrow; the built-in terminal is enabled; the model runs pytest --forked -x ... in the shell and the guards never fire. Ship a profile or tool set that disables the shortcut.

Stale tool lists after server updates. You rename a tool; the client cached the old list; the model calls the old name and gets an error. Clients that handle list_changed recover. For the rest, keep the old name as a deprecated alias with a dated sunset in its description (for example, “Deprecated: use run_tests. Removed 2027-03-31.”).

Resource blindness in tools-only clients. Your server exposes the repo’s architecture docs as resources; the target client doesn’t show resources; the model never sees them. Anything the model needs must also be reachable through a tool.

Implementation Checklist

  1. List your target clients and check each against the opener’s capability table.
  2. Count every tool the session will see, including built-ins, against the ~20 and ~40 lines.
  3. Name reads and writes so the difference is obvious, and annotate every tool honestly.
  4. Give every destructive tool a server-side backstop: read-only credentials, a confirm argument or a dry-run default.
  5. Build command tools the way run_tests is built: argv, configured root, timeout, truncation, redaction.
  6. Commit a .mcp.json with no secrets in it.
  7. Ship a recommended profile or tool set that disables built-in tools your server replaces.
  8. Test with realistic multi-step tasks (“find and fix the failing auth test”) in at least two editors.

What Comes Next

Every rule in this chapter assumed someone else’s client: an editor you can configure but not change, whose approval mode your server has to survive. The next chapter drops that assumption. When no existing host fits (a support console, a mobile app, an internal workflow tool), you build the client yourself, and the approval UX, auth and trust decisions that editors made for you become yours.