Render signature-level parameter docs and surface default exports in MCP bundles

This commit is contained in:
2026-10-18 18:52:10 +05:30
parent f7a997b4ce
commit cf37bff9fd
2 changed files with 121 additions and 15 deletions

View File

@@ -1,22 +1,58 @@
import { ReflectionKind } from 'typedoc'; import { ReflectionKind } from 'typedoc';
/** A single documented parameter (name + rendered description). */
export interface ParamDoc {
name: string;
description: string;
}
/** /**
* Convert a TypeDoc serialized comment into a Google-style markdown docstring. * Convert a TypeDoc serialized comment into a Google-style markdown docstring.
* *
* TypeDoc stores comments as a list of display parts (text/code/inline-tag) * TypeDoc stores comments as a list of display parts (text/code/inline-tag)
* plus blockTags (e.g. @example, @returns, @param). This function renders * plus blockTags (e.g. @example, @returns). It moves `@param`/`@typeParam`
* them back to a stable markdown string so both MkDocs and MCP consumers * descriptions onto the signature's parameters, so those are passed in
* separately as `params`/`typeParams` and rendered as grouped sections.
*
* The result is a stable markdown string so both MkDocs and MCP consumers
* receive the same human-readable documentation. * receive the same human-readable documentation.
*/ */
export function renderComment(comment: any): string { export function renderComment(
if (!comment) return ''; comment: any,
params?: ParamDoc[],
typeParams?: ParamDoc[]
): string {
const documentedParams = (params || []).filter((p) => (p.description || '').trim());
const documentedTypeParams = (typeParams || []).filter((p) => (p.description || '').trim());
if (!comment && !documentedParams.length && !documentedTypeParams.length) return '';
const lines: string[] = []; const lines: string[] = [];
const summary = renderParts(comment.summary); const summary = renderParts(comment?.summary);
if (summary.trim()) lines.push(summary.trim()); if (summary.trim()) lines.push(summary.trim());
for (const tag of comment.blockTags || []) { if (documentedTypeParams.length) {
lines.push('');
lines.push('**Type Parameters:**');
lines.push('');
for (const p of documentedTypeParams) {
lines.push(`- \`${p.name}\` — ${oneLine(p.description)}`);
}
}
if (documentedParams.length) {
lines.push('');
lines.push('**Parameters:**');
lines.push('');
for (const p of documentedParams) {
lines.push(`- \`${p.name}\` — ${oneLine(p.description)}`);
}
}
for (const tag of comment?.blockTags || []) {
const tagName = (tag.tag || '').replace(/^@/, ''); const tagName = (tag.tag || '').replace(/^@/, '');
// @param / @typeParam are rendered from signature parameters above.
if (tagName === 'param' || tagName === 'typeParam' || tagName === 'template') continue;
const content = renderParts(tag.content).trim(); const content = renderParts(tag.content).trim();
if (!content) continue; if (!content) continue;
const heading = tagHeading(tagName); const heading = tagHeading(tagName);
@@ -29,6 +65,11 @@ export function renderComment(comment: any): string {
return lines.join('\n').trim(); return lines.join('\n').trim();
} }
/** Collapse a possibly multi-line description to a single line. */
function oneLine(text: string): string {
return (text || '').trim().replace(/\s*\n\s*/g, ' ');
}
function tagHeading(tag: string): string { function tagHeading(tag: string): string {
switch (tag) { switch (tag) {
case 'param': case 'param':

View File

@@ -4,7 +4,7 @@ import {
resolveModulePath, resolveModulePath,
collectModules collectModules
} from '../models/typedoc.js'; } from '../models/typedoc.js';
import { renderComment, mapKind, classifyReact } from './docstring.js'; import { renderComment, renderParts, mapKind, classifyReact, ParamDoc } from './docstring.js';
import { bestSignature } from './signature.js'; import { bestSignature } from './signature.js';
export interface McpBundle { export interface McpBundle {
@@ -60,9 +60,15 @@ export class TypeDocJsonToMCP {
private renderModule(mod: any, modPath: string): Record<string, unknown> { private renderModule(mod: any, modPath: string): Record<string, unknown> {
const objects: Record<string, unknown> = {}; const objects: Record<string, unknown> = {};
for (const child of mod.children || []) { const children = (mod.children || []).filter((c: any) => c.kind !== RefKind.Module);
if (child.kind === RefKind.Module) continue; const siblingNames = new Set(children.map((c: any) => c.name));
objects[child.name] = this.renderObject(child, modPath); const baseName = moduleBaseName(modPath);
for (const child of children) {
// TypeDoc names default exports "default"; surface them under the
// module's own name (a React component/module convention) unless a
// sibling already claims that name.
const name = child.name === 'default' && !siblingNames.has(baseName) ? baseName : child.name;
objects[name] = this.renderObject(child, modPath, name);
} }
return { return {
path: modPath, path: modPath,
@@ -71,16 +77,17 @@ export class TypeDocJsonToMCP {
}; };
} }
private renderObject(ref: any, parentPath: string): Record<string, unknown> { private renderObject(ref: any, parentPath: string, nameOverride?: string): Record<string, unknown> {
const kindName = classifyReact(ref.name, mapKind(ref.kind)); const name = nameOverride ?? ref.name;
const path = `${parentPath}.${ref.name}`; const kindName = classifyReact(name, mapKind(ref.kind));
const path = `${parentPath}.${name}`;
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
name: ref.name, name,
kind: kindName, kind: kindName,
path, path,
signature: bestSignature(ref), signature: bestSignature(ref),
docstring: renderComment(ref.comment) docstring: this.renderDocstring(ref)
}; };
// Prefer an explicit @kind tag when present (mirrors doc-forge kinds). // Prefer an explicit @kind tag when present (mirrors doc-forge kinds).
@@ -93,6 +100,37 @@ export class TypeDocJsonToMCP {
return data; return data;
} }
/**
* Render a reflection's docstring, sourcing the summary/blockTags from the
* signature when TypeDoc has moved them there (functions, method-like
* type aliases) and attaching parameter documentation.
*/
private renderDocstring(ref: any): string {
const sig = bestSignatureRef(ref);
const refComment = ref.comment;
const sigComment = sig?.comment;
const params: ParamDoc[] = (sig?.parameters || [])
.map((p: any) => ({ name: p.name, description: renderParts(p.comment?.summary) }))
.filter((p: ParamDoc) => (p.description || '').trim().length > 0);
const typeParams: ParamDoc[] = (sig?.typeParameters || [])
.map((p: any) => ({ name: p.name, description: renderParts(p.comment?.summary) }))
.filter((p: ParamDoc) => (p.description || '').trim().length > 0);
const summary =
refComment?.summary && refComment.summary.length
? refComment.summary
: sigComment?.summary;
const blockTags = dedupeTags([
...(refComment?.blockTags || []),
...(sigComment?.blockTags || [])
]);
return renderComment({ summary, blockTags }, params, typeParams);
}
private collectMembers(ref: any, parentPath: string): Record<string, unknown> { private collectMembers(ref: any, parentPath: string): Record<string, unknown> {
const members: Record<string, unknown> = {}; const members: Record<string, unknown> = {};
for (const child of ref.children || []) { for (const child of ref.children || []) {
@@ -111,3 +149,30 @@ function findBlockTag(comment: any, tag: string): string | undefined {
} }
return undefined; return undefined;
} }
/** Last segment of a (dotted) module path, used to name default exports. */
function moduleBaseName(modPath: string): string {
const parts = modPath.split('.');
return parts[parts.length - 1] || modPath;
}
/** Find the signature TypeDoc attached comment data to, if any. */
function bestSignatureRef(ref: any): any {
if (ref?.signatures?.length) return ref.signatures[0];
const decl = ref?.type?.declaration;
if (decl?.signatures?.length) return decl.signatures[0];
return undefined;
}
/** Remove duplicate block tags (same tag + same content) across sources. */
function dedupeTags(tags: any[]): any[] {
const seen = new Set<string>();
const out: any[] = [];
for (const tag of tags) {
const key = `${tag?.tag || ''}|${(tag?.content || []).map((p: any) => p.text ?? '').join('')}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(tag);
}
return out;
}