← Back to DevBytes

Building a Documentation Generator with MCP (Model Context Protocol): Complete Guide

Building a Documentation Generator with MCP (Model Context Protocol): Complete Guide

Documentation is one of those tasks every developer knows they should do more of, yet rarely has the time to maintain. The Model Context Protocol (MCP) — an open standard introduced by Anthropic for connecting AI assistants to external tools and data sources — offers a compelling way to automate documentation generation. By exposing your codebase, schemas, and metadata through an MCP server, an LLM can read, reason about, and produce up-to-date documentation on demand.

In this guide, you'll learn what MCP is, why it's a great fit for documentation generation, how to build a working documentation generator MCP server from scratch, and the best practices that will keep your generated docs accurate and maintainable.

What Is the Model Context Protocol?

MCP is a JSON-RPC 2.0-based protocol that standardizes how AI clients (such as Claude Desktop, IDE extensions, or custom agents) communicate with external "servers" that expose data and actions. An MCP server can provide three kinds of capabilities:

For a documentation generator, resources let the model read source files and config, tools let it write generated Markdown to disk, and prompts codify your documentation style guide so output stays consistent.

Why Use MCP for Documentation Generation?

Traditional doc generators (JSDoc, Sphinx, Javadoc) parse code statically and produce reference output. They're reliable but rigid — they describe what the code does, not why. LLM-powered generators can synthesize explanations, examples, and architecture overviews, but only if they have structured access to the codebase.

MCP gives you the best of both worlds:

How to Use MCP: Project Setup

We'll build an MCP server in TypeScript using the official @modelcontextprotocol/sdk package. The server will expose source files as resources, a generate_docs tool, and a doc-style-guide prompt.

Prerequisites

Initialize the Project

mkdir mcp-docgen && cd mcp-docgen
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Update tsconfig.json to target ES2022 and use NodeNext module resolution:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Building the MCP Server

Create src/server.ts. We'll register one resource template, one tool, and one prompt.

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

const PROJECT_ROOT = process.env.PROJECT_ROOT ?? process.cwd();
const OUTPUT_DIR = path.join(PROJECT_ROOT, "docs", "generated");

const server = new Server(
  { name: "docgen", version: "0.1.0" },
  { capabilities: { resources: {}, tools: {}, prompts: {} } }
);

// --- Resource: read any source file under the project root ---
server.setRequestHandler(
  { method: "resources/list" },
  async () => ({
    resources: [
      {
        uri: "file://src/**",
        name: "Source files",
        description: "Source files in the project",
        mimeType: "text/plain",
      },
    ],
  })
);

server.setRequestHandler(
  { method: "resources/read" },
  async (req: any) => {
    const uri = req.params.uri as string;
    const filePath = uri.replace("file://", "");
    const abs = path.resolve(PROJECT_ROOT, filePath);
    if (!abs.startsWith(PROJECT_ROOT)) {
      throw new Error("Access denied: path outside project root");
    }
    const content = await fs.readFile(abs, "utf8");
    return {
      contents: [{ uri, mimeType: "text/plain", text: content }],
    };
  }
);

// --- Tool: generate_docs ---
const GenerateDocsSchema = z.object({
  target: z.string().describe("Relative path of the source file to document"),
  output: z.string().describe("Relative path of the Markdown file to write"),
});

server.setRequestHandler(
  { method: "tools/list" },
  async () => ({
    tools: [
      {
        name: "generate_docs",
        description:
          "Generate Markdown documentation for a source file. The model should read the file via the resource, then call this tool with the generated Markdown.",
        inputSchema: {
          type: "object",
          properties: {
            target: { type: "string" },
            output: { type: "string" },
            markdown: { type: "string", description: "Generated Markdown content" },
          },
          required: ["target", "output", "markdown"],
        },
      },
    ],
  })
);

