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';
/** 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.
*
* TypeDoc stores comments as a list of display parts (text/code/inline-tag)
* plus blockTags (e.g. @example, @returns, @param). This function renders
* them back to a stable markdown string so both MkDocs and MCP consumers
* plus blockTags (e.g. @example, @returns). It moves `@param`/`@typeParam`
* 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.
*/
export function renderComment(comment: any): string {
if (!comment) return '';
export function renderComment(
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 summary = renderParts(comment.summary);
const summary = renderParts(comment?.summary);
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(/^@/, '');
// @param / @typeParam are rendered from signature parameters above.
if (tagName === 'param' || tagName === 'typeParam' || tagName === 'template') continue;
const content = renderParts(tag.content).trim();
if (!content) continue;
const heading = tagHeading(tagName);
@@ -29,6 +65,11 @@ export function renderComment(comment: any): string {
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 {
switch (tag) {
case 'param':

View File

@@ -4,7 +4,7 @@ import {
resolveModulePath,
collectModules
} 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';
export interface McpBundle {
@@ -60,9 +60,15 @@ export class TypeDocJsonToMCP {
private renderModule(mod: any, modPath: string): Record<string, unknown> {
const objects: Record<string, unknown> = {};
for (const child of mod.children || []) {
if (child.kind === RefKind.Module) continue;
objects[child.name] = this.renderObject(child, modPath);
const children = (mod.children || []).filter((c: any) => c.kind !== RefKind.Module);
const siblingNames = new Set(children.map((c: any) => c.name));
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 {
path: modPath,
@@ -71,16 +77,17 @@ export class TypeDocJsonToMCP {
};
}
private renderObject(ref: any, parentPath: string): Record<string, unknown> {
const kindName = classifyReact(ref.name, mapKind(ref.kind));
const path = `${parentPath}.${ref.name}`;
private renderObject(ref: any, parentPath: string, nameOverride?: string): Record<string, unknown> {
const name = nameOverride ?? ref.name;
const kindName = classifyReact(name, mapKind(ref.kind));
const path = `${parentPath}.${name}`;
const data: Record<string, unknown> = {
name: ref.name,
name,
kind: kindName,
path,
signature: bestSignature(ref),
docstring: renderComment(ref.comment)
docstring: this.renderDocstring(ref)
};
// Prefer an explicit @kind tag when present (mirrors doc-forge kinds).
@@ -93,6 +100,37 @@ export class TypeDocJsonToMCP {
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> {
const members: Record<string, unknown> = {};
for (const child of ref.children || []) {
@@ -111,3 +149,30 @@ function findBlockTag(comment: any, tag: string): string | 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;
}