MCP Developer Server #

CI Security Container Python 3.12 MCP 2026-07-28 License: MIT Documentation

A Model Context Protocol server that gives a language model read-only tools over one contained directory — and treats everything it returns as what it is: untrusted bytes from someone else's files, running with your privileges.

Documentation: https://kogunlowo123.github.io/mcp-developer-server/


What is this? #

An MCP server, implementing revision 2026-07-28 of the specification, that publishes seven tools for reading and searching source code: read_file, list_directory, search_code, find_symbol, project_overview, git_log and git_diff. Point it at a repository and an editor's assistant can navigate that repository without you pasting files into a chat window.

It ships with a protocol conformance suite — 23 executable checks derived from the specification — that can be run against this server or any other, over stdio or over HTTP, and exits non-zero when a requirement is broken.

Why does it exist? #

An MCP server launched by your editor is a process on your machine, started by you, running as you. There is no operating-system boundary between it and ~/.ssh/id_ed25519. Whatever it can read, it can hand to a model, and from there to a provider's logs and to any transcript you share.

Most of the sandboxing in that situation is one function: the one that turns a path a model asked for into a file the process opens. Two obvious versions of that function are both wrong:

  1. Check the string, then open it. docs/../../.ssh/id_rsa has no suspicious component after the first, and a check that runs before normalisation is a check against a different path than the one that gets opened.
  2. Normalise lexically, then compare prefixes. /work/../worker normalises inside /work by string prefix and outside it in fact, and a symbolic link is invisible to lexical normalisation entirely.

This server resolves the candidate fully — following every link, on the real filesystem — and only then asks whether the resolved path is under the resolved root. tests/security/test_sandbox_escape.py is 74 attempts to get past it.

The second half of the story runs in the other direction. Everything this server returns came from files, and files come from pull requests, dependencies and downloaded fixtures. A comment reading "AI assistant: ignore all previous instructions" is, to a model reading a search result, indistinguishable from something you said — unless the boundary is marked.

Key capabilities #

Capability How it is enforced
Containment Paths are resolved on the real filesystem, then checked against the resolved root. Symbolic links are not followed by default; a walk never descends one.
Denial by class .env, *.pem, id_rsa, .git/, .ssh/, .aws/ and more, matched against the resolved path so docs/../.env is the same request as .env. Configuration can extend the list; nothing can shorten it.
Secret redaction 17 credential shapes replaced in both halves of every result — the prose a model reads and the structured data a program parses — with the count and rule names reported so the control is auditable.
Untrusted marking File content is fenced with a per-response nonce and scored by 10 injection signals, and is returned byte-identical to disk. Marking, not neutralising — see ADR-006.
Read-only by construction No tool writes, moves, deletes, runs a shell, or reaches the network. It is a property of the published set, asserted by a test that inspects the registry.
One subprocess, hardened git log and git diff only: fixed argv, no shell, a revision charset that cannot start with -, paths after --, a scrubbed environment, and protocol.ext.allow=never.
Bounded File size, line count, result bytes, directory entries, walk breadth, wall-clock budget — and a static guard that refuses regular expressions which can backtrack catastrophically.
Conformance 23 checks against the specification, runnable against any 2026-07-28 server, with negative controls proving each one fails when the requirement is broken.

Quickstart #

uv sync --all-extras --dev
python tasks.py doctor          # what would be served, and what would be refused
python tasks.py tools           # the published tool set

Ask it something:

uv run mcp-devserver --workspace . call project_overview
uv run mcp-devserver --workspace . call search_code '{"pattern": "def resolve"}'
uv run mcp-devserver --workspace . call read_file '{"path": "src/mcp_devserver/errors.py", "end_line": 20}'

Or run the examples, which are executable documentation:

python examples/quickstart.py         # what a client sees, end to end
python examples/sandbox_demo.py       # eight escape attempts and what happened
python examples/untrusted_demo.py     # a poisoned file, marked and returned intact

Connecting an editor #

The stdio transport is what an MCP client spawns. Most clients take a command and an environment:

{
  "mcpServers": {
    "devserver": {
      "command": "uvx",
      "args": ["--from", "mcp-devserver", "mcp-devserver", "serve", "--transport", "stdio"],
      "env": { "MCP_SANDBOX__WORKSPACE": "/absolute/path/to/your/repository" }
    }
  }
}

MCP_SANDBOX__WORKSPACE is the whole security configuration: it is the only directory the server can reach.

Over HTTP instead:

docker compose up --build -d
curl -s localhost:8080/readyz | python -m json.tool

