local-mcp-simple-server
by jeffcaradona
README.md
# local-mcp-simple-server
A deliberately small MCP server that isolates one complete interaction:
```
one selected file → one MCP tool call → bounded source text → Copilot explains it
```
It exposes exactly one tool, `read_source_file`. There is no inventory, no
search, no Git integration, no model client, and no second tool. This is
the MCP counterpart to `local-llm-simple-harness`: it exists so the
mechanics of *one* MCP round trip are easy to see end-to-end, before
looking at a larger system (`modernization-evidence-harness`) that builds
inventory, search, excerpts, provenance, and artifact-reference validation
on top of the same basic shape.
This is an independent learning project. It does not import, depend on, or
require either the simple LLM harness or the evidence harness.
## What this is not
- **Not a sandbox.** The containment checks in `server.js` (resolving
`..`-free relative paths, then following symlinks with `fs.realpath` to
confirm the real file is still under the configured root) are ordinary
application-level checks, not an OS-level security boundary. They stop a
well-formed MCP client from reading outside the configured root; they do
not defend against a hostile process racing the filesystem underneath
this one (a file swapped out between the containment check and the read
that follows). Run this server with the same trust you'd give any local
process that reads files on your behalf.
- **Not a text-content guarantee.** "UTF-8 text file" is enforced by two
cheap, honest checks: reject any file containing a NUL byte, then require
the remaining bytes to decode as strict UTF-8 (`TextDecoder` with
`fatal: true`). This reliably rejects binary formats (images, archives,
compiled binaries) but cannot prove a file is *meaningful* source code —
a valid UTF-8 file full of nonsense still passes, and in principle a
binary format could (rarely) decode as valid UTF-8 too. Good enough to
keep this tool from handing binary garbage to an LLM; not a general
file-type classifier.
- **Not versioned or attributed.** The tool reads whatever is on disk right
now. There is no Git subprocess, no commit pinning, no hash, and no
provenance record — if you need "prove this exact text existed at this
exact revision," that's what the larger evidence harness is for.
## Requirements
- Node.js 18 or newer, native ESM.
- npm (a lockfile is committed; use `npm ci` for a reproducible install).
## Setup
```powershell
cd local-mcp-simple-server
npm ci
```
Configure the one thing this server needs — an absolute path to the
directory it's allowed to read from — via `SIMPLE_MCP_SOURCE_ROOT`. The
tool caller (Copilot) never sees or changes this value; it is fixed for
the life of the server process.
Run it directly to confirm it starts (single-line PowerShell, no
heredocs):
```powershell
$env:SIMPLE_MCP_SOURCE_ROOT = "C:\path\to\local-mcp-simple-server\examples\source"; node server.js
```
### Why this appears to hang
`server.js` prints a one-line "ready" banner to **stderr** and then waits.
That's correct, not broken: a stdio MCP server has no interactive prompt —
it is a subprocess meant to be launched and driven by an MCP client (VS
Code / Copilot, or the test client in this repo) that writes JSON-RPC
requests to its stdin and reads responses from its stdout. Run standalone
in a terminal, it will sit there silently (aside from that one stderr
line) until something speaks the protocol to it, or until you close its
stdin (Ctrl+C, or closing the terminal) to stop it.
## VS Code / Copilot setup (Windows)
1. Copy `examples/vscode.mcp.json` into your workspace's `.vscode/mcp.json`
(single-line PowerShell):
```powershell
Copy-Item "examples\vscode.mcp.json" ".vscode\mcp.json" -Force
```
2. Edit the copied file so `SIMPLE_MCP_SOURCE_ROOT` points at an absolute
path on your machine (the checked-in example uses
`${workspaceFolder}/examples/source`, which VS Code expands for you, so
this step may already be correct if you keep the same layout).
3. Open the Command Palette and run **MCP: List Servers**, confirm
`local-mcp-simple-server` is listed, and start it from there if it
isn't already running.
4. Open Copilot Chat in **Agent** mode. Ask it to use the
`read_source_file` tool (name it explicitly) to read
`examples/source/CustomerService.vb` and explain its validation
behavior.
5. **Check the tool-call record before trusting the answer.** Copilot's
chat UI shows which tool actually ran for a turn. Confirm it says
`read_source_file` from `local-mcp-simple-server` — not a built-in
workspace file read. A built-in read can produce a similar-looking
explanation without ever going through this server, which would
silently defeat the point of the smoke test.
**Verified 2026-09-21** against live VS Code Copilot Chat (Agent mode,
Windows) reading this repo's own `CustomerService.vb` fixture. The MCP
output log showed the server start under VS Code's MCP client
(`Discovered 1 tools`), and the chat transcript's tool-call record showed
`tool: "mcp_local-mcp-sim_read_source_file"` with
`args: {"path":"CustomerService.vb"}` — the `mcp_`-prefixed tool name
confirms the call went through this server rather than a built-in
workspace file read. The raw response was
`{"path":"CustomerService.vb","text":"..."}`, the complete file text
untouched (CRLF preserved), and Copilot's explanation of the validation
logic (name non-blank; email needs exactly one interior `@`; phone reduces
to digit characters via `Char.IsDigit` and requires exactly 10, correctly
noting this includes Unicode digits, not just ASCII) matched the actual
code.
## What the SDK handles vs. what this code handles
| Concern | Handled by |
|---|---|
| JSON-RPC framing, request/response correlation | `@modelcontextprotocol/sdk` (`StdioServerTransport`, `McpServer`) |
| Tool discovery (`tools/list`) | SDK, from the config passed to `registerTool` |
| Argument shape validation (missing/wrong-typed `path`) | SDK, via the Zod `inputSchema` |
| Output shape validation | SDK, via the Zod `outputSchema` |
| Path safety, root containment, symlink-escape checks | This code (`server.js`) |
| File-size enforcement, streaming read | This code |
| UTF-8 text policy | This code |
| Turning a failure into a non-crashing tool result | This code (`toToolErrorResult`), never the SDK |
We never hand-write JSON-RPC or MCP framing — the SDK owns the wire
format entirely. Our job is exactly the part inside the tool callback:
turn one validated path into either a result or a clear, catchable error.
## Why no model client lives in this server
Copilot supplies its own reasoning; this server supplies bytes. VS Code's
Copilot already has a configured chat model and calls this MCP server as a
*tool* mid-conversation — the server's job ends the moment it returns text.
There is deliberately no API key, no model SDK, and no prompt construction
here: adding one would blur exactly the boundary this milestone exists to
make visible (server = context, Copilot = reasoning).
## Where the text you return actually goes
Whatever `read_source_file` returns is inserted into Copilot's ongoing
chat context and sent to whatever model backs your Copilot subscription
and its currently configured processing environment/region — the same as
if you'd pasted the file into the chat yourself. Only put synthetic,
non-sensitive files under a directory you point `SIMPLE_MCP_SOURCE_ROOT`
at for real use; the fixtures in this repo (`examples/source/`) are
intentionally synthetic for exactly this reason.
## How this maps onto the larger evidence harness
`modernization-evidence-harness` performs the same *kind* of step — hand a
bounded slice of source to an LLM — but wraps it with repository inventory
(which files exist), search (which file is relevant), excerpting (which
lines matter), and provenance (proving what revision the text came from).
This server is that one step by itself: given a path someone already
picked, read it safely and hand back the whole thing. If you can explain
how a call reaches `server.js`'s tool callback and how its result gets
back to Copilot, you've understood the piece the bigger harness builds on.
## Function-by-function walkthrough of one call
Given a request to read `CustomerService.vb`:
1. **VS Code / Copilot** sends a `tools/call` JSON-RPC request over the
server's stdin. The MCP SDK's `StdioServerTransport` reads and parses
it; this code never touches raw stdin.
2. **`McpServer`** (SDK) looks up the registered `read_source_file` tool,
validates `{ path: "CustomerService.vb" }` against the Zod
`inputSchema`, and — only if that passes — invokes the callback
registered in `registerReadSourceFileTool`.
3. **`readSourceFile(sourceRootReal, rawPath)`** is the composition of the
actual policy, in order:
- `normalizeRelativePath(rawPath)` — pure, no filesystem access. Splits
on `/` or `\`, rejects an absolute-looking path, and rejects any `..`
segment. Returns a normalized, forward-slash relative path.
- `resolveRealPathWithinRoot(sourceRootReal, normalizedPath)` — joins
the path onto the root, calls `fs.realpath` (which follows every
symlink/junction in the chain), and confirms the real result is still
inside the real root via `path.relative`.
- `assertRegularFile(realPath, normalizedPath)` — `fs.stat` and rejects
anything that isn't a plain file (a directory, in particular).
- `readBoundedFile(realPath)` — streams the file in 16 KiB chunks,
throwing the moment the running total passes 64 KiB, so an oversized
file is never fully buffered before being rejected.
- `decodeUtf8Text(buffer, normalizedPath)` — rejects a NUL byte, then
strictly decodes the rest as UTF-8.
4. **On success**, the callback returns an ordinary object —
`{ structuredContent: { path, text }, content: [{ type: "text", ... }] }`
— straight back to `McpServer`, which validates it against the
`outputSchema` and serializes it as the `tools/call` response.
5. **On any failure** at any step above, the callback's `catch` calls
`toToolErrorResult(error)`, which returns `{ isError: true, content: [...] }`
instead of throwing. This is what satisfies "return understandable tool
errors without terminating the server": the exception never reaches the
SDK or the transport, so the process and the stdio connection to it
both stay alive for the next call.
6. **`StdioServerTransport`** (SDK) serializes that result and writes it to
stdout; Copilot reads it back as the tool's output and continues the
conversation with it in context.
## Testing
```powershell
npm test
```
Uses Node's built-in test runner (`node --test`) against
`test/server.test.js`. That file starts one real server subprocess via the
SDK's `StdioClientTransport`/`Client` — exactly the way a real MCP client
would — and reuses it across the whole suite (bounded by
`--test-timeout=15000`; the client and its fixtures are torn down in an
`after` hook regardless of pass/fail).
Covered:
- discovery lists exactly `read_source_file`;
- a synthetic fixture round-trips its expected relative path and text;
- malformed arguments (missing `path`, wrong type);
- missing file, a directory instead of a file, an absolute path, `..`
traversal;
- an oversized file is rejected, not truncated (`structuredContent` is
absent on that result);
- unsupported (binary) content is rejected;
- a symlink escaping the source root is rejected — skipped with an
explanation if the test environment can't create a symlink (e.g.
Windows without permission to create one), rather than failing;
- the server answers a normal call correctly after several failed calls;
- the server's stderr banner is captured and asserted on directly, and
every other test having round-tripped valid JSON-RPC over stdout is
itself evidence that no stray diagnostic corrupted the protocol stream.
## Known acceptance gaps
- Symlink/junction-escape rejection is exercised on Linux in this
environment; Windows junction behavior relies on Node's `fs.realpath`
resolving junctions the same way it resolves symlinks, which is
documented Node behavior but has not been separately verified on
Windows here.
## Non-goals (out of scope for this milestone, on purpose)
Repository inventory or search, multiple source roots, Git subprocesses or
revision pinning, evidence catalogs or artifact validation, a model
client or API key, MCP sampling/prompts/resources/HTTP transport/auth,
file writes, shell tools, database storage, a UI, Docker/deployment, or
any plugin/agent/extensibility scaffolding. All of that belongs to
`modernization-evidence-harness`, not here.
TDQS
A4.1/5.0
Scored across 1 tool
Disambiguation5/5
Only one tool exists, so there is no possibility of confusion or overlap. Every call is clearly directed to the single available operation.
Naming Consistency5/5
The tool name 'read_source_file' follows a clear verb_noun pattern, which is consistent even though it is the only tool. The naming is descriptive and predictable.
Tool Count3/5
A single tool feels thin for a server, but the purpose is explicitly simple (reading one file at a time), so it borders on acceptable. It would benefit from at least one companion tool like list_source_files.
Completeness2/5
The server only supports reading one file and offers no listing, writing, or directory traversal capabilities. Agents must know exact relative paths in advance, making workflows fragile and incomplete.
Maintenance
ActivityMaintained
ResponsivenessNo issues