diff --git a/src/mcp-ts-introspect/README.md b/src/mcp-ts-introspect/README.md new file mode 100644 index 000000000..03eed7db3 --- /dev/null +++ b/src/mcp-ts-introspect/README.md @@ -0,0 +1,150 @@ +# TypeScript Introspect Tool + +A command-line tool for introspecting TypeScript exports from packages, source code, or projects. Can also run as an MCP (Model Context Protocol) server. + +Forked from https://github.com/t3ta/ts-introspect-mcp-server/tree/master/src + +## Usage + +```bash +tools mcp-ts-introspect [options] +``` + +## Modes + +The tool supports three introspection modes: + +### 1. Package Mode + +Introspect TypeScript exports from npm packages: + +```bash +tools mcp-ts-introspect -m package -p typescript -t "Type.*" +tools mcp-ts-introspect -m package -p @types/node --limit 20 +``` + +### 2. Source Mode + +Analyze TypeScript source code directly: + +```bash +tools mcp-ts-introspect -m source -s "export function hello() { return 'world'; }" +``` + +### 3. Project Mode + +Analyze an entire TypeScript project: + +```bash +tools mcp-ts-introspect -m project --project ./my-project +tools mcp-ts-introspect -m project --search-term "^get" --limit 20 +``` + +## Options + +- `-m, --mode MODE` - Introspection mode: package, source, or project +- `-p, --package NAME` - Package name to introspect (for package mode) +- `-s, --source CODE` - TypeScript source code to analyze (for source mode) +- `--project PATH` - Project path to analyze (for project mode, defaults to current directory) +- `--search-paths PATH` - Additional paths to search for packages (can use multiple times) +- `-t, --search-term TERM` - Filter exports by search term (supports regex) +- `--cache` - Enable caching (default: true) +- `--cache-dir DIR` - Cache directory (default: .ts-morph-cache) +- `--limit NUM` - Maximum number of results to return +- `-o, --output DEST` - Output destination: file, clipboard, or stdout (default: stdout) +- `-v, --verbose` - Enable verbose logging +- `-h, --help` - Show help message +- `--mcp` - Run as MCP server + +## Examples + +### Interactive Mode + +Run without arguments for interactive prompts: + +```bash +tools mcp-ts-introspect +``` + +### Find specific exports in a package + +```bash +tools mcp-ts-introspect -m package -p typescript -t "^create" --limit 10 +``` + +### Analyze source code and copy to clipboard + +```bash +tools mcp-ts-introspect -m source -s "$(cat myfile.ts)" -o clipboard +``` + +### Analyze current project + +```bash +tools mcp-ts-introspect -m project --search-term "Controller$" -o exports.json +``` + +## Features + +- **Package Resolution**: Supports npm, yarn, and pnpm package managers +- **Caching**: Speeds up repeated lookups with file-based caching +- **Filtering**: Use regex patterns to filter exports by name, type, or description +- **Multiple Output Formats**: Output to stdout, clipboard, or file +- **JSDoc Support**: Extracts descriptions from JSDoc comments +- **TypeScript Support**: Full TypeScript type information extraction + +## MCP Server Mode + +Run the tool as an MCP server to integrate with AI assistants: + +```bash +tools mcp-ts-introspect --mcp +``` + +### MCP Configuration + +Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "ts-introspect": { + "command": "tools", + "args": ["mcp-ts-introspect", "--mcp"] + } + } +} +``` + +### Available MCP Tools + +When running as an MCP server, the following tools are available: + +1. **introspect-package** - Introspect TypeScript exports from an npm package + + - `packageName` (required): The npm package name + - `searchPaths`: Additional search paths + - `searchTerm`: Regex filter pattern + - `cache`: Enable caching (default: true) + - `cacheDir`: Cache directory + - `limit`: Maximum results + +2. **introspect-source** - Analyze TypeScript source code + + - `sourceCode` (required): TypeScript source to analyze + - `searchTerm`: Regex filter pattern + - `limit`: Maximum results + +3. **introspect-project** - Analyze a TypeScript project + - `projectPath`: Path to project (defaults to current directory) + - `searchTerm`: Regex filter pattern + - `cache`: Enable caching (default: true) + - `cacheDir`: Cache directory + - `limit`: Maximum results + +## Notes + +- The tool requires TypeScript declaration files (.d.ts) for package introspection +- Caching is enabled by default and stores results for 7 days +- Use verbose mode (-v) for debugging and additional logging +- When running as MCP server, logs are written to the GenesisTools log directory diff --git a/src/mcp-ts-introspect/cache.ts b/src/mcp-ts-introspect/cache.ts new file mode 100644 index 000000000..e47dd0dcd --- /dev/null +++ b/src/mcp-ts-introspect/cache.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import logger from "../logger"; +import type { CacheEntry, ExportInfo } from "./types"; + +const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days + +export async function loadCache(cacheDir: string, key: string): Promise { + const cacheFile = join(cacheDir, `${key}.json`); + + if (!existsSync(cacheFile)) { + return null; + } + + try { + const cacheData = (await Bun.file(cacheFile).json()) as CacheEntry; + + // Check if cache is still valid + const age = Date.now() - cacheData.timestamp; + if (age > CACHE_TTL) { + logger.info(`Cache for ${key} is expired (${Math.floor(age / 1000 / 60 / 60)} hours old)`); + return null; + } + + logger.info(`Loaded cache for ${key} (${Math.floor(age / 1000 / 60)} minutes old)`); + return cacheData.exports; + } catch (error) { + logger.warn(`Failed to load cache for ${key}: ${error}`); + return null; + } +} + +export async function saveCache(cacheDir: string, key: string, exports: ExportInfo[]): Promise { + try { + // Ensure cache directory exists + if (!existsSync(cacheDir)) { + await mkdir(cacheDir, { recursive: true }); + } + + const cacheFile = join(cacheDir, `${key}.json`); + const cacheEntry: CacheEntry = { + exports, + timestamp: Date.now(), + }; + + await Bun.write(cacheFile, JSON.stringify(cacheEntry, null, 2)); + logger.info(`Saved cache for ${key} (${exports.length} exports)`); + } catch (error) { + logger.warn(`Failed to save cache for ${key}: ${error}`); + // Don't throw - caching is optional + } +} diff --git a/src/mcp-ts-introspect/exportExtractor.ts b/src/mcp-ts-introspect/exportExtractor.ts new file mode 100644 index 000000000..00c2f3cbb --- /dev/null +++ b/src/mcp-ts-introspect/exportExtractor.ts @@ -0,0 +1,110 @@ +import { type Symbol as MorphSymbol, Node, type SourceFile } from "ts-morph"; +import logger from "../logger"; +import type { ExportInfo } from "./types"; + +export async function extractExports(sourceFile: SourceFile): Promise { + const exports: ExportInfo[] = []; + const exportedSymbols = sourceFile.getExportSymbols(); + + logger.info(`Extracting exports from ${sourceFile.getFilePath()}, found ${exportedSymbols.length} export symbols`); + + for (const symbol of exportedSymbols) { + const exportInfo = processSymbol(symbol); + if (exportInfo) { + exports.push(exportInfo); + } + } + + return exports; +} + +function processSymbol(symbol: MorphSymbol): ExportInfo | null { + const name = symbol.getName(); + + // Skip default exports and internal symbols + if (name === "default" || name.startsWith("_")) { + return null; + } + + const declarations = symbol.getDeclarations(); + if (declarations.length === 0) { + return null; + } + + const declaration = declarations[0]; + const node = declaration as Node; + + // Get type signature + const type = symbol.getTypeAtLocation(node); + const typeSignature = type.getText(node); + + // Get JSDoc description + const description = getDescription(node); + + // Determine kind + const kind = getExportKind(node); + if (!kind) { + return null; + } + + return { + name, + kind, + typeSignature, + description, + }; +} + +function getExportKind(node: Node): ExportInfo["kind"] | null { + if (Node.isTypeAliasDeclaration(node)) { + return "type"; + } else if (Node.isFunctionDeclaration(node)) { + return "function"; + } else if (Node.isClassDeclaration(node)) { + return "class"; + } else if (Node.isVariableDeclaration(node)) { + return "const"; + } else if (Node.isExportSpecifier(node)) { + // For re-exports, check the original declaration + const symbol = node.getSymbol(); + if (symbol) { + const valueDeclaration = symbol.getValueDeclaration(); + if (valueDeclaration) { + return getExportKind(valueDeclaration); + } + } + } + + // Default to const for other types + return "const"; +} + +function getDescription(node: Node): string | null { + // Try to get JSDoc comments + if (!("getJsDocs" in node)) { + return null; + } + + const jsDocs = (node as unknown as { getJsDocs(): Array<{ getDescription(): string }> }).getJsDocs(); + + for (const jsDoc of jsDocs) { + const description = jsDoc.getDescription(); + if (description) { + return description.trim(); + } + } + + // Check parent node for JSDoc (useful for variable declarations) + const parent = node.getParent(); + if (parent && "getJsDocs" in parent) { + const parentJsDocs = (parent as unknown as { getJsDocs(): Array<{ getDescription(): string }> }).getJsDocs(); + for (const jsDoc of parentJsDocs) { + const description = jsDoc.getDescription(); + if (description) { + return description.trim(); + } + } + } + + return null; +} diff --git a/src/mcp-ts-introspect/index.ts b/src/mcp-ts-introspect/index.ts new file mode 100644 index 000000000..96dd8ef6b --- /dev/null +++ b/src/mcp-ts-introspect/index.ts @@ -0,0 +1,354 @@ +import chalk from "chalk"; +import clipboardy from "clipboardy"; +import Enquirer from "enquirer"; +import minimist from "minimist"; +import logger from "../logger"; +import { introspectPackage, introspectProject, introspectSource } from "./introspect"; +import { startMcpServer } from "./mcp-server"; +import type { ExportInfo, IntrospectOptions } from "./types"; + +interface Options { + mode?: string; + package?: string; + source?: string; + project?: string; + searchPaths?: string[]; + searchTerm?: string; + cache?: boolean; + cacheDir?: string; + limit?: number; + output?: string; + verbose?: boolean; + help?: boolean; + mcp?: boolean; + // Aliases + m?: string; + p?: string; + s?: string; + t?: string; + o?: string; + v?: boolean; + h?: boolean; +} + +interface Args extends Options { + _: string[]; // Positional arguments +} + +const prompter = new Enquirer(); + +function showHelp() { + console.log(` +Usage: tools mcp-ts-introspect [options] + +Introspect TypeScript exports from packages, source code, or projects. + +Modes: + -m, --mode MODE Introspection mode: package, source, or project + -p, --package NAME Package name to introspect (for package mode) + -s, --source CODE TypeScript source code to analyze (for source mode) + --project PATH Project path to analyze (for project mode, defaults to current directory) + +Options: + --search-paths PATH Additional paths to search for packages (can use multiple times) + -t, --search-term TERM Filter exports by search term (supports regex) + --cache Enable caching (default: true) + --cache-dir DIR Cache directory (default: .ts-morph-cache) + --limit NUM Maximum number of results to return + -o, --output DEST Output destination: file, clipboard, or stdout (default: stdout) + -v, --verbose Enable verbose logging + -h, --help Show this help message + --mcp Run as MCP server + +Examples: + tools mcp-ts-introspect -m package -p typescript -t "Type.*" -o clipboard + tools mcp-ts-introspect -m source -s "export function hello() { return 'world'; }" + tools mcp-ts-introspect -m project --search-term "^get" --limit 20 + tools mcp-ts-introspect # Interactive mode + tools mcp-ts-introspect --mcp # Run as MCP server +`); +} + +async function getMode(argv: Args): Promise { + if (argv.mode) { + return argv.mode; + } + + // Try to infer mode from other arguments + if (argv.package) { + return "package"; + } + if (argv.source) { + return "source"; + } + if (argv.project !== undefined) { + return "project"; + } + + // Interactive prompt + try { + const response = (await prompter.prompt({ + type: "select", + name: "mode", + message: "Select introspection mode:", + choices: ["package", "source", "project"], + })) as { mode: string }; + + return response.mode; + } catch (error: unknown) { + if (error instanceof Error && error.message === "canceled") { + logger.info("\nOperation cancelled by user."); + process.exit(0); + } + throw error; + } +} + +async function getPackageName(argv: Args): Promise { + if (argv.package) { + return argv.package; + } + + try { + const response = (await prompter.prompt({ + type: "input", + name: "packageName", + message: "Enter package name to introspect:", + initial: "typescript", + })) as { packageName: string }; + + return response.packageName; + } catch (error: unknown) { + if (error instanceof Error && error.message === "canceled") { + logger.info("\nOperation cancelled by user."); + process.exit(0); + } + throw error; + } +} + +async function getSourceCode(argv: Args): Promise { + if (argv.source) { + return argv.source; + } + + try { + const response = (await prompter.prompt({ + type: "input", + name: "source", + message: "Enter TypeScript source code:", + multiline: true, + initial: "export function example() { return 42; }", + })) as { source: string }; + + return response.source; + } catch (error: unknown) { + if (error instanceof Error && error.message === "canceled") { + logger.info("\nOperation cancelled by user."); + process.exit(0); + } + throw error; + } +} + +async function getProjectPath(argv: Args): Promise { + if (typeof argv.project === "string") { + return argv.project; + } + + try { + const response = (await prompter.prompt({ + type: "input", + name: "projectPath", + message: "Enter project path:", + initial: process.cwd(), + })) as { projectPath: string }; + + return response.projectPath; + } catch (error: unknown) { + if (error instanceof Error && error.message === "canceled") { + logger.info("\nOperation cancelled by user."); + process.exit(0); + } + throw error; + } +} + +async function getOutputDestination(argv: Args): Promise { + if (argv.output) { + return argv.output; + } + + try { + const response = (await prompter.prompt({ + type: "select", + name: "output", + message: "Where to output results?", + choices: ["stdout", "clipboard", "file"], + })) as { output: string }; + + if (response.output === "file") { + const fileResponse = (await prompter.prompt({ + type: "input", + name: "filename", + message: "Enter output filename:", + initial: "exports.json", + })) as { filename: string }; + + return fileResponse.filename; + } + + return response.output; + } catch (error: unknown) { + if (error instanceof Error && error.message === "canceled") { + logger.info("\nOperation cancelled by user."); + process.exit(0); + } + throw error; + } +} + +function formatExports(exports: ExportInfo[], verbose: boolean): string { + if (exports.length === 0) { + return "No exports found."; + } + + const output: string[] = []; + + if (verbose) { + // Detailed JSON format + return JSON.stringify(exports, null, 2); + } else { + // Concise human-readable format + output.push(`Found ${exports.length} export(s):\n`); + + exports.forEach((exp, index) => { + output.push(`${index + 1}. ${chalk.cyan(exp.name)} (${chalk.yellow(exp.kind)})`); + output.push(` ${chalk.gray(exp.typeSignature)}`); + if (exp.description) { + output.push(` ${chalk.dim(exp.description)}`); + } + output.push(""); + }); + } + + return output.join("\n"); +} + +async function main() { + const argv = minimist(process.argv.slice(2), { + alias: { + m: "mode", + p: "package", + s: "source", + t: "searchTerm", + o: "output", + v: "verbose", + h: "help", + }, + boolean: ["cache", "verbose", "help", "mcp"], + string: ["mode", "package", "source", "project", "searchTerm", "cacheDir", "output"], + default: { + cache: true, + cacheDir: ".ts-morph-cache", + }, + }); + + if (argv.help) { + showHelp(); + process.exit(0); + } + + // Run as MCP server if --mcp flag is set + if (argv.mcp) { + await startMcpServer(); + return; // The server runs indefinitely + } + + try { + // Get introspection mode + const mode = await getMode(argv); + + // Build options + const options: IntrospectOptions = { + searchPaths: Array.isArray(argv.searchPaths) + ? argv.searchPaths + : argv.searchPaths + ? [argv.searchPaths] + : [], + searchTerm: argv.searchTerm, + cache: argv.cache, + cacheDir: argv.cacheDir, + limit: argv.limit, + }; + + if (argv.verbose) { + logger.info(`Introspection mode: ${mode}`); + logger.info(`Options: ${JSON.stringify(options, null, 2)}`); + } + + let exports: ExportInfo[] = []; + + // Execute introspection based on mode + switch (mode) { + case "package": { + const packageName = await getPackageName(argv); + if (argv.verbose) { + logger.info(`Introspecting package: ${packageName}`); + } + exports = await introspectPackage(packageName, options); + break; + } + + case "source": { + const sourceCode = await getSourceCode(argv); + if (argv.verbose) { + logger.info(`Introspecting source code...`); + } + exports = await introspectSource(sourceCode, options); + break; + } + + case "project": { + const projectPath = await getProjectPath(argv); + if (argv.verbose) { + logger.info(`Introspecting project: ${projectPath}`); + } + exports = await introspectProject(projectPath, options); + break; + } + + default: + logger.error(`Invalid mode: ${mode}`); + process.exit(1); + } + + // Format results + const formattedOutput = formatExports(exports, argv.verbose || false); + + // Handle output + const outputDest = await getOutputDestination(argv); + + if (outputDest === "clipboard") { + await clipboardy.write(formattedOutput); + logger.info("āœ” Results copied to clipboard!"); + } else if (outputDest === "stdout") { + console.log(formattedOutput); + } else { + // Output to file + await Bun.write(outputDest, formattedOutput); + logger.info(`āœ” Results written to ${outputDest}`); + } + } catch (error) { + console.error(`āœ– Error: ${error}`); + if (argv.verbose && error instanceof Error) { + console.error(error.stack || ""); + } + process.exit(1); + } +} + +main().catch((err) => { + console.error(`\nāœ– Unexpected error: ${err}`); + process.exit(1); +}); diff --git a/src/mcp-ts-introspect/introspect.ts b/src/mcp-ts-introspect/introspect.ts new file mode 100644 index 000000000..b36f07463 --- /dev/null +++ b/src/mcp-ts-introspect/introspect.ts @@ -0,0 +1,211 @@ +import crypto from "node:crypto"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { Project, ts } from "ts-morph"; +import logger from "../logger"; +import { loadCache, saveCache } from "./cache"; +import { extractExports } from "./exportExtractor"; +import { findDeclarationFiles, findPackageJsonAndDir } from "./packageResolver"; +import type { ExportInfo, IntrospectOptions } from "./types"; +import { filterExports } from "./utils"; + +const DEFAULT_SEARCH_PATHS = [process.cwd(), dirname(process.cwd())]; + +async function withTimeout(promise: Promise, timeoutMs: number = 60000): Promise { + const timeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Operation timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + return Promise.race([promise, timeout]); +} + +export async function introspectPackage(packageName: string, options: IntrospectOptions = {}): Promise { + const { searchPaths = [], searchTerm, cache = true, cacheDir = ".ts-morph-cache", limit } = options; + + const allSearchPaths = [...searchPaths, ...DEFAULT_SEARCH_PATHS]; + + // Check cache first + if (cache) { + const cached = await loadCache(cacheDir, packageName); + if (cached) { + logger.info(`Using cached results for ${packageName}`); + return filterExports(cached, searchTerm, limit); + } + } + + try { + // Find package location + const packageLocation = await findPackageJsonAndDir(packageName, allSearchPaths); + if (!packageLocation) { + throw new Error(`Could not find package '${packageName}' in search paths: ${allSearchPaths.join(", ")}`); + } + + logger.info(`Found package at: ${packageLocation.packageDir}`); + + // Create ts-morph project + const project = new Project({ + compilerOptions: { + allowJs: true, + declaration: true, + emitDeclarationOnly: true, + noEmit: false, + skipLibCheck: true, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + resolveJsonModule: true, + jsx: ts.JsxEmit.React, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true, + }); + + // Find and add declaration files + const declarationFiles = await findDeclarationFiles(packageLocation); + if (declarationFiles.length === 0) { + throw new Error(`No TypeScript declaration files found for package '${packageName}'`); + } + + logger.info(`Found ${declarationFiles.length} declaration file(s)`); + + const sourceFiles = declarationFiles.map((file) => { + logger.info(`Adding declaration file: ${file}`); + return project.addSourceFileAtPath(file); + }); + + // Extract exports + const allExports: ExportInfo[] = []; + + for (const sourceFile of sourceFiles) { + const exports = await withTimeout( + extractExports(sourceFile), + 30000 // 30s timeout per file + ); + allExports.push(...exports); + } + + // Remove duplicates + const uniqueExports = Array.from(new Map(allExports.map((exp) => [exp.name, exp])).values()); + + // Save to cache + if (cache) { + await saveCache(cacheDir, packageName, uniqueExports); + } + + return filterExports(uniqueExports, searchTerm, limit); + } catch (error) { + logger.error(`Failed to introspect package '${packageName}': ${error}`); + throw error; + } +} + +export async function introspectSource(sourceCode: string, options: IntrospectOptions = {}): Promise { + const { searchTerm, limit } = options; + + try { + // Create in-memory project + const project = new Project({ + compilerOptions: { + allowJs: true, + skipLibCheck: true, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + esModuleInterop: true, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + useInMemoryFileSystem: true, + }); + + // Add source file + const sourceFile = project.createSourceFile("temp.ts", sourceCode); + + // Extract exports + const exports = await withTimeout( + extractExports(sourceFile), + 30000 // 30s timeout + ); + + return filterExports(exports, searchTerm, limit); + } catch (error) { + logger.error(`Failed to introspect source code: ${error}`); + throw error; + } +} + +export async function introspectProject( + projectPath: string = process.cwd(), + options: IntrospectOptions = {} +): Promise { + const { searchTerm, cache = true, cacheDir = ".ts-morph-cache", limit } = options; + + // Generate cache key from project path + const cacheKey = crypto.createHash("md5").update(projectPath).digest("hex"); + + // Check cache first + if (cache) { + const cached = await loadCache(cacheDir, `project-${cacheKey}`); + if (cached) { + logger.info(`Using cached results for project at ${projectPath}`); + return filterExports(cached, searchTerm, limit); + } + } + + try { + // Find tsconfig.json + let tsconfigPath = join(projectPath, "tsconfig.json"); + if (!existsSync(tsconfigPath)) { + // Try parent directory + const parentPath = dirname(projectPath); + tsconfigPath = join(parentPath, "tsconfig.json"); + if (!existsSync(tsconfigPath)) { + throw new Error(`Could not find tsconfig.json in ${projectPath} or its parent directory`); + } + projectPath = parentPath; + } + + logger.info(`Loading project from: ${projectPath}`); + logger.info(`Using tsconfig: ${tsconfigPath}`); + + // Create project from tsconfig + const project = new Project({ + tsConfigFilePath: tsconfigPath, + skipAddingFilesFromTsConfig: false, + }); + + // Get all source files + const sourceFiles = project.getSourceFiles(); + logger.info(`Found ${sourceFiles.length} source file(s)`); + + // Extract exports from all files + const allExports: ExportInfo[] = []; + + for (const sourceFile of sourceFiles) { + try { + const exports = await withTimeout( + extractExports(sourceFile), + 10000 // 10s timeout per file + ); + allExports.push(...exports); + } catch (error) { + logger.warn(`Failed to extract exports from ${sourceFile.getFilePath()}: ${error}`); + // Continue with other files + } + } + + // Remove duplicates + const uniqueExports = Array.from( + new Map(allExports.map((exp) => [`${exp.name}:${exp.typeSignature}`, exp])).values() + ); + + // Save to cache + if (cache) { + await saveCache(cacheDir, `project-${cacheKey}`, uniqueExports); + } + + return filterExports(uniqueExports, searchTerm, limit); + } catch (error) { + logger.error(`Failed to introspect project at '${projectPath}': ${error}`); + throw error; + } +} diff --git a/src/mcp-ts-introspect/mcp-server.ts b/src/mcp-ts-introspect/mcp-server.ts new file mode 100644 index 000000000..91fad2abe --- /dev/null +++ b/src/mcp-ts-introspect/mcp-server.ts @@ -0,0 +1,244 @@ +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 { z } from "zod"; +import logger from "../logger"; +import { introspectPackage, introspectProject, introspectSource } from "./introspect"; +import type { IntrospectOptions } from "./types"; + +// Zod schemas for parameter validation +const IntrospectPackageSchema = z.object({ + packageName: z.string().describe("The name of the npm package to introspect"), + searchPaths: z.array(z.string()).optional().describe("Additional paths to search for the package"), + searchTerm: z.string().optional().describe("Regex pattern to filter exports by name"), + cache: z.boolean().optional().default(true).describe("Enable caching"), + cacheDir: z.string().optional().default(".ts-morph-cache").describe("Cache directory"), + limit: z.number().optional().describe("Maximum number of results to return"), +}); + +const IntrospectSourceSchema = z.object({ + sourceCode: z.string().describe("TypeScript source code to analyze"), + searchTerm: z.string().optional().describe("Regex pattern to filter exports by name"), + limit: z.number().optional().describe("Maximum number of results to return"), +}); + +const IntrospectProjectSchema = z.object({ + projectPath: z.string().optional().describe("Path to the TypeScript project (defaults to current directory)"), + searchTerm: z.string().optional().describe("Regex pattern to filter exports by name"), + cache: z.boolean().optional().default(true).describe("Enable caching"), + cacheDir: z.string().optional().default(".ts-morph-cache").describe("Cache directory"), + limit: z.number().optional().describe("Maximum number of results to return"), +}); + +export async function startMcpServer() { + const server = new Server( + { + name: "mcp-ts-introspect", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } + ); + + // Register tools/call handler + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === "introspect-package") { + try { + const args = IntrospectPackageSchema.parse(request.params.arguments); + const options: IntrospectOptions = { + searchPaths: args.searchPaths, + searchTerm: args.searchTerm, + cache: args.cache, + cacheDir: args.cacheDir, + limit: args.limit, + }; + + const exports = await introspectPackage(args.packageName, options); + + return { + content: [ + { + type: "text", + text: JSON.stringify(exports, null, 2), + }, + ], + }; + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error( + `Invalid arguments: ${error.errors.map((e) => `${e.path}: ${e.message}`).join(", ")}` + ); + } + throw error; + } + } + + if (request.params.name === "introspect-source") { + try { + const args = IntrospectSourceSchema.parse(request.params.arguments); + const options: IntrospectOptions = { + searchTerm: args.searchTerm, + limit: args.limit, + }; + + const exports = await introspectSource(args.sourceCode, options); + + return { + content: [ + { + type: "text", + text: JSON.stringify(exports, null, 2), + }, + ], + }; + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error( + `Invalid arguments: ${error.errors.map((e) => `${e.path}: ${e.message}`).join(", ")}` + ); + } + throw error; + } + } + + if (request.params.name === "introspect-project") { + try { + const args = IntrospectProjectSchema.parse(request.params.arguments); + const options: IntrospectOptions = { + searchTerm: args.searchTerm, + cache: args.cache, + cacheDir: args.cacheDir, + limit: args.limit, + }; + + const exports = await introspectProject(args.projectPath, options); + + return { + content: [ + { + type: "text", + text: JSON.stringify(exports, null, 2), + }, + ], + }; + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error( + `Invalid arguments: ${error.errors.map((e) => `${e.path}: ${e.message}`).join(", ")}` + ); + } + throw error; + } + } + + throw new Error(`Unknown tool: ${request.params.name}`); + }); + + // Register tools/list handler + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: "introspect-package", + description: "Introspect TypeScript exports from an npm package", + inputSchema: { + type: "object", + properties: { + packageName: { + type: "string", + description: "The name of the npm package to introspect", + }, + searchPaths: { + type: "array", + items: { type: "string" }, + description: "Additional paths to search for the package", + }, + searchTerm: { + type: "string", + description: "Regex pattern to filter exports by name", + }, + cache: { + type: "boolean", + description: "Enable caching", + default: true, + }, + cacheDir: { + type: "string", + description: "Cache directory", + default: ".ts-morph-cache", + }, + limit: { + type: "number", + description: "Maximum number of results to return", + }, + }, + required: ["packageName"], + }, + }, + { + name: "introspect-source", + description: "Introspect TypeScript exports from source code", + inputSchema: { + type: "object", + properties: { + sourceCode: { + type: "string", + description: "TypeScript source code to analyze", + }, + searchTerm: { + type: "string", + description: "Regex pattern to filter exports by name", + }, + limit: { + type: "number", + description: "Maximum number of results to return", + }, + }, + required: ["sourceCode"], + }, + }, + { + name: "introspect-project", + description: "Introspect TypeScript exports from a project", + inputSchema: { + type: "object", + properties: { + projectPath: { + type: "string", + description: "Path to the TypeScript project (defaults to current directory)", + }, + searchTerm: { + type: "string", + description: "Regex pattern to filter exports by name", + }, + cache: { + type: "boolean", + description: "Enable caching", + default: true, + }, + cacheDir: { + type: "string", + description: "Cache directory", + default: ".ts-morph-cache", + }, + limit: { + type: "number", + description: "Maximum number of results to return", + }, + }, + required: [], + }, + }, + ], + }; + }); + + // Start the server + const transport = new StdioServerTransport(); + await server.connect(transport); + + logger.info("MCP TypeScript Introspect Server started"); +} diff --git a/src/mcp-ts-introspect/packageResolver.ts b/src/mcp-ts-introspect/packageResolver.ts new file mode 100644 index 000000000..fdbb13059 --- /dev/null +++ b/src/mcp-ts-introspect/packageResolver.ts @@ -0,0 +1,214 @@ +import { existsSync, realpathSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import logger from "../logger"; +import type { PackageLocation } from "./types"; + +export async function findPackageJsonAndDir( + packageName: string, + searchPaths: string[] +): Promise { + logger.info(`Searching for package '${packageName}' in paths: ${searchPaths.join(", ")}`); + + // Try multiple strategies to find the package + + // Strategy 1: Try require.resolve (fastest) + try { + const resolvedPath = require.resolve(packageName, { paths: searchPaths }); + const packageDir = findPackageRoot(dirname(resolvedPath)); + if (packageDir) { + const packageJsonPath = join(packageDir, "package.json"); + if (existsSync(packageJsonPath)) { + logger.info(`Found package via require.resolve at: ${packageDir}`); + return { packageJsonPath, packageDir }; + } + } + } catch (error) { + logger.info(`require.resolve failed for ${packageName}: ${error}`); + } + + // Strategy 2: Check standard node_modules paths + for (const searchPath of searchPaths) { + const nodeModulesPath = join(searchPath, "node_modules", packageName); + const packageJsonPath = join(nodeModulesPath, "package.json"); + + if (existsSync(packageJsonPath)) { + logger.info(`Found package in node_modules at: ${nodeModulesPath}`); + return { packageJsonPath, packageDir: nodeModulesPath }; + } + } + + // Strategy 3: Check pnpm paths (.pnpm directory) + for (const searchPath of searchPaths) { + const pnpmPath = join(searchPath, "node_modules", ".pnpm"); + if (existsSync(pnpmPath)) { + try { + const entries = await readdir(pnpmPath); + for (const entry of entries) { + if (entry.includes(packageName)) { + const packagePath = join(pnpmPath, entry, "node_modules", packageName); + const packageJsonPath = join(packagePath, "package.json"); + + if (existsSync(packageJsonPath)) { + // Resolve symlinks for pnpm + const realPackagePath = realpathSync(packagePath); + logger.info(`Found package in pnpm at: ${realPackagePath}`); + return { + packageJsonPath: join(realPackagePath, "package.json"), + packageDir: realPackagePath, + }; + } + } + } + } catch (error) { + logger.warn(`Failed to read pnpm directory: ${error}`); + } + } + } + + // Strategy 4: Check if searchPath is the package itself + for (const searchPath of searchPaths) { + const packageJsonPath = join(searchPath, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const packageJson = await Bun.file(packageJsonPath).json(); + if (packageJson.name === packageName) { + logger.info(`Found package at search path: ${searchPath}`); + return { packageJsonPath, packageDir: searchPath }; + } + } catch (error) { + logger.warn(`Failed to read package.json at ${packageJsonPath}: ${error}`); + } + } + } + + return null; +} + +function findPackageRoot(startPath: string): string | null { + let currentPath = startPath; + + while (currentPath !== dirname(currentPath)) { + if (existsSync(join(currentPath, "package.json"))) { + return currentPath; + } + currentPath = dirname(currentPath); + } + + return null; +} + +export async function findDeclarationFiles(packageLocation: PackageLocation): Promise { + const { packageJsonPath, packageDir } = packageLocation; + const declarationFiles: string[] = []; + + try { + const packageJson = await Bun.file(packageJsonPath).json(); + + // Check for explicit types/typings field + const typesField = packageJson.types || packageJson.typings; + if (typesField) { + const typesPath = join(packageDir, typesField); + if (existsSync(typesPath)) { + logger.info(`Found types field pointing to: ${typesPath}`); + declarationFiles.push(typesPath); + return declarationFiles; + } + } + + // Check exports field + if (packageJson.exports) { + const typesPaths = extractTypesFromExports(packageJson.exports, packageDir); + if (typesPaths.length > 0) { + logger.info(`Found types in exports field: ${typesPaths.join(", ")}`); + return typesPaths; + } + } + + // Check for index.d.ts + const indexDts = join(packageDir, "index.d.ts"); + if (existsSync(indexDts)) { + logger.info(`Found index.d.ts at: ${indexDts}`); + declarationFiles.push(indexDts); + return declarationFiles; + } + + // Check for main field with .d.ts extension + if (packageJson.main) { + const mainBase = packageJson.main.replace(/\.[^.]+$/, ""); + const mainDts = join(packageDir, `${mainBase}.d.ts`); + if (existsSync(mainDts)) { + logger.info(`Found declaration file for main: ${mainDts}`); + declarationFiles.push(mainDts); + return declarationFiles; + } + } + + // Fall back to scanning for all .d.ts files + logger.info(`Scanning for all .d.ts files in ${packageDir}`); + const allDtsFiles = await findAllDeclarationFiles(packageDir); + return allDtsFiles; + } catch (error) { + logger.error(`Failed to find declaration files: ${error}`); + return []; + } +} + +function extractTypesFromExports(exports: Record, packageDir: string): string[] { + const typesPaths: string[] = []; + + function processExport(exp: unknown) { + if (typeof exp === "string") { + if (exp.endsWith(".d.ts")) { + const fullPath = join(packageDir, exp); + if (existsSync(fullPath)) { + typesPaths.push(fullPath); + } + } + } else if (typeof exp === "object" && exp !== null) { + const obj = exp as Record; + if (typeof obj.types === "string") { + const fullPath = join(packageDir, obj.types); + if (existsSync(fullPath)) { + typesPaths.push(fullPath); + } + } + // Recursively check nested exports + for (const value of Object.values(obj)) { + processExport(value); + } + } + } + + processExport(exports); + return [...new Set(typesPaths)]; // Remove duplicates +} + +async function findAllDeclarationFiles(dir: string, maxDepth: number = 3): Promise { + const declarationFiles: string[] = []; + + async function scan(currentDir: string, depth: number) { + if (depth > maxDepth) { + return; + } + + try { + const entries = await readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + + if (entry.isFile() && entry.name.endsWith(".d.ts")) { + declarationFiles.push(fullPath); + } else if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { + await scan(fullPath, depth + 1); + } + } + } catch (error) { + logger.warn(`Failed to scan directory ${currentDir}: ${error}`); + } + } + + await scan(dir, 0); + return declarationFiles; +} diff --git a/src/mcp-ts-introspect/types.ts b/src/mcp-ts-introspect/types.ts new file mode 100644 index 000000000..0e63f80cd --- /dev/null +++ b/src/mcp-ts-introspect/types.ts @@ -0,0 +1,24 @@ +export interface ExportInfo { + name: string; + kind: "function" | "class" | "type" | "const"; + typeSignature: string; + description: string | null; +} + +export interface IntrospectOptions { + searchPaths?: string[]; + searchTerm?: string; + cache?: boolean; + cacheDir?: string; + limit?: number; +} + +export interface PackageLocation { + packageJsonPath: string; + packageDir: string; +} + +export interface CacheEntry { + exports: ExportInfo[]; + timestamp: number; +} diff --git a/src/mcp-ts-introspect/utils.ts b/src/mcp-ts-introspect/utils.ts new file mode 100644 index 000000000..89fd4aad6 --- /dev/null +++ b/src/mcp-ts-introspect/utils.ts @@ -0,0 +1,38 @@ +import logger from "../logger"; +import type { ExportInfo } from "./types"; + +export function filterExports(exports: ExportInfo[], searchTerm?: string, limit?: number): ExportInfo[] { + let filtered = exports; + + // Apply search filter + if (searchTerm) { + try { + const regex = new RegExp(searchTerm, "i"); + filtered = exports.filter( + (exp) => + regex.test(exp.name) || + regex.test(exp.typeSignature) || + (exp.description && regex.test(exp.description)) + ); + logger.info(`Filtered ${exports.length} exports to ${filtered.length} using pattern: ${searchTerm}`); + } catch (error) { + logger.warn(`Invalid regex pattern '${searchTerm}': ${error}`); + // Fall back to simple string matching + const lowerSearch = searchTerm.toLowerCase(); + filtered = exports.filter( + (exp) => + exp.name.toLowerCase().includes(lowerSearch) || + exp.typeSignature.toLowerCase().includes(lowerSearch) || + exp.description?.toLowerCase().includes(lowerSearch) + ); + } + } + + // Apply limit + if (limit && limit > 0 && filtered.length > limit) { + logger.info(`Limiting results from ${filtered.length} to ${limit}`); + filtered = filtered.slice(0, limit); + } + + return filtered; +} diff --git a/tsconfig.json b/tsconfig.json index fc5402ba8..5168be0ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,10 @@ "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false, + // Required for compatibility + "esModuleInterop": true, + "downlevelIteration": true, + // Path aliases "paths": { "@app/*": ["./src/*"],