Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 247 additions & 91 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@leanmcp/cli",
"version": "0.5.4",
"version": "0.5.5",
"description": "Command-line interface for scaffolding LeanMCP projects",
"bin": {
"leanmcp": "bin/leanmcp.js"
Expand Down Expand Up @@ -40,7 +40,8 @@
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.4.0",
"vite-plugin-singlefile": "^2.3.0"
"vite-plugin-singlefile": "^2.3.0",
"ts-morph": "^24.0.0"
},
"devDependencies": {
"@types/archiver": "^6.0.3",
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { spawn } from 'child_process';
import ora from 'ora';
import path from 'path';
import fs from 'fs-extra';
import { logger, chalk } from '../logger';
import { logger } from '../logger';
import { scanUIApp, buildUIComponent, writeUIManifest } from '../vite';
import { generateSchemaMetadata } from '../schema-extractor';

export async function buildCommand() {
const cwd = process.cwd();
Expand Down Expand Up @@ -105,6 +106,16 @@ export async function buildCommand() {
process.exit(1);
}

// Step 4: Generate schema metadata (for fast runtime schema generation)
const schemaSpinner = ora('Generating schema metadata...').start();
try {
await generateSchemaMetadata(cwd);
schemaSpinner.succeed('Schema metadata generated');
} catch (error) {
schemaSpinner.warn('Schema metadata generation failed (runtime fallback will be used)');
logger.gray(` ${error instanceof Error ? error.message : String(error)}`);
}

logger.success('\nBuild complete!');
logger.gray('\nTo start the server:');
logger.info(' npm run start:node\n');
Expand Down
238 changes: 238 additions & 0 deletions packages/cli/src/schema-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Schema Extractor
*
* Extracts type information from TypeScript source files at build time
* and generates schema-metadata.json for fast runtime schema generation.
*/
import path from 'path';
import fs from 'fs-extra';

// ts-morph is a dev dependency of CLI
import { Project, Node, SyntaxKind } from 'ts-morph';

export interface SchemaMetadata {
[className: string]: {
[propertyName: string]: {
type: string;
items?: { type: string };
};
};
}

/**
* Generate schema metadata for all input classes in the project.
* Uses reachability analysis: finds classes used as inputs to @Tool/@Prompt methods.
*/
export async function generateSchemaMetadata(projectDir: string): Promise<void> {
const metadata: SchemaMetadata = {};

// Maps to store discovery
const classesByName = new Map<string, any>(); // ClassDeclaration
const classesToProcess = new Set<string>();
const processedClasses = new Set<string>();

// Create ts-morph project
const project = new Project({
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
});

// Find all TypeScript files (excluding node_modules and dist)
const tsFiles = await findTsFiles(projectDir);

// Pass 1: Scan all files to find classes and tool usages
for (const filePath of tsFiles) {
try {
const sourceFile = project.addSourceFileAtPath(filePath);

// Register all classes by name
for (const classDecl of sourceFile.getClasses()) {
const className = classDecl.getName();
if (className) {
classesByName.set(className, classDecl);
}
}

// Find tool usages to seed the processing queue
// Look for classes with methods decorated with @Tool, @Prompt, @Resource
for (const classDecl of sourceFile.getClasses()) {
for (const method of classDecl.getInstanceMethods()) {
const decorators = method.getDecorators();
const isTool = decorators.some((d) => {
const name = d.getName();
return ['Tool', 'Prompt', 'Resource'].includes(name);
});

if (isTool) {
// Check first argument for input type
const params = method.getParameters();
if (params.length > 0) {
const paramToken = params[0]; // First argument is input
const typeNode = paramToken.getTypeNode();
if (typeNode) {
const typeName = typeNode.getText().trim();
// If it's a class reference (not primitive)
if (!isPrimitive(typeName)) {
classesToProcess.add(extractBaseClassName(typeName));
}
}
}
}
}
}
} catch (error) {
// Skip parsing errors
}
}

// Pass 2: Process the queue (BFS for dependencies)
for (const className of classesToProcess) {
if (processedClasses.has(className)) continue;

const classDecl = classesByName.get(className);
if (!classDecl) continue; // Class not found in project sources

// Extract properties
const propertyTypes: SchemaMetadata[string] = {};

for (const prop of classDecl.getInstanceProperties()) {
if (Node.isPropertyDeclaration(prop)) {
const propName = prop.getName();
const typeNode = prop.getTypeNode();

if (typeNode) {
const typeText = typeNode.getText();
const schemaType = typeTextToSchemaType(typeText);
propertyTypes[propName] = schemaType;

// Check if we need to process referenced types
const baseType = extractBaseClassName(typeText);
if (!isPrimitive(baseType) && !processedClasses.has(baseType)) {
classesToProcess.add(baseType);
}
}
}
}

if (Object.keys(propertyTypes).length > 0) {
metadata[className] = propertyTypes;
}

processedClasses.add(className);
}

// Write metadata to dist/schema-metadata.json
const outputPath = path.join(projectDir, 'dist', 'schema-metadata.json');
await fs.ensureDir(path.dirname(outputPath));
await fs.writeJSON(outputPath, metadata, { spaces: 2 });
}

/**
* Check if a type name is a primitive
*/
function isPrimitive(typeName: string): boolean {
const t = typeName.toLowerCase();
return [
'string',
'number',
'boolean',
'any',
'void',
'null',
'undefined',
'object',
'date',
].includes(t);
}

/**
* Extract base class name from type string (e.g. "MyClass[]" -> "MyClass")
*/
function extractBaseClassName(typeText: string): string {
let name = typeText.trim();
// Remove array brackets
if (name.endsWith('[]')) {
name = name.slice(0, -2);
}
// Remove Array<T> wrapper
const arrayMatch = name.match(/^Array<(.+)>$/i);
if (arrayMatch) {
name = arrayMatch[1];
}
return name.trim();
}

/**
* Find all TypeScript files in the project
*/
async function findTsFiles(dir: string): Promise<string[]> {
const files: string[] = [];

async function scan(currentDir: string) {
const entries = await fs.readdir(currentDir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);

if (entry.isDirectory()) {
// Skip node_modules, dist, .git
if (['node_modules', 'dist', '.git', '.leanmcp'].includes(entry.name)) {
continue;
}
await scan(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
files.push(fullPath);
}
}
}

await scan(dir);
return files;
}

/**
* Convert TypeScript type text to JSON Schema type
*/
function typeTextToSchemaType(typeText: string): { type: string; items?: { type: string } } {
const type = typeText.trim().replace(/\?$/, '');

// Array types: string[], number[], etc.
if (type.endsWith('[]')) {
const elementType = type.slice(0, -2).toLowerCase();
return {
type: 'array',
items: { type: mapPrimitiveType(elementType) },
};
}

// Array<T> syntax
const arrayMatch = type.match(/^Array<(.+)>$/i);
if (arrayMatch) {
const elementType = arrayMatch[1].trim().toLowerCase();
return {
type: 'array',
items: { type: mapPrimitiveType(elementType) },
};
}

// Primitive types
return { type: mapPrimitiveType(type.toLowerCase()) };
}

/**
* Map TypeScript primitive types to JSON Schema types
*/
function mapPrimitiveType(tsType: string): string {
switch (tsType) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
case 'integer':
return 'integer';
default:
return 'string'; // Default to string for unknown types
}
}
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@leanmcp/core",
"version": "0.4.4",
"version": "0.4.7",
"description": "Core library implementing decorators, reflection, and MCP runtime server",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down Expand Up @@ -32,6 +32,9 @@
"dotenv": "^16.3.1",
"reflect-metadata": "^0.2.1"
},
"optionalDependencies": {
"ts-morph": "^24.0.0"
},
"peerDependencies": {
"cors": "^2.8.5",
"express": "^5.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Ajv from 'ajv';

export * from './decorators';
export * from './schema-generator';
export { parseClassTypesSync, registerClassSource, clearTypeCache } from './type-parser';
export * from './http-server';
export * from './logger';
export * from './validation';
Expand Down
Loading
Loading