Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nicobailon/pi-mcp-adapter/llms.txt

Use this file to discover all available pages before exploring further.

Overview

The metadata cache enables Pi MCP Adapter to provide search, list, and describe functionality without active server connections. It also powers direct tool registration at startup, eliminating the need to connect all servers just to discover their tools.

Cache Location

Metadata is stored at:
~/.pi/agent/mcp-cache.json
The cache persists across Pi sessions and survives system reboots.

Cache Structure

interface MetadataCache {
  version: number  // Cache format version (currently 1)
  servers: Record<string, ServerCacheEntry>
}

interface ServerCacheEntry {
  configHash: string      // SHA-256 hash of server config
  tools: CachedTool[]     // Tool definitions
  resources: CachedResource[]  // Resource definitions
  cachedAt: number        // Unix timestamp in milliseconds
}

interface CachedTool {
  name: string
  description?: string
  inputSchema?: unknown   // JSON Schema object
}

interface CachedResource {
  uri: string             // MCP resource URI
  name: string
  description?: string
}

Example Cache Entry

{
  "version": 1,
  "servers": {
    "chrome-devtools": {
      "configHash": "a7f3e9c8b2d1f4e5c6a8b9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
      "tools": [
        {
          "name": "navigate",
          "description": "Navigate to a URL",
          "inputSchema": {
            "type": "object",
            "properties": {
              "url": { "type": "string", "description": "URL to navigate to" }
            },
            "required": ["url"]
          }
        },
        {
          "name": "take_screenshot",
          "description": "Take a screenshot of the page or element",
          "inputSchema": {
            "type": "object",
            "properties": {
              "format": { "type": "string", "enum": ["png", "jpeg", "webp"], "default": "png" },
              "fullPage": { "type": "boolean", "description": "Full page instead of viewport" }
            }
          }
        }
      ],
      "resources": [],
      "cachedAt": 1709500800000
    }
  }
}

Cache Validation

The cache is only used if it’s valid for a given server. Validation checks three criteria:

1. Config Hash Match

The adapter computes a SHA-256 hash of server configuration fields that affect tool output:
export function computeServerHash(definition: ServerEntry): string {
  const identity: Record<string, unknown> = {
    command: definition.command,
    args: definition.args,
    env: definition.env,
    cwd: definition.cwd,
    url: definition.url,
    headers: definition.headers,
    auth: definition.auth,
    bearerToken: definition.bearerToken,
    bearerTokenEnv: definition.bearerTokenEnv,
    exposeResources: definition.exposeResources,
  }
  const normalized = stableStringify(identity)
  return createHash("sha256").update(normalized).digest("hex")
}
Lifecycle settings (lifecycle, idleTimeout, debug) are intentionally excluded from the hash because they don’t affect which tools a server exposes.
If the config hash doesn’t match, the cache entry is considered stale.

2. Timestamp Check

Cache entries expire after 7 days by default:
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000  // 7 days

export function isServerCacheValid(
  entry: ServerCacheEntry,
  definition: ServerEntry,
  maxAgeMs: number = CACHE_MAX_AGE_MS
): boolean {
  if (!entry || entry.configHash !== computeServerHash(definition)) return false
  if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false
  if (maxAgeMs > 0 && Date.now() - entry.cachedAt > maxAgeMs) return false
  return true
}
Old cache entries are ignored, forcing a fresh connection to get updated metadata.

3. Structure Check

The cache loader validates the JSON structure:
export function loadMetadataCache(): MetadataCache | null {
  if (!existsSync(CACHE_PATH)) return null
  try {
    const raw = JSON.parse(readFileSync(CACHE_PATH, "utf-8"))
    if (!raw || typeof raw !== "object") return null
    if (raw.version !== CACHE_VERSION) return null
    if (!raw.servers || typeof raw.servers !== "object") return null
    return raw as MetadataCache
  } catch {
    return null
  }
}
Corrupted or incompatible cache files are rejected.

Cache Population

The cache is updated whenever a server connects successfully:
function updateMetadataCache(state: McpExtensionState, serverName: string): void {
  const connection = state.manager.getConnection(serverName)
  if (!connection || connection.status !== "connected") return

  const definition = state.config.mcpServers[serverName]
  if (!definition) return

  const entry: ServerCacheEntry = {
    configHash: computeServerHash(definition),
    tools: serializeTools(connection.tools),
    resources: serializeResources(connection.resources),
    cachedAt: Date.now(),
  }

  saveMetadataCache({ version: 1, servers: { [serverName]: entry } })
}

Serialization

The adapter serializes MCP tools and resources into a minimal format:
export function serializeTools(tools: McpTool[]): CachedTool[] {
  return tools
    .filter(t => t?.name)  // Skip invalid tools
    .map(t => ({
      name: t.name,
      description: t.description,
      inputSchema: t.inputSchema,
    }))
}

export function serializeResources(resources: McpResource[]): CachedResource[] {
  return resources
    .filter(r => r?.name && r?.uri)  // Skip invalid resources
    .map(r => ({
      uri: r.uri,
      name: r.name,
      description: r.description,
    }))
}
Only essential fields are stored to minimize cache size.

Cache Reconstruction

