Skip to main content
Glama
jeffcaradona

local-mcp-simple-server

by jeffcaradona

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.

Related MCP server: Project Files Read-only MCP

Requirements

  • Node.js 18 or newer, native ESM.

  • npm (a lockfile is committed; use npm ci for a reproducible install).

Setup

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):

$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):

    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

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.

Available Tools

1 tool
read_source_fileRead source fileA

Reads one UTF-8 text file from the server's configured source root and returns its relative path and complete text. Files over 64 KiB are rejected, not truncated. The caller cannot choose or change the root; only a path relative to it.

ParametersJSON Schema
NameRequiredDescriptionDefault
pathYesPath to the file, relative to the configured source root, e.g. "CustomerService.vb".

Output Schema

ParametersJSON Schema
NameRequiredDescription
pathYes
textYes

TDQS

A4.3/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the behavioral disclosure burden. It proactively states the 64 KiB rejection policy, the UTF-8 requirement, and the immutability of the source root, which are meaningful behavioral details beyond what the schema provides.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Three short sentences, each carrying distinct information: what is read, the file-size policy, and the root constraint. The core action and return value are front-loaded with no filler or repetition.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple single-parameter read tool with an output schema present, the description covers the essential behaviors: path semantics, root restriction, size limit, and return content. Nothing an agent needs to invoke it correctly is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The input schema already documents the 'path' parameter with a clear relative-to-root description and an example. The description reinforces the relative-root constraint but does not materially add new parameter-specific meaning beyond the schema, so the baseline of 3 applies.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly identifies the operation ('Reads one UTF-8 text file'), the resource ('server's configured source root'), and the return value ('relative path and complete text'). It is specific and unambiguous even without sibling tools to distinguish from.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives clear usage constraints: the path must be relative to the configured root, the root cannot be changed, and files over 64 KiB are rejected. It does not explicitly name alternatives, but no siblings exist, so the context is sufficient for correct selection.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 1 tool updatev1.0.0
    • First observedread_source_file

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

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables local ChatGPT/OpenAI MCP clients to read files and search within explicitly authorized directories using read-only, policy-constrained tools.
    1
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables secure, read-only access to local project files (including text, DOCX, PDF, and XLSX) through MCP, with strict directory whitelisting and no write, edit, or command-execution tools.
    1
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables ChatGPT, Codex, and compatible MCP clients to read bounded UTF-8 text and create files or directories in operator-selected folders, with strict protections against overwrites, deletes, and path escapes.
    Apache 2.0