server.setRequestHandler(
  { method: "tools/call" },
  async (req: any) => {
    const { target, output, markdown } = req.params;
    const absTarget = path.resolve(PROJECT_ROOT, target);
    const absOutput = path.resolve(OUTPUT_DIR, output);

    if (!absTarget.startsWith(PROJECT_ROOT) || !absOutput.startsWith(OUTPUT_DIR)) {
      return {
        content: [{ type: "text", text: "Error: invalid path" }],
        isError: true,
      };
    }

    await fs.mkdir(path.dirname(absOutput), { recursive: true });
    await fs.writeFile(absOutput, markdown, "utf8");

    return {
      content: [
        {
          type: "text",
          text: `Documentation written to ${path.relative(PROJECT_ROOT, absOutput)}`,
        },
      ],
    };
  }
);

// --- Prompt: doc style guide ---
server.setRequestHandler(
  { method: "prompts/list" },
  async () => ({
    prompts: [
      {
        name: "doc-style-guide",
        description: "Style guide for generating project documentation",
      },
    ],
  })
);

server.setRequestHandler(
  { method: "prompts/get" },
  async (req: any) => {
    if (req.params.name !== "doc-style-guide") {
      throw new Error("Unknown prompt");
    }
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text: `You are a technical writer. Generate documentation in GitHub-flavored Markdown.
Rules:
- Start with a one-paragraph summary.
- Include a "## API" section with one subsection per exported symbol.
- For each symbol: signature, parameter table, return value, and a runnable example.
- Use a "## Notes" section for caveats.
- Keep tone neutral and concise. Avoid marketing language.`,
          },
        },
      ],
    };
  }
);

// --- Start ---
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("docgen MCP server running on stdio");

Note the security check on paths: every resource read and tool write is constrained to the project root and the output directory. This is critical — without it, a model could read or overwrite arbitrary files.

Compile and Run

npx tsc
node dist/server.js

For development, use tsx src/server.ts to skip the build step.

Connecting the Server to a Client

In Claude Desktop, edit claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/) to register the server:

{
  "mcpServers": {
    "docgen": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-docgen/dist/server.js"],
      "env": {
        "PROJECT_ROOT": "/absolute/path/to/your/codebase"
      }
    }
  }
}

Restart Claude Desktop. You can now ask things like "Generate documentation for src/auth/login.ts and write it to auth.md". The assistant will read the file via the resource, apply the style guide prompt, and call generate_docs to persist the result.

Adding AST-Aware Resources

Reading raw source works, but exposing structured symbol metadata produces much better docs. Let's add a second resource that returns a JSON index of exported symbols using a lightweight regex scan (swap in a real parser like typescript or acorn for production use).

async function buildSymbolIndex(dir: string): Promise<any[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  const symbols: any[] = [];
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory() && !entry.name.startsWith("node_modules")) {
      symbols.push(...(await buildSymbolIndex(full)));
    } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".js")) {
      const text = await fs.readFile(full, "utf8");
      const exportMatches = text.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g);
      for (const m of exportMatches) {
        symbols.push({ file: path.relative(PROJECT_ROOT, full), symbol: m[1] });
      }
    }
  }
  return symbols;
}

server.setRequestHandler(
  { method: "resources/read" },
  async (req: any) => {
    if (req.params.uri === "symbols://index") {
      const index = await buildSymbolIndex(PROJECT_ROOT);
      return {
        contents: [
          {
            uri: "symbols://index",
            mimeType: "application/json",
            text: JSON.stringify(index, null, 2),
          },
        ],
      };
    }
    // ... existing file:// handler fallback
  }
);

Now the model can first read symbols://index to discover what exists, then fetch individual files. This two-step pattern dramatically reduces token usage on large codebases.

Best Practices

Conclusion

Building a documentation generator on top of MCP turns a tedious chore into a repeatable, on-demand workflow. By exposing source files and symbol indexes as resources, persisting output through a sandboxed tool, and codifying your writing style in a prompt, you get documentation that stays close to the code without sacrificing readability. The protocol's composable nature means you can extend this same server over time — adding changelog generation, API reference extraction, or integration with your issue tracker — all while keeping the model's access explicit, auditable, and safe. Start small with a single resource and tool, iterate on the prompt until the output matches your team's voice, and you'll have a documentation pipeline that scales with your codebase.

— Ad —

Google AdSense will appear here after approval

← Back to all articles