Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

Building a Minimal MCP Server with Scoped Tools and Per-Client Boundaries

I have been building an MCP server called Bridgekit, and the part that took the longest was not exposing tools. Exposing tools is easy. The hard part was making sure a tool could only ever touch what a specific client wa

I have been building an MCP server called Bridgekit, and the part that took the longest was not exposing tools. Exposing tools is easy. The hard part was making sure a tool could only ever touch what a specific client was allowed to touch, and being able to prove afterward what it did. This tutorial walks through that technique on a minimal server so you can lift the pattern into your own.

The Model Context Protocol lets a host (say, a Claude client) discover and call tools you define. The naive version of this is dangerous: you hand the model a read_file tool with a path argument and it will happily read anything the process can read. What we want instead is a server where every tool has a strict input schema, every call is checked against a per-client boundary, and every call is logged.

The three ideas

There are three things I want you to take away, independent of my project:

  1. Tools declare narrow, typed input schemas so bad calls fail before your code runs.
  2. Authorization is a boundary the tool cannot escape, not a suggestion in a prompt.
  3. Every effectful call leaves an audit record.

Let me build a tiny server that does all three.

Setup

We use the official TypeScript SDK and Zod for schemas.

npm install @modelcontextprotocol/sdk zod

Defining a scoped tool

Here is the key structural decision: I never let a tool receive a raw filesystem path. Instead a client is bound to a root directory, and the tool takes a path relative to that root. The boundary lives in code, not in the argument.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import path from "node:path";

// Per-client boundary. In a real server this comes from auth,
// not a constant. The point is that the tool closes over it.
interface ClientContext {
  clientId: string;
  root: string;          // absolute directory this client may touch
  canWrite: boolean;     // scope: read-only vs read-write
}

const audit: Array<Record<string, unknown>> = [];

function log(entry: Record<string, unknown>) {
  const record = { ts: new Date().toISOString(), ...entry };
  audit.push(record);
  // Real servers append to durable storage here.
  console.error(JSON.stringify(record));
}

Note console.error, not console.log. On a stdio transport, stdout is the protocol channel. Anything you print to stdout corrupts the JSON-RPC stream. Logs go to stderr.

The boundary check

This is the function every tool must go through. It resolves the requested relative path against the client root and refuses anything that escapes it. This defends against ../../etc/passwd and against absolute paths.

function resolveInScope(ctx: ClientContext, relPath: string): string {
  const resolved = path.resolve(ctx.root, relPath);
  const rootWithSep = ctx.root.endsWith(path.sep)
    ? ctx.root
    : ctx.root + path.sep;
  if (resolved !== ctx.root && !resolved.startsWith(rootWithSep)) {
    throw new Error(`path escapes client scope: ${relPath}`);
  }
  return resolved;
}

path.resolve collapses .. segments before we test the prefix, so a traversal attempt resolves to a real path that fails the startsWith check. Testing the prefix on the raw string would be trivially bypassable; testing it after resolution is not.

Registering the tools

Now the tools. Each one declares its input schema with Zod, so the SDK rejects a call with a missing or wrong-typed argument before your handler runs. Each one runs the boundary check. Each one logs.

function buildServer(ctx: ClientContext): McpServer {
  const server = new McpServer({ name: "bridgekit-min", version: "1.0.0" });

  server.tool(
    "read_text_file",
    "Read a UTF-8 text file within the client's root.",
    { relPath: z.string().min(1).describe("Path relative to the client root") },
    async ({ relPath }) => {
      const abs = resolveInScope(ctx, relPath);
      const content = await fs.readFile(abs, "utf8");
      log({ client: ctx.clientId, tool: "read_text_file", relPath, ok: true });
      return { content: [{ type: "text", text: content }] };
    }
  );

  server.tool(
    "write_text_file",
    "Write a UTF-8 text file within the client's root.",
    {
      relPath: z.string().min(1),
      contents: z.string(),
    },
    async ({ relPath, contents }) => {
      if (!ctx.canWrite) {
        log({ client: ctx.clientId, tool: "write_text_file", relPath, denied: "read-only scope" });
        throw new Error("client scope is read-only");
      }
      const abs = resolveInScope(ctx, relPath);
      await fs.writeFile(abs, contents, "utf8");
      log({ client: ctx.clientId, tool: "write_text_file", relPath, ok: true, bytes: contents.length });
      return { content: [{ type: "text", text: `wrote ${contents.length} bytes` }] };
    }
  );

  return server;
}

Two scopes are enforced here at once. The spatial scope (the root) is enforced by resolveInScope. The capability scope (read vs write) is enforced by the canWrite check. Crucially, the write check happens before path resolution, so a read-only client cannot even use the write tool as a probe.

Wiring the transport

async function main() {
  const ctx: ClientContext = {
    clientId: process.env.CLIENT_ID ?? "anonymous",
    root: path.resolve(process.env.CLIENT_ROOT ?? "./sandbox"),
    canWrite: process.env.CLIENT_WRITE === "1",
  };
  const server = buildServer(ctx);
  await server.connect(new StdioServerTransport());
}

main().catch((err) => {
  console.error(JSON.stringify({ fatal: String(err) }));
  process.exit(1);
});

Each client gets its own process with its own context. That is the simplest way to guarantee two clients never share a boundary: they never share a server instance. When you graduate to a single multi-tenant HTTP server, the context must be derived per request from the authenticated identity, and every tool closure must read it from the request, never from a module global.

Why the schema matters for security, not just ergonomics

It is tempting to think of the Zod schema as documentation. It is more than that. The model generating a tool call is an untrusted input source. If your handler assumes relPath is a non-empty string and the model sends null, you want that rejected at the protocol layer with a clean error, not turned into an unhandled exception halfway through your logic. A tight schema shrinks the set of inputs your handler has to reason about, which is exactly what makes the boundary check above sufficient rather than hopeful.

One honest caveat

This design contains a tool within a directory, but it does not sandbox what the tool does with legitimate content. If a read-only client reads a file, the model still sees whatever secrets that file contains. Scope limits reach; they do not classify data. If your root can contain sensitive files, you need content-level controls (allowlists of readable paths, redaction, or a separate policy layer) on top of the boundary shown here. The boundary stops traversal and enforces capability. It does not decide what is safe to reveal.

Closing

The whole technique fits in three habits: give every tool a narrow typed schema, make authorization a boundary in code that the tool cannot argue its way past, and log every effectful call so you can answer "what did it touch" later. Bridgekit is my fuller take on this, with real per-client permission tables and a durable audit log, and you are welcome to read the source and borrow whatever is useful at github.com/AgentPostmortem/Bridgekit.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.