When loading from cache, the adapter reconstructs ToolMetadata objects:
export function reconstructToolMetadata(
  serverName: string,
  entry: ServerCacheEntry,
  prefix: "server" | "none" | "short",
  exposeResources?: boolean
): ToolMetadata[] {
  const metadata: ToolMetadata[] = []

  // Reconstruct tools
  for (const tool of entry.tools ?? []) {
    if (!tool?.name) continue
    metadata.push({
      name: formatToolName(tool.name, serverName, prefix),
      originalName: tool.name,
      description: tool.description ?? "",
      inputSchema: tool.inputSchema,
    })
  }

  // Reconstruct resources as tools (if enabled)
  if (exposeResources !== false) {
    for (const resource of entry.resources ?? []) {
      if (!resource?.name || !resource?.uri) continue
      const baseName = `get_${resourceNameToToolName(resource.name)}`
      metadata.push({
        name: formatToolName(baseName, serverName, prefix),
        originalName: baseName,
        description: resource.description ?? `Read resource: ${resource.uri}`,
        resourceUri: resource.uri,
      })
    }
  }

  return metadata
}
Tool names are formatted according to the configured prefix mode (server, short, or none).

Cache-Powered Features

Search works without active connections:
mcp({ search: "screenshot" })
The adapter searches through cached metadata:
for (const [serverName, metadata] of state.toolMetadata.entries()) {
  if (server && serverName !== server) continue
  for (const tool of metadata) {
    if (pattern.test(tool.name) || pattern.test(tool.description)) {
      matches.push({ server: serverName, tool })
    }
  }
}

2. Offline Describe

Tool descriptions and schemas come from cache:
mcp({ describe: "chrome_devtools_take_screenshot" })
No server connection required - the adapter reads from state.toolMetadata.

3. Direct Tool Registration

Direct tools register from cache at startup:
const directSpecs = resolveDirectTools(
  earlyConfig,
  earlyCache,  // Cache loaded before server connections
  prefix,
  envRaw?.split(",").map(s => s.trim()).filter(Boolean),
)

for (const spec of directSpecs) {
  pi.registerTool({
    name: spec.prefixedName,
    label: `MCP: ${spec.originalName}`,
    description: spec.description || "(no description)",
    parameters: Type.Unsafe<Record<string, unknown>>(
      spec.inputSchema || { type: "object", properties: {} }
    ),
    async execute(_toolCallId, params) {
      // Lazy connect when actually called
    },
  })
}
On the first session after configuring a new server, the cache won’t exist yet. Direct tools fall back to proxy-only mode. After the first connection (automatic for eager/keep-alive, manual for lazy), the cache is populated and direct tools become available on restart.

Cache Write Strategy

The adapter uses atomic writes to prevent corruption:
export function saveMetadataCache(cache: MetadataCache): void {
  const dir = dirname(CACHE_PATH)
  mkdirSync(dir, { recursive: true })

  // Merge with existing cache (other servers)
  let merged: MetadataCache = { version: CACHE_VERSION, servers: {} }
  try {
    if (existsSync(CACHE_PATH)) {
      const existing = JSON.parse(readFileSync(CACHE_PATH, "utf-8")) as MetadataCache
      if (existing && existing.version === CACHE_VERSION && existing.servers) {
        merged.servers = { ...existing.servers }
      }
    }
  } catch {
    // Ignore parse errors and proceed with empty cache
  }

  merged.version = CACHE_VERSION
  merged.servers = { ...merged.servers, ...cache.servers }

  // Atomic write: tmp file + rename
  const tmpPath = `${CACHE_PATH}.${process.pid}.tmp`
  writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8")
  renameSync(tmpPath, CACHE_PATH)
}
The two-step write (temp file + rename) ensures the cache is never left in a partially-written state.

Bootstrap Behavior

On the very first run (no cache file exists), the adapter connects to all servers to populate the cache:
const cachePath = getMetadataCachePath()
const cacheFileExists = existsSync(cachePath)
let cache = loadMetadataCache()
let bootstrapAll = false

if (!cacheFileExists) {
  bootstrapAll = true
  saveMetadataCache({ version: 1, servers: {} })  // Create empty cache
} else if (!cache) {
  cache = { version: 1, servers: {} }
  saveMetadataCache(cache)
}

const startupServers = bootstrapAll
  ? serverEntries  // Connect ALL servers on first run
  : serverEntries.filter(([, definition]) => {
      const mode = definition.lifecycle ?? "lazy"
      return mode === "keep-alive" || mode === "eager"
    })
After the first session, lazy servers no longer connect at startup - they rely on cache.

Cache Invalidation

The cache is automatically invalidated when:
  1. Config changes - Any change to command, args, env, url, etc.
  2. Cache expires - Entries older than 7 days
  3. Manual reconnect - Using /mcp reconnect or mcp({ connect: "..." })
You can force a refresh by reconnecting:
mcp({ connect: "server-name" })  // Refreshes cache
Or via command:
/mcp reconnect server-name

Performance Benefits

Fast Startup

No need to connect all servers at launch. Cached metadata is available instantly.

Offline Operation

Search, list, and describe work without active connections.

Low Memory

Only connect servers when actually needed. Lazy servers stay idle.

Direct Tools

Register individual tools from cache without spawning server processes.

Best Practices

  1. Let the cache warm up naturally - Don’t force-connect all servers just to populate cache. Eager/keep-alive servers will handle it.
  2. Use eager mode for cache population - If you want to pre-populate cache for a lazy server, temporarily set it to eager, restart Pi, then switch back to lazy.
  3. Monitor cache size - Large servers (100+ tools) create large cache entries. The cache file is typically under 100 KB for most setups.
  4. Don’t edit the cache manually - The adapter validates cache structure and hashes. Manual edits will likely be rejected.
  5. Understand bootstrap behavior - The first run connects all servers. Subsequent runs respect lifecycle modes.

Build docs developers (and LLMs) love