MCP Tooling for Markdown CRM

Agentic AI Systems intermediate Building with AI ~25 min read

Overview

The Model Context Protocol (MCP) defines a standard interface through which AI desktop agents — like Claude, Cursor, or custom LLM applications — discover and invoke tools exposed by external servers. By building an MCP server that wraps a markdown-file-based CRM, teams give AI agents the ability to list clients, search by criteria, read individual records, and update fields — all through structured tool calls rather than raw file manipulation. This is safer, auditable, and much more reliable than asking an LLM to parse and rewrite markdown files directly.

A markdown-based CRM stores each client as a separate .md file with YAML frontmatter containing structured fields (name, email, company, status, tags, last contacted date) and a freeform body for notes and interaction history. This format is human-readable, git-versionable, and trivially diffable — qualities that make it appealing for small teams and solo practitioners who prefer file-system-native tooling over heavyweight SaaS CRMs. The MCP server sits between the AI agent and these files, providing a typed API surface that prevents the agent from accidentally corrupting file structure or writing invalid frontmatter.

The server exposes four core tools — list_clients, search_clients, get_client, and update_client — each accepting JSON parameters and returning structured JSON responses. The agent never sees raw file paths or markdown syntax; it works exclusively through the tool interface. This separation of concerns means the file format can evolve (e.g., migrating from YAML frontmatter to TOML) without changing the agent’s behavior, and access controls can be enforced at the tool level (e.g., allowing reads but blocking writes for certain agent roles).

Core Concepts

Model Context Protocol (MCP)

MCP is an open protocol that standardizes how AI agents discover and invoke tools from external servers. An MCP server advertises a list of available tools (each with a name, description, and JSON Schema for parameters), and the agent selects which tool to call based on the user’s intent. The protocol uses JSON-RPC over stdio or HTTP, making it transport-agnostic. Tool responses are structured JSON that the agent can interpret, summarize, or act upon without parsing unstructured text.

Markdown Frontmatter as Structured Data

Each CRM record is a markdown file where YAML frontmatter (delimited by ---) holds typed fields and the body contains freeform notes. The MCP server parses frontmatter into a typed object and exposes it through tool responses, while the markdown body is returned as a separate notes field. This dual structure — typed fields for querying and filtering, freeform text for context — maps naturally to how sales teams think about client records: structured status and tags for pipeline views, narrative notes for relationship context.

Tool-Mediated File Access

Instead of giving the AI agent direct filesystem access, the MCP server acts as a controlled gateway. The get_client tool reads and parses a specific file; update_client modifies only the frontmatter fields specified in the request, preserving the rest of the file untouched. This prevents common failure modes like the agent accidentally deleting the notes section, corrupting YAML indentation, or writing invalid field values. The server validates all inputs against a schema before writing.

Search & Filtering Over File Collections

The search_clients tool accepts query parameters — name substring, status enum, tag list, date ranges — and scans all markdown files in the CRM directory, parsing each file’s frontmatter and returning matching records. For small to medium CRM directories (up to a few thousand files), this scan-and-filter approach is fast enough without an index. For larger collections, the server can maintain an in-memory index that refreshes on file-system change events via fsnotify or equivalent.

Audit Logging & Change Tracking

Every update_client call logs the fields changed, the old and new values, and the agent identity (extracted from the MCP session) to an append-only audit log. Combined with git versioning of the markdown files themselves, this provides two layers of change tracking: the audit log captures intent (what the agent was trying to do and why), while git captures the actual file diffs. Teams can correlate the two to review and, if necessary, revert agent-initiated changes.

How It Works

MCP Server Initialization

The server starts by scanning the CRM directory for all .md files, parsing their frontmatter to build an in-memory index of client records. It then registers the four tools with the MCP runtime, providing JSON Schema definitions for each tool’s parameters and return type. When an AI agent connects (via stdio pipe or HTTP), the MCP handshake sends the tool list to the agent, which incorporates the tools into its available action space. The agent can then call any tool by name with the appropriate parameters.

Tool Dispatch & Execution

When a tool call arrives, the MCP runtime deserializes the JSON-RPC request, validates the parameters against the registered schema, and dispatches to the corresponding handler function. The handler executes the operation — reading files, filtering records, or writing frontmatter updates — and returns a JSON result. The runtime serializes the result back to the agent. Error handling follows JSON-RPC conventions: invalid parameters return a structured error with a code and message that the agent can interpret and retry.

Frontmatter Parsing & Safe Updates

Reading a client file involves splitting the file at the frontmatter delimiters, parsing the YAML block into a typed object, and returning it alongside the markdown body. Updating is more delicate: the server reads the current file, parses the frontmatter, applies only the specified field changes (leaving unmentioned fields untouched), re-serializes the frontmatter to YAML, and writes the file back — preserving the original markdown body byte-for-byte. This read-modify-write cycle happens under a file lock to prevent concurrent updates from corrupting the file.

Implementation Patterns

Client Record Type & Frontmatter Parser

Define the CRM record structure and a parser that extracts typed data from markdown files with YAML frontmatter.

// types/client.ts
export interface ClientRecord {
  slug: string;           // derived from filename: "acme-corp.md" -> "acme-corp"
  name: string;
  email: string;
  company: string;
  status: "lead" | "prospect" | "active" | "churned" | "archived";
  tags: string[];
  lastContacted: string;  // ISO date
  dealValue?: number;
  notes: string;          // markdown body (freeform)
}

// parser/frontmatter.ts
import * as fs from "fs/promises";
import * as path from "path";
import * as yaml from "yaml";

