559 lines
16 KiB
TypeScript
559 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
|
|
import type {Tool} from '@modelcontextprotocol/sdk/types.js';
|
|
|
|
import {cliOptions} from '../build/src/bin/chrome-devtools-mcp-cli-options.js';
|
|
import type {ParsedArguments} from '../build/src/bin/chrome-devtools-mcp-cli-options.js';
|
|
import {buildFlag} from '../build/src/index.js';
|
|
import {
|
|
ToolCategory,
|
|
OFF_BY_DEFAULT_CATEGORIES,
|
|
labels,
|
|
} from '../build/src/tools/categories.js';
|
|
import {createTools} from '../build/src/tools/tools.js';
|
|
|
|
const OUTPUT_PATH = './docs/tool-reference.md';
|
|
const SLIM_OUTPUT_PATH = './docs/slim-tool-reference.md';
|
|
const README_PATH = './README.md';
|
|
|
|
// Extend the MCP Tool type to include our annotations
|
|
interface ToolWithAnnotations extends Tool {
|
|
annotations?: {
|
|
title?: string;
|
|
category?: typeof ToolCategory;
|
|
conditions?: string[];
|
|
};
|
|
}
|
|
|
|
interface ZodCheck {
|
|
kind: string;
|
|
}
|
|
|
|
interface ZodDef {
|
|
typeName: string;
|
|
checks?: ZodCheck[];
|
|
values?: string[];
|
|
type?: ZodSchema;
|
|
innerType?: ZodSchema;
|
|
schema?: ZodSchema;
|
|
defaultValue?: () => unknown;
|
|
}
|
|
|
|
interface ZodSchema {
|
|
_def: ZodDef;
|
|
description?: string;
|
|
}
|
|
|
|
interface TypeInfo {
|
|
type: string;
|
|
enum?: string[];
|
|
items?: TypeInfo;
|
|
description?: string;
|
|
default?: unknown;
|
|
}
|
|
|
|
function escapeHtmlTags(text: string): string {
|
|
return text
|
|
.replace(/&(?![a-zA-Z]+;)/g, '&')
|
|
.replace(/<([a-zA-Z][^>]*)>/g, '<$1>');
|
|
}
|
|
|
|
function addCrossLinks(text: string, tools: ToolWithAnnotations[]): string {
|
|
let result = text;
|
|
|
|
// Create a set of all tool names for efficient lookup
|
|
const toolNames = new Set(tools.map(tool => tool.name));
|
|
|
|
// Sort tool names by length (descending) to match longer names first
|
|
const sortedToolNames = Array.from(toolNames).sort(
|
|
(a, b) => b.length - a.length,
|
|
);
|
|
|
|
for (const toolName of sortedToolNames) {
|
|
// Create regex to match tool name (case insensitive, word boundaries)
|
|
const regex = new RegExp(`\\b${toolName}\\b`, 'gi');
|
|
|
|
result = result.replace(regex, match => {
|
|
// Only create link if the match isn't already inside a link
|
|
if (result.indexOf(`[${match}]`) !== -1) {
|
|
return match; // Already linked
|
|
}
|
|
const anchorLink = toolName.toLowerCase();
|
|
return `[\`${match}\`](#${anchorLink})`;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function sortTools(a: ToolWithAnnotations, b: ToolWithAnnotations): number {
|
|
const aHasConditions = Boolean(a.annotations?.conditions?.length > 0);
|
|
const bHasConditions = Boolean(b.annotations?.conditions?.length > 0);
|
|
|
|
if (aHasConditions && !bHasConditions) {
|
|
return 1;
|
|
}
|
|
if (!aHasConditions && bHasConditions) {
|
|
return -1;
|
|
}
|
|
|
|
return a.name.localeCompare(b.name);
|
|
}
|
|
|
|
function generateToolsTOC(
|
|
categories: Record<string, ToolWithAnnotations[]>,
|
|
sortedCategories: string[],
|
|
): string {
|
|
let toc = '';
|
|
|
|
for (const category of sortedCategories) {
|
|
const categoryTools = categories[category];
|
|
const categoryName = labels[category];
|
|
toc += `- **${categoryName}** (${categoryTools.length} tools)\n`;
|
|
|
|
// Sort tools within category for TOC
|
|
categoryTools.sort(sortTools);
|
|
for (const tool of categoryTools) {
|
|
const anchorLink = tool.name.toLowerCase();
|
|
toc += ` - [\`${tool.name}\`](docs/tool-reference.md#${anchorLink})\n`;
|
|
}
|
|
}
|
|
|
|
return toc;
|
|
}
|
|
|
|
function updateReadmeWithToolsTOC(toolsTOC: string): void {
|
|
const readmeContent = fs.readFileSync(README_PATH, 'utf8');
|
|
|
|
const beginMarker = '<!-- BEGIN AUTO GENERATED TOOLS -->';
|
|
const endMarker = '<!-- END AUTO GENERATED TOOLS -->';
|
|
|
|
const beginIndex = readmeContent.indexOf(beginMarker);
|
|
const endIndex = readmeContent.indexOf(endMarker);
|
|
|
|
if (beginIndex === -1 || endIndex === -1) {
|
|
console.warn('Could not find auto-generated tools markers in README.md');
|
|
return;
|
|
}
|
|
|
|
const before = readmeContent.substring(0, beginIndex + beginMarker.length);
|
|
const after = readmeContent.substring(endIndex);
|
|
|
|
const updatedContent = before + '\n\n' + toolsTOC + '\n' + after;
|
|
|
|
fs.writeFileSync(README_PATH, updatedContent);
|
|
console.log('Updated README.md with tools table of contents');
|
|
}
|
|
|
|
function generateConfigOptionsMarkdown(): string {
|
|
let markdown = '';
|
|
|
|
for (const [optionName, optionConfig] of Object.entries(cliOptions)) {
|
|
// Skip hidden options
|
|
if (optionConfig.hidden) {
|
|
continue;
|
|
}
|
|
|
|
const aliasText = optionConfig.alias ? `, \`-${optionConfig.alias}\`` : '';
|
|
const description = optionConfig.description || optionConfig.describe || '';
|
|
|
|
// Convert camelCase to dash-case
|
|
const dashCaseName = optionName
|
|
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
.toLowerCase();
|
|
const nameDisplay =
|
|
dashCaseName !== optionName
|
|
? `\`--${optionName}\`/ \`--${dashCaseName}\``
|
|
: `\`--${optionName}\``;
|
|
|
|
// Start with option name and description
|
|
markdown += `- **${nameDisplay}${aliasText}**\n`;
|
|
markdown += ` ${description}\n`;
|
|
|
|
// Add type information
|
|
markdown += ` - **Type:** ${optionConfig.type}\n`;
|
|
|
|
// Add choices if available
|
|
if (optionConfig.choices) {
|
|
markdown += ` - **Choices:** ${optionConfig.choices.map(c => `\`${c}\``).join(', ')}\n`;
|
|
}
|
|
|
|
// Add default if available
|
|
markdown += ` - **Default:** \`${optionConfig.default ?? 'false'}\`\n`;
|
|
|
|
markdown += '\n';
|
|
}
|
|
|
|
return markdown.trim();
|
|
}
|
|
|
|
function updateReadmeWithOptionsMarkdown(optionsMarkdown: string): void {
|
|
const readmeContent = fs.readFileSync(README_PATH, 'utf8');
|
|
|
|
const beginMarker = '<!-- BEGIN AUTO GENERATED OPTIONS -->';
|
|
const endMarker = '<!-- END AUTO GENERATED OPTIONS -->';
|
|
|
|
const beginIndex = readmeContent.indexOf(beginMarker);
|
|
const endIndex = readmeContent.indexOf(endMarker);
|
|
|
|
if (beginIndex === -1 || endIndex === -1) {
|
|
console.warn('Could not find auto-generated options markers in README.md');
|
|
return;
|
|
}
|
|
|
|
const before = readmeContent.substring(0, beginIndex + beginMarker.length);
|
|
const after = readmeContent.substring(endIndex);
|
|
|
|
const updatedContent = before + '\n\n' + optionsMarkdown + '\n\n' + after;
|
|
|
|
fs.writeFileSync(README_PATH, updatedContent);
|
|
console.log('Updated README.md with options markdown');
|
|
}
|
|
|
|
// Helper to convert Zod schema to JSON schema-like object for docs
|
|
function getZodTypeInfo(schema: ZodSchema): TypeInfo {
|
|
let description = schema.description;
|
|
let def = schema._def;
|
|
let defaultValue: unknown;
|
|
|
|
// Unwrap optional/default/effects
|
|
while (
|
|
def.typeName === 'ZodOptional' ||
|
|
def.typeName === 'ZodDefault' ||
|
|
def.typeName === 'ZodEffects'
|
|
) {
|
|
if (def.typeName === 'ZodDefault' && def.defaultValue) {
|
|
defaultValue = def.defaultValue();
|
|
}
|
|
const next = def.innerType || def.schema;
|
|
if (!next) {
|
|
break;
|
|
}
|
|
schema = next;
|
|
def = schema._def;
|
|
if (!description && schema.description) {
|
|
description = schema.description;
|
|
}
|
|
}
|
|
|
|
const result: TypeInfo = {type: 'unknown'};
|
|
if (description) {
|
|
result.description = description;
|
|
}
|
|
if (defaultValue !== undefined) {
|
|
result.default = defaultValue;
|
|
}
|
|
|
|
switch (def.typeName) {
|
|
case 'ZodString':
|
|
result.type = 'string';
|
|
break;
|
|
case 'ZodNumber':
|
|
result.type = def.checks?.some((c: ZodCheck) => c.kind === 'int')
|
|
? 'integer'
|
|
: 'number';
|
|
break;
|
|
case 'ZodBoolean':
|
|
result.type = 'boolean';
|
|
break;
|
|
case 'ZodEnum':
|
|
result.type = 'string';
|
|
result.enum = def.values;
|
|
break;
|
|
case 'ZodArray':
|
|
result.type = 'array';
|
|
if (def.type) {
|
|
result.items = getZodTypeInfo(def.type);
|
|
}
|
|
break;
|
|
default:
|
|
result.type = 'unknown';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isRequired(schema: ZodSchema): boolean {
|
|
let def = schema._def;
|
|
while (def.typeName === 'ZodEffects') {
|
|
if (!def.schema) {
|
|
break;
|
|
}
|
|
schema = def.schema;
|
|
def = schema._def;
|
|
}
|
|
return def.typeName !== 'ZodOptional' && def.typeName !== 'ZodDefault';
|
|
}
|
|
|
|
async function generateReference(
|
|
title: string,
|
|
outputPath: string,
|
|
toolsWithAnnotations: ToolWithAnnotations[],
|
|
categories: Record<string, ToolWithAnnotations[]>,
|
|
sortedCategories: string[],
|
|
) {
|
|
console.log(`Found ${toolsWithAnnotations.length} tools`);
|
|
|
|
// Generate markdown documentation
|
|
let markdown = `<!-- AUTO GENERATED DO NOT EDIT - run 'npm run gen' to update-->
|
|
|
|
# ${title}
|
|
|
|
`;
|
|
// Generate table of contents
|
|
for (const category of sortedCategories) {
|
|
const categoryTools = categories[category];
|
|
const categoryName = labels[category];
|
|
const anchorName = categoryName.toLowerCase().replace(/\s+/g, '-');
|
|
markdown += `- **[${categoryName}](#${anchorName})** (${categoryTools.length} tools)\n`;
|
|
|
|
// Sort tools within category for TOC
|
|
categoryTools.sort(sortTools);
|
|
for (const tool of categoryTools) {
|
|
// Generate proper markdown anchor link: backticks are removed, keep underscores, lowercase
|
|
const anchorLink = tool.name.toLowerCase();
|
|
markdown += ` - [\`${tool.name}\`](#${anchorLink})\n`;
|
|
}
|
|
}
|
|
markdown += '\n';
|
|
|
|
for (const category of sortedCategories) {
|
|
const categoryTools = categories[category];
|
|
const categoryName = labels[category];
|
|
|
|
markdown += `## ${categoryName}\n\n`;
|
|
|
|
if (OFF_BY_DEFAULT_CATEGORIES.includes(category)) {
|
|
const flagName = `--${buildFlag(category)}`;
|
|
|
|
markdown += `> NOTE: The ${categoryName} category is not active by default. Use the '${flagName}' flag.\n\n`;
|
|
}
|
|
|
|
// Sort tools within category
|
|
categoryTools.sort(sortTools);
|
|
|
|
for (const tool of categoryTools) {
|
|
markdown += `### \`${tool.name}\`\n\n`;
|
|
|
|
if (tool.description) {
|
|
// Escape HTML tags but preserve JS function syntax
|
|
let escapedDescription = escapeHtmlTags(tool.description);
|
|
|
|
const requiredFlags: string[] = [];
|
|
|
|
const isOffByDefault = OFF_BY_DEFAULT_CATEGORIES.includes(category);
|
|
if (isOffByDefault) {
|
|
const categoryFlag = buildFlag(category);
|
|
requiredFlags.push(`--${categoryFlag}=true`);
|
|
}
|
|
|
|
const conditions = tool.annotations?.conditions || [];
|
|
for (const condition of conditions) {
|
|
requiredFlags.push(`--${condition}=true`);
|
|
}
|
|
|
|
if (requiredFlags.length > 0) {
|
|
escapedDescription += ` (requires flag: ${requiredFlags.join(', ')})`;
|
|
}
|
|
|
|
// Add cross-links to mentioned tools
|
|
escapedDescription = addCrossLinks(
|
|
escapedDescription,
|
|
toolsWithAnnotations,
|
|
);
|
|
markdown += `**Description:** ${escapedDescription}\n\n`;
|
|
}
|
|
|
|
// Handle input schema
|
|
if (
|
|
tool.inputSchema &&
|
|
tool.inputSchema.properties &&
|
|
Object.keys(tool.inputSchema.properties).length > 0
|
|
) {
|
|
const properties = tool.inputSchema.properties;
|
|
const required = tool.inputSchema.required || [];
|
|
|
|
markdown += '**Parameters:**\n\n';
|
|
|
|
const propertyNames = Object.keys(properties).sort((a, b) => {
|
|
const aRequired = required.includes(a);
|
|
const bRequired = required.includes(b);
|
|
if (aRequired && !bRequired) {
|
|
return -1;
|
|
}
|
|
if (!aRequired && bRequired) {
|
|
return 1;
|
|
}
|
|
return a.localeCompare(b);
|
|
});
|
|
for (const propName of propertyNames) {
|
|
const prop = properties[propName] as TypeInfo;
|
|
const isRequired = required.includes(propName);
|
|
const requiredText = isRequired ? ' **(required)**' : ' _(optional)_';
|
|
|
|
let typeInfo = prop.type || 'unknown';
|
|
if (prop.enum) {
|
|
typeInfo = `enum: ${prop.enum.map((v: string) => `"${v}"`).join(', ')}`;
|
|
}
|
|
|
|
markdown += `- **${propName}** (${typeInfo})${requiredText}`;
|
|
if (prop.description) {
|
|
let escapedParamDesc = escapeHtmlTags(prop.description);
|
|
|
|
// Add cross-links to mentioned tools
|
|
escapedParamDesc = addCrossLinks(
|
|
escapedParamDesc,
|
|
toolsWithAnnotations,
|
|
);
|
|
markdown += `: ${escapedParamDesc}`;
|
|
}
|
|
markdown += '\n';
|
|
}
|
|
markdown += '\n';
|
|
} else {
|
|
markdown += '**Parameters:** None\n\n';
|
|
}
|
|
|
|
markdown += '---\n\n';
|
|
}
|
|
}
|
|
|
|
// Write the documentation to file
|
|
fs.writeFileSync(outputPath, markdown.trim() + '\n');
|
|
|
|
console.log(
|
|
`Generated documentation for ${toolsWithAnnotations.length} tools in ${outputPath}`,
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function getToolsAndCategories(tools: any) {
|
|
// Convert ToolDefinitions to ToolWithAnnotations
|
|
const toolsWithAnnotations: ToolWithAnnotations[] = tools
|
|
.filter(tool => {
|
|
// Skipping in_page tools as they are not launched yet
|
|
if (tool.annotations.category === ToolCategory.IN_PAGE) {
|
|
return false;
|
|
}
|
|
|
|
// Skipping internal interop tools not meant for public documentation
|
|
const skipTools = ['get_tab_id'];
|
|
if (skipTools.includes(tool.name)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map(tool => {
|
|
const properties: Record<string, TypeInfo> = {};
|
|
const required: string[] = [];
|
|
|
|
for (const [key, schema] of Object.entries(
|
|
tool.schema as unknown as Record<string, ZodSchema>,
|
|
)) {
|
|
const info = getZodTypeInfo(schema);
|
|
properties[key] = info;
|
|
if (isRequired(schema)) {
|
|
required.push(key);
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties,
|
|
required,
|
|
},
|
|
annotations: tool.annotations,
|
|
};
|
|
});
|
|
// Group tools by category (based on annotations)
|
|
const categories: Record<string, ToolWithAnnotations[]> = {};
|
|
toolsWithAnnotations.forEach((tool: ToolWithAnnotations) => {
|
|
const category = tool.annotations?.category || 'Uncategorized';
|
|
if (!categories[category]) {
|
|
categories[category] = [];
|
|
}
|
|
categories[category].push(tool);
|
|
});
|
|
|
|
// Sort categories using the enum order
|
|
const categoryOrder = Object.values(ToolCategory);
|
|
const sortedCategories = Object.keys(categories).sort((a, b) => {
|
|
const aOff = OFF_BY_DEFAULT_CATEGORIES.includes(a as ToolCategory);
|
|
const bOff = OFF_BY_DEFAULT_CATEGORIES.includes(b as ToolCategory);
|
|
|
|
if (aOff !== bOff) {
|
|
return aOff ? 1 : -1;
|
|
}
|
|
|
|
const aIndex = categoryOrder.indexOf(a as ToolCategory);
|
|
const bIndex = categoryOrder.indexOf(b as ToolCategory);
|
|
|
|
// Put known categories first, unknown categories last
|
|
if (aIndex === -1 && bIndex === -1) {
|
|
return a.localeCompare(b);
|
|
}
|
|
if (aIndex === -1) {
|
|
return 1;
|
|
}
|
|
if (bIndex === -1) {
|
|
return -1;
|
|
}
|
|
return aIndex - bIndex;
|
|
});
|
|
return {toolsWithAnnotations, categories, sortedCategories};
|
|
}
|
|
|
|
async function generateToolDocumentation(): Promise<void> {
|
|
try {
|
|
console.log('Generating tool documentation from definitions...');
|
|
|
|
{
|
|
const {toolsWithAnnotations, categories, sortedCategories} =
|
|
getToolsAndCategories(createTools({slim: false} as ParsedArguments));
|
|
await generateReference(
|
|
'Chrome DevTools MCP Tool Reference',
|
|
OUTPUT_PATH,
|
|
toolsWithAnnotations,
|
|
categories,
|
|
sortedCategories,
|
|
);
|
|
|
|
// Generate tools TOC and update README
|
|
const toolsTOC = generateToolsTOC(categories, sortedCategories);
|
|
updateReadmeWithToolsTOC(toolsTOC);
|
|
}
|
|
|
|
{
|
|
const {toolsWithAnnotations, categories, sortedCategories} =
|
|
getToolsAndCategories(createTools({slim: true} as ParsedArguments));
|
|
await generateReference(
|
|
'Chrome DevTools MCP Slim Tool Reference',
|
|
SLIM_OUTPUT_PATH,
|
|
toolsWithAnnotations,
|
|
categories,
|
|
sortedCategories,
|
|
);
|
|
}
|
|
|
|
// Generate and update configuration options
|
|
const optionsMarkdown = generateConfigOptionsMarkdown();
|
|
updateReadmeWithOptionsMarkdown(optionsMarkdown);
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error('Error generating documentation:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run the documentation generator
|
|
generateToolDocumentation().catch(console.error);
|