@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
284 lines (246 loc) • 7.61 kB
JavaScript
/**
* Vizzly Docs MCP Server
* Provides Claude Code with easy access to Vizzly documentation
*
* This server fetches docs from the deployed docs.vizzly.dev site,
* making it easy for LLMs to navigate and retrieve documentation content.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { fetchDocsIndex, fetchDocContent, searchDocs } from './docs-fetcher.js';
class VizzlyDocsMCPServer {
constructor() {
this.server = new Server(
{
name: 'vizzly-docs',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
// Cache the index for the session
this.indexCache = null;
this.indexFetchTime = null;
this.CACHE_TTL = 5 * 60 * 1000; // 5 minutes
this.setupHandlers();
}
/**
* Get the docs index (with caching)
*/
async getIndex() {
let now = Date.now();
if (!this.indexCache || !this.indexFetchTime || now - this.indexFetchTime > this.CACHE_TTL) {
this.indexCache = await fetchDocsIndex();
this.indexFetchTime = now;
}
return this.indexCache;
}
setupHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_docs',
description:
'List all available Vizzly documentation pages. Returns title, description, category, and URL for each doc. Optionally filter by category.',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
description:
'Optional category filter (e.g., "Integration > CLI", "Features"). Case-insensitive partial match.'
}
}
}
},
{
name: 'get_doc',
description:
'Get the full markdown content of a specific documentation page. Returns the raw MDX/markdown with frontmatter. Use the path or slug from list_docs.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description:
'The document path (e.g., "integration/cli/overview.mdx") or slug (e.g., "integration/cli/overview"). Get this from list_docs or search_docs.'
}
},
required: ['path']
}
},
{
name: 'search_docs',
description:
'Search documentation by keyword. Searches in titles and descriptions. Returns matching docs with relevance scores.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (e.g., "TDD mode", "authentication", "parallel builds")'
},
limit: {
type: 'number',
description: 'Maximum number of results to return (default: 10)',
default: 10
}
},
required: ['query']
}
},
{
name: 'get_sidebar',
description:
'Get the complete sidebar navigation structure. Useful for understanding how docs are organized and finding related pages.',
inputSchema: {
type: 'object',
properties: {}
}
}
]
}));
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async request => {
try {
switch (request.params.name) {
case 'list_docs':
return await this.handleListDocs(request.params.arguments);
case 'get_doc':
return await this.handleGetDoc(request.params.arguments);
case 'search_docs':
return await this.handleSearchDocs(request.params.arguments);
case 'get_sidebar':
return await this.handleGetSidebar();
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error.message}`
}
],
isError: true
};
}
});
}
async handleListDocs(args) {
let index = await this.getIndex();
let { category } = args || {};
let docs = index.docs;
// Filter by category if provided
if (category) {
let lowerCategory = category.toLowerCase();
docs = docs.filter(doc => doc.category.toLowerCase().includes(lowerCategory));
}
// Format response
let response = `# Vizzly Documentation (${docs.length} docs)\n\n`;
if (category) {
response += `Filtered by category: "${category}"\n\n`;
}
// Group by category
let byCategory = {};
for (let doc of docs) {
if (!byCategory[doc.category]) {
byCategory[doc.category] = [];
}
byCategory[doc.category].push(doc);
}
for (let [cat, catDocs] of Object.entries(byCategory)) {
response += `## ${cat}\n\n`;
for (let doc of catDocs) {
response += `- **${doc.title}**\n`;
response += ` - Path: \`${doc.path}\`\n`;
response += ` - Slug: \`${doc.slug}\`\n`;
if (doc.description) {
response += ` - ${doc.description}\n`;
}
response += ` - URL: ${doc.url}\n\n`;
}
}
return {
content: [
{
type: 'text',
text: response
}
]
};
}
async handleGetDoc(args) {
let { path } = args;
if (!path) {
throw new Error('path parameter is required');
}
let content = await fetchDocContent(path);
return {
content: [
{
type: 'text',
text: content
}
]
};
}
async handleSearchDocs(args) {
let { query, limit = 10 } = args;
if (!query) {
throw new Error('query parameter is required');
}
let index = await this.getIndex();
let results = searchDocs(index.docs, query, limit);
let response = `# Search Results for "${query}"\n\n`;
response += `Found ${results.length} matching docs:\n\n`;
for (let result of results) {
response += `## ${result.doc.title}\n`;
response += `- **Category:** ${result.doc.category}\n`;
response += `- **Path:** \`${result.doc.path}\`\n`;
response += `- **Relevance:** ${Math.round(result.score * 100)}%\n`;
if (result.doc.description) {
response += `- **Description:** ${result.doc.description}\n`;
}
response += `- **URL:** ${result.doc.url}\n\n`;
}
return {
content: [
{
type: 'text',
text: response
}
]
};
}
async handleGetSidebar() {
let index = await this.getIndex();
let response = `# Vizzly Documentation Structure\n\n`;
response += JSON.stringify(index.sidebar, null, 2);
return {
content: [
{
type: 'text',
text: response
}
]
};
}
async run() {
let transport = new StdioServerTransport();
await this.server.connect(transport);
}
}
// Start the server
let server = new VizzlyDocsMCPServer();
server.run().catch(error => {
console.error('Server error:', error);
process.exit(1);
});