@apideck/docs-mcp
Version:
MCP server that lets agents search and retrieve your documentation at query time instead of relying on stale training data.
183 lines • 9.87 kB
JavaScript
// SPDX-License-Identifier: MIT
import { z } from 'zod';
import { sectionUrl } from './store.js';
export const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
/** Build a tool result: text for the model plus optional structured content, for reuse by host-defined tools. */
export const toolResult = (body, structured, isError = false) => ({
content: [{ type: 'text', text: body }],
...(structured !== undefined ? { structuredContent: structured } : {}),
...(isError ? { isError: true } : {}),
});
const text = toolResult;
const isoDate = (d) => d.toISOString().slice(0, 10);
const pageHeader = (page) => {
const lines = [`# ${page.title}`, '', `path: ${page.path}`];
if (page.url !== undefined)
lines.push(`url: ${page.url}`);
if (page.description !== undefined)
lines.push(`description: ${page.description}`);
lines.push(`last_modified: ${isoDate(page.lastModified)}`, `words: ${page.words}`);
return lines.join('\n');
};
const outlineSections = (page) => {
const headed = page.sections.filter((s) => s.level > 0);
const h1s = headed.filter((s) => s.level === 1);
// A single H1 that is the page title adds nothing to the outline.
return h1s.length === 1 && h1s[0]?.heading === page.title ? headed.filter((s) => s.level !== 1) : headed;
};
const sectionOutline = (page) => {
const sections = outlineSections(page);
const minLevel = Math.min(...sections.map((s) => s.level));
return sections.map((s) => `${' '.repeat(s.level - minLevel)}- ${s.heading} (#${s.anchor})`).join('\n');
};
const searchSchema = {
query: z.string().min(1).describe('Free-text query. Prefix matching and light fuzzy matching are applied per term.'),
limit: z.number().int().min(1).max(25).default(8).describe('Maximum number of results.'),
path_prefix: z
.string()
.optional()
.describe('Only search pages whose path starts with this prefix, e.g. "guides" or "api/reference".'),
};
const getSchema = {
path: z
.string()
.min(1)
.describe('Page path from search_docs or list_docs, e.g. "guides/getting-started" (extensions and "docs://" prefix are accepted), or a full https:// URL to the page, optionally with a #fragment.'),
section: z
.string()
.optional()
.describe('Return only this section: an anchor ("#install" or "install") or the heading text. Defaults to the #fragment of `path` when `path` is a URL.'),
};
const listSchema = {
path_prefix: z.string().optional().describe('Only list pages under this path prefix.'),
limit: z.number().int().min(1).max(1000).default(200).describe('Maximum number of pages to list.'),
};
export const createDocTools = (store) => {
const searchDocs = {
name: 'search_docs',
title: 'Search documentation',
description: 'Full-text search over the documentation. Returns matching sections with a snippet, the page path and heading anchor. Call get_doc with a returned path to read the full page or a single section.',
inputSchema: searchSchema,
annotations: READ_ONLY,
handler: async ({ query, limit, path_prefix }) => {
const hits = store.search(query, {
limit,
...(path_prefix !== undefined ? { pathPrefix: path_prefix } : {}),
});
const stats = store.stats();
if (hits.length === 0) {
return text(`No results for "${query}" across ${stats.pages} pages. Try fewer or broader terms, or list_docs to browse.`, { query, results: [] });
}
const lines = [`${hits.length} result(s) for "${query}" (${stats.pages} pages indexed):`, ''];
hits.forEach((h, i) => {
const where = h.heading ? `${h.title} › ${h.heading}` : h.title;
const ref = h.anchor ? `${h.path}#${h.anchor}` : h.path;
lines.push(`${i + 1}. ${where}`, ` path: ${ref}`);
if (h.url !== undefined)
lines.push(` url: ${h.url}`);
if (h.snippet)
lines.push(` ${h.snippet}`);
lines.push('');
});
return text(lines.join('\n').trimEnd(), { query, results: hits });
},
};
const getDoc = {
name: 'get_doc',
title: 'Get documentation page',
description: 'Return the full markdown of a documentation page by path, or a single section of it. Use search_docs or list_docs to find paths.',
inputSchema: getSchema,
annotations: READ_ONLY,
handler: async ({ path, section }) => {
const isUrl = /^https?:\/\//i.test(path);
const resolved = isUrl ? store.getByUrl(path) : undefined;
const page = resolved?.page ?? (isUrl ? undefined : store.get(path));
if (!page) {
const suggestions = isUrl
? []
: store.search(path.replace(/[/#._-]+/g, ' '), { limit: 5, perPage: 1 });
const hint = suggestions.length > 0
? `\n\nDid you mean:\n${suggestions.map((s) => `- ${s.path} (${s.title})`).join('\n')}`
: '';
return text(`No page at "${path}".${hint}`, { path, found: false }, true);
}
// An explicit `section` argument must resolve or the call errors. A
// section implied only by the URL's #fragment is best-effort: some
// fragments are page-relative UI state rather than a heading (or the
// page's own URL already carries a fragment, e.g. a Redoc deep link),
// so a miss there falls through to the whole page instead of erroring.
const explicitSection = section?.trim();
const effectiveSection = explicitSection || resolved?.anchor;
if (effectiveSection !== undefined && effectiveSection !== '') {
const wanted = effectiveSection.replace(/^#/, '').toLowerCase();
const match = page.sections.find((s) => s.level > 0 && (s.anchor.toLowerCase() === wanted || s.heading.toLowerCase() === wanted));
if (!match && explicitSection) {
return text(`No section "${explicitSection}" in ${page.path}. Available sections:\n${sectionOutline(page) || '(none)'}`, { path: page.path, found: false }, true);
}
if (match) {
// Include nested sub-sections so the caller gets the whole subtree.
const start = page.sections.indexOf(match);
const subtree = [match];
for (let i = start + 1; i < page.sections.length; i += 1) {
const s = page.sections[i];
if (!s || s.level <= match.level)
break;
subtree.push(s);
}
const body = subtree
.map((s) => `${'#'.repeat(s.level)} ${s.heading}\n\n${s.content}`.trimEnd())
.join('\n\n');
return text(`${pageHeader(page)}\nsection: #${match.anchor}\n\n---\n\n${body}`, {
path: page.path,
title: page.title,
section: match.anchor,
...(page.url !== undefined ? { url: sectionUrl(page, match.anchor) } : {}),
content: body,
});
}
}
const outline = sectionOutline(page);
return text(`${pageHeader(page)}${outline ? `\n\nsections:\n${outline}` : ''}\n\n---\n\n${page.body.trim()}`, {
path: page.path,
title: page.title,
...(page.url !== undefined ? { url: page.url } : {}),
last_modified: page.lastModified.toISOString(),
sections: outlineSections(page).map((s) => ({ heading: s.heading, anchor: s.anchor, level: s.level })),
content: page.body.trim(),
});
},
};
const listDocs = {
name: 'list_docs',
title: 'List documentation pages',
description: 'List documentation pages with their path, title, description and last-modified date. Useful to browse the structure before searching or to find pages under a prefix.',
inputSchema: listSchema,
annotations: READ_ONLY,
handler: async ({ path_prefix, limit }) => {
const all = store.listPages(path_prefix);
const pages = all.slice(0, limit);
const stats = store.stats();
const header = path_prefix !== undefined
? `${all.length} page(s) under "${path_prefix}"${all.length > limit ? `, showing ${limit}` : ''}:`
: `${all.length} page(s)${all.length > limit ? `, showing ${limit}` : ''}:`;
const lines = pages.map((p) => {
const desc = p.description !== undefined ? ` — ${p.description}` : '';
return `- ${p.path}: ${p.title}${desc} (${p.words} words, updated ${isoDate(p.lastModified)})`;
});
return text([header, ...lines].join('\n'), {
total: all.length,
indexed_at: stats.indexedAt?.toISOString() ?? null,
pages: pages.map((p) => ({
path: p.path,
title: p.title,
...(p.description !== undefined ? { description: p.description } : {}),
...(p.url !== undefined ? { url: p.url } : {}),
words: p.words,
last_modified: p.lastModified.toISOString(),
})),
});
},
};
return [searchDocs, getDoc, listDocs];
};
//# sourceMappingURL=tools.js.map