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).
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.
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.
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.
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.
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.
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.
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.
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.
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);
}
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);
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.
.lock files or flock) and return a conflict error to the agent if the lock cannot be acquired within a timeout, letting the agent retry.status to an invalid value like “maybe” or set dealValue to a string. The MCP server must validate all incoming field values against the schema before writing. Zod schemas at the tool registration level catch most of these, but add a second validation pass against the full record schema before file writes.search_clients call introduces latency that frustrates the interactive agent experience. Build an in-memory index on server startup that watches for file changes, and search against the index instead of re-parsing files on every call.list_clients responses bloats the context sent to the agent and wastes tokens. Return only frontmatter fields in list and search results; reserve the full notes for get_client calls on specific records.