Extending MCP Servers with Dependencies

Build a server that adds tools to or overrides tools from an existing MCP server. Pharos handles dependency installation — your server code handles the extension logic.

How dependency extension works

Pharos dependencies are package-level: when someone installs your server, Pharos recursively installs everything in your dependencies list. This guarantees the upstream server is present on the machine.

The actual tool extension — adding new tools, overriding existing ones, wrapping calls with validation — happens in your server code. Your server launches the upstream server as a subprocess and proxies MCP requests to it. This is a standard MCP proxy pattern, not a Pharos-specific feature. Pharos just makes sure the dependency is installed.

Declare dependencies in your manifest

The dependencies field in pharos.json is an array of { name, version } objects. The version is a semver constraint resolved at install time.

Interactive
# Create your manifest interactively
pharos init

# When prompted for dependencies:
#   dep> filesystem-mcp-server^1.0.0
#   ✓ added filesystem-mcp-server@^1.0.0
#   dep> (empty line to finish)

# Or add them manually to pharos.json
pharos.json
{
  "name": "enhanced-filesystem-server",
  "version": "1.0.0",
  "description": "Filesystem server with audit logging and safe-write validation",
  "license": "MIT",
  "transport": "stdio",
  "runtime": "python",
  "command": "python server.py",
  "capabilities": ["tools"],
  "files": ["server.py", "lib/"],
  "dependencies": [
    {
      "name": "filesystem-mcp-server",
      "version": "^1.0.0"
    }
  ]
}

Supported version formats: ^1.0.0, >=0.1.0, =1.2.0, latest, or * (any).

How Pharos resolves dependencies

When a user installs your package, Pharos reads the dependency list, resolves each constraint against the registry, reports version conflicts and circular dependencies, and installs everything recursively.

Install with dependency resolution
# Install your package — Pharos resolves and installs dependencies recursively
pharos install enhanced-filesystem-server

# Output:
#   Resolving dependencies...
#   Installing [email protected]...
#   Installing [email protected]...
#   Done.

# Install without writing MCP client configs for dependencies
# (your wrapper handles the upstream server, not the client)
pharos install enhanced-filesystem-server --no-dep-config
Lockfile for reproducible installs
# Resolve dependencies and write pharos.lock
pharos lock

# pharos.lock records exact resolved versions:
#   filesystem-mcp-server: 1.2.0

# Install from a frozen lockfile (CI-friendly)
pharos install enhanced-filesystem-server --frozen

The proxy pattern

Your server launches the upstream MCP server as a subprocess and communicates over stdio (JSON-RPC). You intercept calls to add logging, validation, or new tools entirely. Three things you can do:

Pass-through

Forward a tool call to the upstream server unchanged. Add audit logging on top.

Override

Intercept a tool call, validate or transform the arguments, then decide whether to forward or reject.

Add new

Expose tools that do not exist in the upstream server at all. Your server handles them directly.

Python — proxy wrapper
"""
Enhanced Filesystem Server — wraps filesystem-mcp-server and adds:
  - audit_log: logs every tool call
  - safe_write: validates paths before writing (overrides write_file)
"""
import asyncio
import json
import sys
from datetime import datetime, timezone

# The upstream server runs as a subprocess via stdio
# Pharos installed it, so it's available as a local MCP server

UPSTREAM_COMMAND = "python"
UPSTREAM_ARGS = ["-m", "filesystem_mcp_server"]


