feat(docs): doc-sections system, crdt package, MCP server, and responsive fixes
Adds a hand-authored .vue doc-sections system (intro + guide pages per package, #docs/sections map, sidebar Guide group, client-side TOC), registers @robonen/crdt, renders demos client-only, base64-encodes the server-metadata virtual, plus the MCP docs endpoint and responsive/overflow fixes across pages and tables.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Builds a configured MCP server exposing the @robonen/tools documentation.
|
||||
*
|
||||
* Transport-agnostic: the same server is mounted on the Nuxt/Nitro HTTP route
|
||||
* (see `docs/server/routes/mcp.post.ts`). Given already-extracted
|
||||
* {@link DocsMetadata}, it registers the docs tools and resources.
|
||||
*/
|
||||
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { DocsMetadata } from '../extractor/types';
|
||||
import type { Leaf } from './docs-index';
|
||||
import { buildLeaves, getPackage, resolveEntry, search } from './docs-index';
|
||||
import {
|
||||
renderDocEntry,
|
||||
renderPackageList,
|
||||
renderPackageOverview,
|
||||
renderSearchResults,
|
||||
} from './format';
|
||||
|
||||
const INSTRUCTIONS = [
|
||||
'Documentation for the @robonen/tools monorepo (core utilities, Vue composables &',
|
||||
'primitives, shared configs). Workflow: call `list_packages` to see what exists,',
|
||||
'`search_docs` to find an item by keyword, `get_package` for a package\'s table of',
|
||||
'contents, and `get_doc` for the full reference (signatures, params, examples,',
|
||||
'props/emits, demo source) of one item. Slugs are kebab-case; names are as exported.',
|
||||
].join(' ');
|
||||
|
||||
/** Wrap a not-found message as a non-fatal tool error. */
|
||||
function toolError(message: string) {
|
||||
return { content: [{ type: 'text' as const, text: message }], isError: true };
|
||||
}
|
||||
|
||||
/** Wrap rendered Markdown as a successful tool result. */
|
||||
function toolText(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }] };
|
||||
}
|
||||
|
||||
function registerTools(server: McpServer, metadata: DocsMetadata, leaves: Leaf[]): void {
|
||||
server.registerTool(
|
||||
'list_packages',
|
||||
{
|
||||
title: 'List documentation packages',
|
||||
description:
|
||||
'List every documented @robonen/tools package (core, vue, configs, infra) with its kind, '
|
||||
+ 'version, description and entry count. Start here to discover what is available.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => toolText(renderPackageList(metadata)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'search_docs',
|
||||
{
|
||||
title: 'Search documentation',
|
||||
description:
|
||||
'Full-text search across all documented functions, classes, types, components and guide '
|
||||
+ 'sections. Returns ranked matches as `package/slug` references to pass to get_doc.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe('Search terms, e.g. "debounce", "clipboard", "eslint imports".'),
|
||||
limit: z.number().int().min(1).max(50).optional().describe('Maximum number of results (default 20).'),
|
||||
},
|
||||
},
|
||||
async ({ query, limit }) => toolText(renderSearchResults(search(leaves, query, limit ?? 20), query)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_package',
|
||||
{
|
||||
title: 'Get a package overview',
|
||||
description:
|
||||
'Show a package\'s full table of contents: categories and items (api), components and their '
|
||||
+ 'parts (components), or sections (guide). Pass the package slug from list_packages.',
|
||||
inputSchema: {
|
||||
slug: z.string().min(1).describe('Package slug, e.g. "stdlib", "toolkit", "primitives".'),
|
||||
},
|
||||
},
|
||||
async ({ slug }) => {
|
||||
const pkg = getPackage(metadata, slug);
|
||||
if (!pkg) return toolError(`No package with slug "${slug}". Call list_packages to see available slugs.`);
|
||||
return toolText(renderPackageOverview(pkg));
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_doc',
|
||||
{
|
||||
title: 'Get full documentation for an item',
|
||||
description:
|
||||
'Return the complete documentation for a single function, class, type, component or guide '
|
||||
+ 'section: signatures, parameters, return type, examples, props/emits, demo source and the '
|
||||
+ 'source path. Pass the package slug plus the item slug or its exported name.',
|
||||
inputSchema: {
|
||||
package: z.string().min(1).describe('Package slug, e.g. "stdlib".'),
|
||||
name: z.string().min(1).describe('Item slug or exported name, e.g. "clamp" or "useClipboard".'),
|
||||
},
|
||||
},
|
||||
async ({ package: pkgSlug, name }) => {
|
||||
const entry = resolveEntry(leaves, pkgSlug, name);
|
||||
if (!entry) {
|
||||
return toolError(
|
||||
`No documented item "${name}" in package "${pkgSlug}". Call get_package("${pkgSlug}") to list its items.`,
|
||||
);
|
||||
}
|
||||
return toolText(renderDocEntry(entry));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerResources(server: McpServer, metadata: DocsMetadata, leaves: Leaf[]): void {
|
||||
// The whole table of contents as a single browsable resource.
|
||||
server.registerResource(
|
||||
'docs-index',
|
||||
'robonen-docs://index',
|
||||
{
|
||||
title: '@robonen/tools documentation index',
|
||||
description: 'Table of contents for every documented package.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
async uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderPackageList(metadata) }],
|
||||
}),
|
||||
);
|
||||
|
||||
// One resource per documented leaf, addressable as robonen-docs://<package>/<slug>.
|
||||
server.registerResource(
|
||||
'docs-entry',
|
||||
new ResourceTemplate('robonen-docs://{package}/{slug}', {
|
||||
list: async () => ({
|
||||
resources: leaves.map(leaf => ({
|
||||
uri: `robonen-docs://${leaf.packageSlug}/${leaf.slug}`,
|
||||
name: `${leaf.packageName} / ${leaf.name}`,
|
||||
description: leaf.description || `${leaf.badge} in ${leaf.packageName}`,
|
||||
mimeType: 'text/markdown',
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
title: 'Documentation entry',
|
||||
description: 'Full documentation for a single documented item.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
async (uri, variables) => {
|
||||
const pkgSlug = String(variables.package);
|
||||
const slug = String(variables.slug);
|
||||
const entry = resolveEntry(leaves, pkgSlug, slug);
|
||||
if (!entry) throw new Error(`Unknown documentation resource: ${uri.href}`);
|
||||
return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderDocEntry(entry) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Build an MCP server for the given documentation metadata, ready to connect to a transport. */
|
||||
export function createDocsMcpServer(metadata: DocsMetadata, version = '0.0.0'): McpServer {
|
||||
const leaves = buildLeaves(metadata);
|
||||
const server = new McpServer({ name: 'robonen-docs', version }, { instructions: INSTRUCTIONS });
|
||||
registerTools(server, metadata, leaves);
|
||||
registerResources(server, metadata, leaves);
|
||||
return server;
|
||||
}
|
||||
Reference in New Issue
Block a user