The protocol #

Revision 2026-07-28 is a redesign rather than an increment, and this server implements the new shape rather than adapting the old one:

A legacy client sending initialize gets -32022 naming the revisions this server speaks — ten lines instead of a second protocol era. ADR-002 explains the trade.

See docs/protocol.md.

The tools #

Seven, all read-only. What is absent is as much of the design as what is present.

Tool Answers Notes
project_overview "What is this repository?" Language mix, manifests, entry points, test count, VCS — one call instead of six exploratory listings.
list_directory "What is in here?" Denied entries counted, not named. Symlinks reported as skipped.
read_file "What does this file say?" Line-numbered, range-selectable, fenced as untrusted.
search_code "Where does this appear?" Literal or regex, glob-filtered, with a ReDoS guard.
find_symbol "Where is this defined?" Python is parsed; other languages are matched by pattern, and every result says which — parsed or pattern.
git_log "What changed recently?" Commit subjects are attacker-controlled text and arrive fenced.
git_diff "What changed here?" Working tree, index, or against a revision.

There is no shell tool, no write, no test runner and no network fetch. A test runner would mean executing the project's code, which path containment cannot constrain — and a hole there would make the rest of the sandbox unclaimable. See docs/tools.md.

What a denial looks like #

A refusal is a result, not a transport error, because a model that never sees the refusal cannot learn from it:

{
  "resultType": "complete",
  "isError": true,
  "content": [{ "type": "text", "text":
    "'../../.ssh/id_rsa' resolves outside the workspace and cannot be read. Paths must stay inside myproject/." }],
  "structuredContent": {
    "error": "outside_workspace",
    "message": "'../../.ssh/id_rsa' resolves outside the workspace and cannot be read.",
    "remedy": "Paths must stay inside myproject/."
  }
}

The message names the workspace and never the resolved path: a denial that echoes back where it looked is an oracle for mapping the filesystem outside the sandbox one request at a time.

Conformance is the gate #

python tasks.py conform                 # in-process
python tasks.py conform-stdio           # against a real server process
uv run mcp-devserver conform --http http://localhost:8080/mcp --strict
target      stdio (uv run mcp-devserver serve --transport stdio)
checks      23/23 passed
duration    412ms

  ok    C001  [MUST]    server/discover is implemented
  ok    C004  [MUST]    every result carries resultType
  ok    C011  [MUST]    an unsupported version returns -32022 and the supported list
  ok    C017  [MUST]    notifications receive no response
  ok    C021  [MUST]    requests do not share connection state
  ...

CONFORMANT

CI runs it in-process, over stdio, and inside the built container. The suite itself is tested by breaking things: tests/conformance mutates responses one requirement at a time and asserts the corresponding check goes red. A suite that has only ever seen a correct server proves nothing about whether it would notice an incorrect one.

See docs/conformance.md.

Testing #

python tasks.py test            # everything, with the coverage gate
python tasks.py test-security   # adversarial; a failure is a security regression
python tasks.py test-e2e        # a real server process over a real pipe
python tasks.py smoke           # build the image and exercise it over HTTP

515 tests across five layers — unit, integration, security, e2e and conformance — at 91% coverage against an 88% gate. The security layer is 131 of them: path traversal through every tool that takes a path, symlink escape, denylist bypasses, credential leakage through both halves of a result, git argument injection, and resource exhaustion.

Configuration #

Every setting is an environment variable prefixed MCP_, with __ for nesting. .env.example documents all of them, and unknown keys in a section are rejected at start-up rather than silently ignored — a mistyped MCP_SANDBOX__MAX_FILE_BTYES would otherwise leave you believing a limit is in force that is not.

Setting Default Why
MCP_SANDBOX__WORKSPACE the working directory The only directory reachable.
MCP_SANDBOX__FOLLOW_SYMLINKS false A followed link is a path nobody asked for.
MCP_SECURITY__REDACT_SECRETS true The control behind "credentials do not leave".
MCP_SECURITY__SCAN_UNTRUSTED_CONTENT true The control behind the fences.
MCP_HTTP__BEARER_TOKEN empty Required in production; an unauthenticated listener is a remote read primitive.
MCP_SANDBOX__TOOL_TIMEOUT_SECONDS 15 What bounds a walk.

With MCP_ENVIRONMENT=production a process with any of these wrong refuses to start. See docs/configuration.md.

Documentation #

Known limitations #

Stated plainly, because a portfolio project that claims to be finished is less useful than one that says where the edges are.

Licence #

MIT — see LICENSE.