class EnhancedFilesystemServer:
    def __init__(self):
        self.upstream = None
        self.audit_log: list[dict] = []

    async def start(self):
        """Start the upstream server as a subprocess."""
        self.upstream = await asyncio.create_subprocess_exec(
            UPSTREAM_COMMAND,
            *UPSTREAM_ARGS,
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )

    async def _call_upstream(self, method: str, params: dict) -> dict:
        """Forward a JSON-RPC request to the upstream server."""
        request = {
            "jsonrpc": "2.0",
            "id": len(self.audit_log) + 1,
            "method": method,
            "params": params,
        }
        line = json.dumps(request) + "\n"
        self.upstream.stdin.write(line.encode())
        await self.upstream.stdin.drain()

        response_line = await self.upstream.stdout.readline()
        return json.loads(response_line)

    def _audit(self, tool: str, args: dict, result: str):
        """Record every tool call."""
        self.audit_log.append({
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "tool": tool,
            "args": args,
            "result_summary": result[:200],
        })

    # ─── Pass-through tools ──────────────────────────────────

    async def read_file(self, path: str) -> str:
        """Read a file — delegates to upstream, logs the call."""
        result = await self._call_upstream("tools/call", {
            "name": "read_file",
            "arguments": {"path": path},
        })
        content = result.get("result", "")
        self._audit("read_file", {"path": path}, content)
        return content

    async def list_directory(self, path: str) -> str:
        """List a directory — delegates to upstream, logs the call."""
        result = await self._call_upstream("tools/call", {
            "name": "list_directory",
            "arguments": {"path": path},
        })
        content = result.get("result", "")
        self._audit("list_directory", {"path": path}, content)
        return content

    # ─── Overridden tools ────────────────────────────────────

    BLOCKED_PATTERNS = ["/etc/", "/sys/", "/proc/", "~/.ssh/"]

    async def write_file(self, path: str, content: str) -> str:
        """
        Override write_file — validates the path before delegating.
        The upstream server's write_file is never called for blocked paths.
        """
        for pattern in self.BLOCKED_PATTERNS:
            if pattern in path:
                self._audit("write_file", {"path": path}, "BLOCKED")
                return f"Error: path '{path}' is in a protected directory"

        result = await self._call_upstream("tools/call", {
            "name": "write_file",
            "arguments": {"path": path, "content": content},
        })
        response = result.get("result", "")
        self._audit("write_file", {"path": path}, response)
        return response

    # ─── New tools ───────────────────────────────────────────

    async def get_audit_log(self) -> str:
        """New tool — returns the audit log. Not in the upstream server."""
        return json.dumps(self.audit_log, indent=2)
TypeScript — proxy wrapper
/**
 * Enhanced Filestream Server — wraps filesystem-mcp-server.
 * Adds audit logging and path validation.
 */
import { spawn, ChildProcess } from "child_process";

interface AuditEntry {
  timestamp: string;
  tool: string;
  args: Record<string, unknown>;
  resultSummary: string;
}

const BLOCKED_PATTERNS = ["/etc/", "/sys/", "/proc/", "~/.ssh/"];

class EnhancedFilesystemServer {
  private upstream: ChildProcess | null = null;
  private auditLog: AuditEntry[] = [];
  private requestId = 0;

  async start(): Promise<void> {
    this.upstream = spawn("npx", ["-y", "filesystem-mcp-server"], {
      stdio: ["pipe", "pipe", "pipe"],
    });
  }

  private async callUpstream(
    method: string,
    params: Record<string, unknown>,
  ): Promise<Record<string, unknown>> {
    const id = ++this.requestId;
    const request = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";

    return new Promise((resolve, reject) => {
      this.upstream!.stdin!.write(request);
      this.upstream!.stdout!.once("data", (data) => {
        try {
          resolve(JSON.parse(data.toString()));
        } catch (e) {
          reject(e);
        }
      });
    });
  }

  private audit(tool: string, args: Record<string, unknown>, result: string): void {
    this.auditLog.push({
      timestamp: new Date().toISOString(),
      tool,
      args,
      resultSummary: result.slice(0, 200),
    });
  }

  // ─── Pass-through tools ──────────────────────────────────

  async readFile(path: string): Promise<string> {
    const result = await this.callUpstream("tools/call", {
      name: "read_file",
      arguments: { path },
    });
    const content = (result.result as string) ?? "";
    this.audit("read_file", { path }, content);
    return content;
  }

  // ─── Overridden tools ────────────────────────────────────

  async writeFile(path: string, content: string): Promise<string> {
    for (const pattern of BLOCKED_PATTERNS) {
      if (path.includes(pattern)) {
        this.audit("write_file", { path }, "BLOCKED");
        return `Error: path '${path}' is in a protected directory`;
      }
    }

    const result = await this.callUpstream("tools/call", {
      name: "write_file",
      arguments: { path, content },
    });
    const response = (result.result as string) ?? "";
    this.audit("write_file", { path }, response);
    return response;
  }

  // ─── New tools ───────────────────────────────────────────

  async getAuditLog(): Promise<string> {
    return JSON.stringify(this.auditLog, null, 2);
  }
}

Publish the extended server

Publishing works the same as any other package. Pharos stores your manifest with its dependency declarations. When someone installs your server, the dependencies are resolved automatically.

Publish
# Validate first
pharos publish --dry-run

# Publish to the registry
pharos publish

# Users install your server — dependencies come automatically
pharos install enhanced-filesystem-server

What Pharos does and does not do

  • Does: Resolve and install dependencies recursively. Detect version conflicts and circular dependencies. Write lockfiles for reproducible installs.
  • Does: Ensure the upstream server binary is on the machine when your server runs.
  • Does not:Wire up the subprocess connection between your server and the upstream. That is your server code's job — the proxy pattern above.
  • Does not: Merge tool lists automatically. Your server exposes its own tools; if you want the upstream tools too, proxy them.

Next steps