Building a Local-First MCP Server for Your Team's Documentation: A Step-by-Step Guide
Build a local-first MCP documentation server so your AI coding assistant actually knows your codebase, APIs, and team conventions.
Here's a problem every team with an AI coding assistant eventually hits: the assistant doesn't know your codebase.
It knows Node.js. It knows Express. It knows PostgreSQL. But it doesn't know that your authentication middleware has a known edge case with concurrent token refresh, or that the user_events table has a non-obvious partition key, or that the internal API for the billing service has a quirk documented only in a Notion page from 2022.
Getting AI agents to reliably use your team's internal documentation is an unsolved problem that most teams handle badly: pasting context into every prompt, which is tedious and doesn't scale.
The Model Context Protocol (MCP) was designed to solve exactly this. A project called Neuledge implements a local-first MCP approach for team documentation. This tutorial walks through the problem, the solution, and how to build or configure a local-first MCP documentation server for your own team.
Why Your Documentation Context Needs to Be Local-First
The obvious approach to giving your AI agent access to team docs is to send them to the cloud: upload your documentation to a service, let the service handle retrieval, have the agent query it. Several products offer this.
There are three reasons local-first is usually the better architecture:
Sensitivity. Your internal documentation contains things you don't want in a third-party's training data or logging infrastructure: architectural decisions, security considerations, undocumented API behaviors, internal credentials that someone pasted into a Notion comment in 2021. Even with enterprise data handling agreements, local-first eliminates the exposure entirely.
Latency. Retrieval from a local MCP server is faster than a round-trip to an external service. When your agent is making multiple tool calls per interaction, this adds up.
Control. You decide what's indexed, how it's chunked, how it's retrieved. You can tune the retrieval for your specific documentation style. You're not dependent on a third-party's indexing quality.
How MCP Works (The Relevant Parts)
MCP is a protocol for connecting AI agents to external tools and data sources. From the agent's perspective, an MCP server is a tool it can call: it sends a request, the server returns data, the agent incorporates the data into its context window.
The minimal interface for a documentation MCP server is:
- A
search_docstool that accepts a query and returns relevant documentation chunks - A
get_doctool that accepts a document identifier and returns the full content
That's it. The server can be arbitrarily sophisticated underneath: semantic search, hybrid search, knowledge graphs. As long as it implements these tools and speaks the MCP protocol, it works.
Building a Minimal MCP Documentation Server
I built one using Node.js and the official MCP SDK. Here's the full implementation.
Prerequisites
npm install @modelcontextprotocol/sdk better-sqlite3 @xenova/transformers
We're using SQLite for simplicity and @xenova/transformers for local embeddings: no external API calls, fully local.
Step 1: Set Up the Document Store
// docs-store.js
const Database = require('better-sqlite3');
const path = require('path');
class DocumentStore {
constructor(dbPath = './docs.db') {
this.db = new Database(dbPath);
this.init();
}
init() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding BLOB,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts
USING fts5(content, document_id, chunk_index);
`);
}
insertDocument(doc) {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO documents (id, title, content, source, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
stmt.run(doc.id, doc.title, doc.content, doc.source || null);
}
insertChunk(chunk) {
const stmt = this.db.prepare(`
INSERT INTO chunks (document_id, chunk_index, content, embedding)
VALUES (?, ?, ?, ?)
`);
stmt.run(chunk.documentId, chunk.index, chunk.content, chunk.embedding || null);
// Also insert into FTS index
const ftsStmt = this.db.prepare(`
INSERT INTO chunks_fts (content, document_id, chunk_index)
VALUES (?, ?, ?)
`);
ftsStmt.run(chunk.content, chunk.documentId, chunk.index);
}
searchFTS(query, limit = 5) {
const stmt = this.db.prepare(`
SELECT c.content, c.document_id, c.chunk_index, d.title, d.source,
rank
FROM chunks_fts fts
JOIN chunks c ON c.document_id = fts.document_id AND c.chunk_index = fts.chunk_index
JOIN documents d ON d.id = c.document_id
WHERE chunks_fts MATCH ?
ORDER BY rank
LIMIT ?
`);
return stmt.all(query, limit);
}
getDocument(id) {
const stmt = this.db.prepare('SELECT * FROM documents WHERE id = ?');
return stmt.get(id);
}
}
module.exports = { DocumentStore };
Step 2: Build the Document Indexer
// indexer.js
const fs = require('fs');
const path = require('path');
const { DocumentStore } = require('./docs-store');
function chunkText(text, chunkSize = 500, overlap = 100) {
const words = text.split(/\s+/);
const chunks = [];
for (let i = 0; i < words.length; i += (chunkSize - overlap)) {
const chunk = words.slice(i, i + chunkSize).join(' ');
if (chunk.trim()) {
chunks.push(chunk);
}
if (i + chunkSize >= words.length) break;
}
return chunks;
}
async function indexMarkdownDirectory(dirPath, store) {
const files = fs.readdirSync(dirPath, { recursive: true });
for (const file of files) {
if (!file.endsWith('.md')) continue;
const fullPath = path.join(dirPath, file);
const content = fs.readFileSync(fullPath, 'utf-8');
// Extract title from first heading or filename
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1] : path.basename(file, '.md');
const docId = file.replace(/\\/g, '/').replace('.md', '');
store.insertDocument({
id: docId,
title,
content,
source: fullPath
});
// Chunk the document for search
const chunks = chunkText(content);
chunks.forEach((chunk, index) => {
store.insertChunk({
documentId: docId,
index,
content: chunk
});
});
console.log(`Indexed: ${docId} (${chunks.length} chunks)`);
}
}
module.exports = { indexMarkdownDirectory };
Step 3: Build the MCP Server
// server.js
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const {
CallToolRequestSchema,
ListToolsRequestSchema,
} = require('@modelcontextprotocol/sdk/types.js');
const { DocumentStore } = require('./docs-store');
const store = new DocumentStore('./docs.db');
const server = new Server(
{ name: 'team-docs-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'search_docs',
description: 'Search team documentation for information relevant to a query. Use this to find internal documentation, architectural decisions, API quirks, and team-specific conventions.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query'
},
limit: {
type: 'number',
description: 'Maximum number of results to return (default: 5)',
default: 5
}
},
required: ['query']
}
},
{
name: 'get_doc',
description: 'Retrieve the full content of a specific documentation page by its ID.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'The document ID (path without .md extension)'
}
},
required: ['id']
}
}
]
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'search_docs') {
const results = store.searchFTS(args.query, args.limit || 5);
if (results.length === 0) {
return {
content: [{ type: 'text', text: 'No documentation found matching that query.' }]
};
}
const formatted = results.map((r, i) =>
`## Result ${i + 1}: ${r.title}\nSource: ${r.document_id}\n\n${r.content}`
).join('\n\n---\n\n');
return {
content: [{ type: 'text', text: formatted }]
};
}
if (name === 'get_doc') {
const doc = store.getDocument(args.id);
if (!doc) {
return {
content: [{ type: 'text', text: `Document not found: ${args.id}` }]
};
}
return {
content: [{ type: 'text', text: `# ${doc.title}\n\n${doc.content}` }]
};
}
throw new Error(`Unknown tool: ${name}`);
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Team docs MCP server running');
}
main().catch(console.error);
Step 4: Configure Claude Code to Use It
Add the server to your Claude Code MCP configuration (~/.claude.json on Windows, ~/.config/claude/claude.json on Linux/Mac):
{
"mcpServers": {
"team-docs": {
"command": "node",
"args": ["/path/to/your/docs-mcp-server/server.js"],
"env": {}
}
}
}
Step 5: Index Your Documentation
// index-docs.js
const { indexMarkdownDirectory } = require('./indexer');
const { DocumentStore } = require('./docs-store');
const store = new DocumentStore('./docs.db');
// Index your docs directory
indexMarkdownDirectory('./docs', store)
.then(() => console.log('Indexing complete'))
.catch(console.error);
Run with: node index-docs.js
What to Index
The value of your documentation MCP server is proportional to what's in it. Start with:
- Architecture decision records (ADRs): Why you made the choices you made
- API documentation: Internal APIs, especially ones with non-obvious behavior
- Runbooks: Operational procedures for common tasks
- Onboarding docs: What new engineers need to know about your stack
- Known issues and workarounds: The institutional knowledge that lives in Slack and gets lost
Avoid indexing things that change constantly without a clear update process: stale documentation is worse than no documentation because the agent will confidently give you wrong information.
Adding Incremental Updates
The indexer above does a full re-index every time you run it. For a production setup, you want incremental updates when docs change. The simplest approach: watch the docs directory for changes and re-index modified files.
const chokidar = require('chokidar');
const watcher = chokidar.watch('./docs', { ignoreInitial: true });
watcher.on('change', (filePath) => {
console.log(`Re-indexing: ${filePath}`);
// Re-index the specific file
indexFile(filePath, store);
});
What Neuledge Gets Right
The Neuledge approach takes this further with structured metadata, relationship modeling between documents, and a more sophisticated retrieval layer. If your documentation has complex cross-references: API specs that reference data models that reference architectural decisions: the relationship modeling becomes important.
The local-first principle they're building around is the right call. Your internal documentation is the institutional knowledge that makes your team productive. It belongs on infrastructure you control.