export async function parseClientFile(
  filePath: string
): Promise<ClientRecord> {
  const content = await fs.readFile(filePath, "utf-8");
  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) {
    throw new Error(`Invalid frontmatter in ${filePath}`);
  }

  const frontmatter = yaml.parse(match[1]) as Record<string, unknown>;
  const notes = match[2].trim();
  const slug = path.basename(filePath, ".md");

  return {
    slug,
    name: String(frontmatter.name ?? ""),
    email: String(frontmatter.email ?? ""),
    company: String(frontmatter.company ?? ""),
    status: frontmatter.status as ClientRecord["status"],
    tags: Array.isArray(frontmatter.tags)
      ? frontmatter.tags.map(String)
      : [],
    lastContacted: String(frontmatter.lastContacted ?? ""),
    dealValue: typeof frontmatter.dealValue === "number"
      ? frontmatter.dealValue
      : undefined,
    notes,
  };
}

export async function updateClientFile(
  filePath: string,
  updates: Partial<Omit<ClientRecord, "slug" | "notes">>
): Promise<ClientRecord> {
  const content = await fs.readFile(filePath, "utf-8");
  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) {
    throw new Error(`Invalid frontmatter in ${filePath}`);
  }

  const frontmatter = yaml.parse(match[1]) as Record<string, unknown>;
  const body = match[2];

  // Apply only the specified updates
  for (const [key, value] of Object.entries(updates)) {
    if (value !== undefined) {
      frontmatter[key] = value;
    }
  }

  const newYaml = yaml.stringify(frontmatter);
  const newContent = `---\n${newYaml}---\n${body}`;
  await fs.writeFile(filePath, newContent, "utf-8");

  return parseClientFile(filePath);
}

MCP Server with Tool Registration

Register the four CRM tools with the MCP server runtime, mapping each to its handler and parameter schema.

// server/mcp-crm.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import { parseClientFile, updateClientFile } from "../parser/frontmatter";
import type { ClientRecord } from "../types/client";

const CRM_DIR = process.env.CRM_DIR || "./clients";

const server = new McpServer({
  name: "markdown-crm",
  version: "1.0.0",
});

// --- list_clients: return all client records ---
server.tool(
  "list_clients",
  "List all clients in the CRM with their key fields",
  { status: z.string().optional().describe("Filter by status") },
  async ({ status }) => {
    const files = (await fs.readdir(CRM_DIR))
      .filter((f) => f.endsWith(".md"));

    const clients: ClientRecord[] = [];
    for (const file of files) {
      const client = await parseClientFile(path.join(CRM_DIR, file));
      if (!status || client.status === status) {
        clients.push(client);
      }
    }

    return {
      content: [
        {
          type: "text" as const,
          text: JSON.stringify(
            clients.map(({ slug, name, company, status, tags }) => ({
              slug, name, company, status, tags,
            })),
            null,
            2
          ),
        },
      ],
    };
  }
);

// --- search_clients: find clients matching criteria ---
server.tool(
  "search_clients",
  "Search clients by name, company, tag, or status",
  {
    query: z.string().optional().describe("Name or company substring"),
    tag: z.string().optional().describe("Filter by tag"),
    status: z.string().optional().describe("Filter by status"),
  },
  async ({ query, tag, status }) => {
    const files = (await fs.readdir(CRM_DIR))
      .filter((f) => f.endsWith(".md"));

    const results: ClientRecord[] = [];
    for (const file of files) {
      const client = await parseClientFile(path.join(CRM_DIR, file));
      const q = query?.toLowerCase();

      if (q && !client.name.toLowerCase().includes(q)
            && !client.company.toLowerCase().includes(q)) continue;
      if (tag && !client.tags.includes(tag)) continue;
      if (status && client.status !== status) continue;

      results.push(client);
    }

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify(results, null, 2),
      }],
    };
  }
);

// --- get_client: read a single client record ---
server.tool(
  "get_client",
  "Get full details for a specific client by slug",
  { slug: z.string().describe("Client slug (filename without .md)") },
  async ({ slug }) => {
    const filePath = path.join(CRM_DIR, `${slug}.md`);
    const client = await parseClientFile(filePath);

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify(client, null, 2),
      }],
    };
  }
);

// --- update_client: modify frontmatter fields ---
server.tool(
  "update_client",
  "Update specific fields on a client record",
  {
    slug: z.string().describe("Client slug"),
    status: z.string().optional(),
    tags: z.array(z.string()).optional(),
    lastContacted: z.string().optional(),
    dealValue: z.number().optional(),
  },
  async ({ slug, ...updates }) => {
    const filePath = path.join(CRM_DIR, `${slug}.md`);
    const updated = await updateClientFile(filePath, updates);

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify(updated, null, 2),
      }],
    };
  }
);

// --- Start server ---
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch(console.error);

Practical Applications

In the merchant dashboard platform, the MCP CRM server allows Claude Desktop to inspect, search, and update client records stored as markdown files in a git repository. A sales rep can ask Claude “show me all active prospects tagged enterprise that I haven’t contacted in 30 days” and the agent translates that into a search_clients call with the appropriate filters. The structured response lets Claude format a summary table, suggest follow-up actions, or even draft outreach messages that reference specific client details from the CRM record.

The update capability is particularly valuable for post-meeting workflows: after a call, the rep tells Claude “update Acme Corp status to active, add the enterprise tag, and set last contacted to today.” Claude issues an update_client call that modifies only the specified frontmatter fields, preserving all existing notes and interaction history. Because the markdown files are git-tracked, the update creates a diffable commit that shows exactly what changed, providing a complete audit trail without any additional infrastructure.

Common Pitfalls

Further Reading