diff --git a/package-lock.json b/package-lock.json index 132d3cc..8a5f76d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -467,6 +467,7 @@ "examples/chat-files-2026-01-24": { "name": "leanmcp-minimal2", "version": "1.0.0", + "extraneous": true, "license": "MIT", "dependencies": { "@leanmcp/core": "0.3.9", @@ -479,67 +480,6 @@ "typescript": "^5.6.3" } }, - "examples/chat-files-2026-01-24/node_modules/@leanmcp/core": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.3.9.tgz", - "integrity": "sha512-80hFV+Y3L8z/5cruFDoc+FfxvXkaUdr8zibrxquMnHI9tG2n2pxWKtrr3xCtfm2gg98YrwGcxMKJpfHX8Fnfhw==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "ajv": "^8.12.0", - "chokidar": "^4.0.0", - "dotenv": "^16.3.1", - "reflect-metadata": "^0.2.1" - }, - "peerDependencies": { - "cors": "^2.8.5", - "express": "^5.0.0" - }, - "peerDependenciesMeta": { - "cors": { - "optional": true - }, - "express": { - "optional": true - } - } - }, - "examples/chat-files-2026-01-24/node_modules/@slack/web-api": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.11.0.tgz", - "integrity": "sha512-m+dGluB7OTebNqEt7wRXyvUfjUGLBuqN4ZAjEmQvu7oeKmVNBXO+mQbH9nop0f/GCvkGK52aaoOWz0H1ole2xg==", - "license": "MIT", - "dependencies": { - "@slack/logger": "^4.0.0", - "@slack/types": "^2.17.0", - "@types/node": ">=18.0.0", - "@types/retry": "0.12.0", - "axios": "^1.11.0", - "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", - "p-queue": "^6", - "p-retry": "^4", - "retry": "^0.13.1" - }, - "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" - } - }, - "examples/chat-files-2026-01-24/node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "examples/clerk-auth": { "version": "1.0.0", "dependencies": { @@ -1579,11 +1519,53 @@ "typescript": "^5.7.2" } }, + "examples/leanmcp-auth/node_modules/@leanmcp/auth": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@leanmcp/auth/-/auth-0.4.5.tgz", + "integrity": "sha512-MbLu9fRe0rOIFFAscknPOK4Yllgjtvp3v/7sY54MoEjxwUAJ+WInGargeLmBmXXCq3SCv05Da6ModRI72AfATg==", + "license": "MIT", + "dependencies": { + "@leanmcp/core": "^0.3.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.0.0", + "@leanmcp/env-injection": "^0.1.0", + "axios": "^1.0.0", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "jwk-to-pem": "^2.0.0", + "keytar": "^7.0.0", + "open": "^10.0.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-cognito-identity-provider": { + "optional": true + }, + "@leanmcp/env-injection": { + "optional": true + }, + "axios": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "jwk-to-pem": { + "optional": true + }, + "keytar": { + "optional": true + }, + "open": { + "optional": true + } + } + }, "examples/leanmcp-auth/node_modules/@leanmcp/auth/node_modules/@leanmcp/core": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.3.19.tgz", "integrity": "sha512-67VWhhOcX3xvO6AseFAlrBBUYNrKurf2Dt6fRY90UznVeH+cYeByAZ/1LcfQYgeGJIxQWOAXQ3FRtD7KdgN+RA==", - "extraneous": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -2026,11 +2008,53 @@ "typescript": "^5.6.3" } }, + "examples/slack-with-auth/node_modules/@leanmcp/auth": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@leanmcp/auth/-/auth-0.4.5.tgz", + "integrity": "sha512-MbLu9fRe0rOIFFAscknPOK4Yllgjtvp3v/7sY54MoEjxwUAJ+WInGargeLmBmXXCq3SCv05Da6ModRI72AfATg==", + "license": "MIT", + "dependencies": { + "@leanmcp/core": "^0.3.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.0.0", + "@leanmcp/env-injection": "^0.1.0", + "axios": "^1.0.0", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "jwk-to-pem": "^2.0.0", + "keytar": "^7.0.0", + "open": "^10.0.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-cognito-identity-provider": { + "optional": true + }, + "@leanmcp/env-injection": { + "optional": true + }, + "axios": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "jwk-to-pem": { + "optional": true + }, + "keytar": { + "optional": true + }, + "open": { + "optional": true + } + } + }, "examples/slack-with-auth/node_modules/@leanmcp/auth/node_modules/@leanmcp/core": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.3.19.tgz", "integrity": "sha512-67VWhhOcX3xvO6AseFAlrBBUYNrKurf2Dt6fRY90UznVeH+cYeByAZ/1LcfQYgeGJIxQWOAXQ3FRtD7KdgN+RA==", - "extraneous": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -2446,11 +2470,53 @@ "typescript": "^5.6.3" } }, + "examples/slack-with-ui/node_modules/@leanmcp/auth": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@leanmcp/auth/-/auth-0.4.5.tgz", + "integrity": "sha512-MbLu9fRe0rOIFFAscknPOK4Yllgjtvp3v/7sY54MoEjxwUAJ+WInGargeLmBmXXCq3SCv05Da6ModRI72AfATg==", + "license": "MIT", + "dependencies": { + "@leanmcp/core": "^0.3.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.0.0", + "@leanmcp/env-injection": "^0.1.0", + "axios": "^1.0.0", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "jwk-to-pem": "^2.0.0", + "keytar": "^7.0.0", + "open": "^10.0.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-cognito-identity-provider": { + "optional": true + }, + "@leanmcp/env-injection": { + "optional": true + }, + "axios": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "jwk-to-pem": { + "optional": true + }, + "keytar": { + "optional": true + }, + "open": { + "optional": true + } + } + }, "examples/slack-with-ui/node_modules/@leanmcp/auth/node_modules/@leanmcp/core": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.3.19.tgz", "integrity": "sha512-67VWhhOcX3xvO6AseFAlrBBUYNrKurf2Dt6fRY90UznVeH+cYeByAZ/1LcfQYgeGJIxQWOAXQ3FRtD7KdgN+RA==", - "extraneous": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -2948,11 +3014,53 @@ "typescript": "^5.6.3" } }, + "examples/test-stateful-mcp/node_modules/@leanmcp/auth": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@leanmcp/auth/-/auth-0.4.5.tgz", + "integrity": "sha512-MbLu9fRe0rOIFFAscknPOK4Yllgjtvp3v/7sY54MoEjxwUAJ+WInGargeLmBmXXCq3SCv05Da6ModRI72AfATg==", + "license": "MIT", + "dependencies": { + "@leanmcp/core": "^0.3.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.0.0", + "@leanmcp/env-injection": "^0.1.0", + "axios": "^1.0.0", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "jwk-to-pem": "^2.0.0", + "keytar": "^7.0.0", + "open": "^10.0.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-cognito-identity-provider": { + "optional": true + }, + "@leanmcp/env-injection": { + "optional": true + }, + "axios": { + "optional": true + }, + "jsonwebtoken": { + "optional": true + }, + "jwk-to-pem": { + "optional": true + }, + "keytar": { + "optional": true + }, + "open": { + "optional": true + } + } + }, "examples/test-stateful-mcp/node_modules/@leanmcp/auth/node_modules/@leanmcp/core": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.3.19.tgz", "integrity": "sha512-67VWhhOcX3xvO6AseFAlrBBUYNrKurf2Dt6fRY90UznVeH+cYeByAZ/1LcfQYgeGJIxQWOAXQ3FRtD7KdgN+RA==", - "extraneous": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", @@ -9069,6 +9177,41 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@types/archiver": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz", @@ -10480,6 +10623,12 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -11826,9 +11975,9 @@ } }, "node_modules/hono": { - "version": "4.11.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.5.tgz", - "integrity": "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==", + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", + "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", "peer": true, "engines": { @@ -13063,10 +13212,6 @@ "resolved": "examples/slack-with-ui", "link": true }, - "node_modules/leanmcp-minimal2": { - "resolved": "examples/chat-files-2026-01-24", - "link": true - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -14169,6 +14314,12 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -15954,6 +16105,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -17173,16 +17334,6 @@ } } }, - "packages/auth/node_modules/@leanmcp/env-injection": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@leanmcp/env-injection/-/env-injection-0.1.5.tgz", - "integrity": "sha512-d66R3uEdNRcR1aDtvEamW5xLe4KKSac77jp3fZZC5cfUVfWN/sa697r6yCiREAINAAwYev2IX1/9Z5o5iucRfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "reflect-metadata": "^0.2.1" - } - }, "packages/auth/node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -17198,7 +17349,7 @@ }, "packages/cli": { "name": "@leanmcp/cli", - "version": "0.5.4", + "version": "0.5.5", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.0.1", @@ -17213,6 +17364,7 @@ "ora": "^8.1.0", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", + "ts-morph": "^24.0.0", "vite": "^5.4.0", "vite-plugin-singlefile": "^2.3.0" }, @@ -17949,7 +18101,7 @@ }, "packages/core": { "name": "@leanmcp/core", - "version": "0.4.4", + "version": "0.4.7", "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "^3.700.0", @@ -17965,6 +18117,9 @@ "@types/express": "^5.0.0", "@types/node": "^20.0.0" }, + "optionalDependencies": { + "ts-morph": "^24.0.0" + }, "peerDependencies": { "@leanmcp/auth": "^0.4.0", "cors": "^2.8.5", @@ -18100,9 +18255,9 @@ } }, "packages/leanmcp/node_modules/@leanmcp/cli": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@leanmcp/cli/-/cli-0.5.3.tgz", - "integrity": "sha512-eOC60qJ5Yw7y7cifIPDBeWuogvYlubyNdIK7gGCZF/AvHexM5rMaYpSq4SlMtIf8u3T6bWgpsMUEfDbi/5wD5A==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@leanmcp/cli/-/cli-0.5.4.tgz", + "integrity": "sha512-D6QtzgcVhG1ARaLNnsp+suG65oeaThmkXvBWRmetXmRLPQ6pxdnM3ZuWf1FhgjEF3Mewu7oSgw/D+lZ5jqnMeQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18126,9 +18281,9 @@ } }, "packages/leanmcp/node_modules/@leanmcp/core": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.4.0.tgz", - "integrity": "sha512-v9i55Q3l3RD+ynLriOCDnTWie8sCKDquml/fUPMPIj7e5PvFtfUqAOcsOaIm8Hxv0o1GcwkWjTA8onmsnCmX0Q==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@leanmcp/core/-/core-0.4.6.tgz", + "integrity": "sha512-DbN+WiKa5VFoWCpRaJ/rNu/Ysylz6874MOHmiRai5JdWXcyOM8ZknZjOqWn/W2Z4m83Qo3dxMalW8kVWxeVI0Q==", "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "^3.700.0", @@ -18137,7 +18292,8 @@ "ajv": "^8.12.0", "chokidar": "^4.0.0", "dotenv": "^16.3.1", - "reflect-metadata": "^0.2.1" + "reflect-metadata": "^0.2.1", + "ts-morph": "^24.0.0" }, "peerDependencies": { "@leanmcp/auth": "^0.4.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6d015a5..6ae4eea 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" @@ -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", diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 6366a67..498236e 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -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(); @@ -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'); diff --git a/packages/cli/src/schema-extractor.ts b/packages/cli/src/schema-extractor.ts new file mode 100644 index 0000000..342c59a --- /dev/null +++ b/packages/cli/src/schema-extractor.ts @@ -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 { + const metadata: SchemaMetadata = {}; + + // Maps to store discovery + const classesByName = new Map(); // ClassDeclaration + const classesToProcess = new Set(); + const processedClasses = new Set(); + + // 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 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 { + 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 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 + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 30f13a4..34b7aaa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6db26c..9aea525 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/schema-generator.ts b/packages/core/src/schema-generator.ts index 105a437..f671877 100644 --- a/packages/core/src/schema-generator.ts +++ b/packages/core/src/schema-generator.ts @@ -1,4 +1,20 @@ import 'reflect-metadata'; +import { parseClassTypesSync, registerClassSource } from './type-parser'; +import path from 'path'; +import fs from 'fs'; + +// Pre-computed schema metadata (loaded at module init for fast runtime) +let schemaMetadata: Record> | null = null; + +// Try to load pre-built schema metadata (generated by leanmcp build) +try { + const metadataPath = path.join(process.cwd(), 'dist', 'schema-metadata.json'); + if (fs.existsSync(metadataPath)) { + schemaMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + } +} catch { + // Not built yet or invalid - will use ts-morph fallback +} /** * Converts a TypeScript class to JSON Schema @@ -84,19 +100,62 @@ export function SchemaConstraint(constraints: { enum?: any[]; description?: string; default?: any; + type?: string; }): PropertyDecorator { return (target, propertyKey) => { Reflect.defineMetadata('schema:constraints', constraints, target, propertyKey); + + // Capture source file location for later type parsing + // Uses stack trace to find where the decorator is being applied + const originalPrepareStackTrace = Error.prepareStackTrace; + try { + Error.prepareStackTrace = (_, stack) => stack; + const err = new Error(); + const stack = err.stack as unknown as NodeJS.CallSite[]; + + for (const site of stack) { + const fileName = site.getFileName(); + if ( + fileName && + !fileName.includes('node_modules') && + !fileName.includes('schema-generator') && + (fileName.endsWith('.ts') || fileName.endsWith('.js')) + ) { + // Get the class name from the target + const className = target.constructor.name; + if (className && className !== 'Object') { + registerClassSource(className, fileName); + } + break; + } + } + } finally { + Error.prepareStackTrace = originalPrepareStackTrace; + } }; } /** - * Enhanced schema generator that includes constraints + * Enhanced schema generator that includes constraints. + * Uses pre-computed metadata (from leanmcp build) when available for fast startup. + * Falls back to ts-morph parsing in dev mode. */ -export function classToJsonSchemaWithConstraints(classConstructor: new () => any): any { +export function classToJsonSchemaWithConstraints( + classConstructor: new () => any, + sourceFilePath?: string +): any { const instance = new classConstructor(); const properties: Record = {}; const required: string[] = []; + const className = classConstructor.name; + + // FAST PATH: Use pre-computed metadata if available (from leanmcp build) + const precomputedTypes = schemaMetadata?.[className]; + + // SLOW PATH: Parse TypeScript types from source (dev mode fallback) + const parsedTypes = precomputedTypes + ? null + : parseClassTypesSync(classConstructor, sourceFilePath); const propertyNames = Object.keys(instance); @@ -105,9 +164,22 @@ export function classToJsonSchemaWithConstraints(classConstructor: new () => any const constraints = Reflect.getMetadata('schema:constraints', instance, propertyName); const isOptional = Reflect.getMetadata('optional', instance, propertyName); - let jsonSchemaType = 'string'; // Default to string when metadata is unavailable (tsx/ts-node limitation) + // Get parsed type from source (includes proper array items) + let parsedType: any; + if (precomputedTypes) { + parsedType = precomputedTypes[propertyName]; + } else if (parsedTypes) { + parsedType = parsedTypes.get(propertyName); + } - if (propertyType) { + let propertySchema: any; + + if (parsedType && parsedType.type) { + // Use parsed type (has accurate array item types) + propertySchema = { ...parsedType }; + } else if (propertyType) { + // Fallback to reflect-metadata + let jsonSchemaType = 'string'; switch (propertyType.name) { case 'String': jsonSchemaType = 'string'; @@ -127,9 +199,15 @@ export function classToJsonSchemaWithConstraints(classConstructor: new () => any default: jsonSchemaType = 'object'; } + propertySchema = { type: jsonSchemaType }; } else if (constraints) { // Infer type from constraints when design:type metadata is unavailable - if ( + let jsonSchemaType = 'string'; + if (constraints.type) { + jsonSchemaType = constraints.type; + } else if (constraints.items) { + jsonSchemaType = 'array'; + } else if ( constraints.minLength !== undefined || constraints.maxLength !== undefined || constraints.pattern @@ -138,7 +216,6 @@ export function classToJsonSchemaWithConstraints(classConstructor: new () => any } else if (constraints.minimum !== undefined || constraints.maximum !== undefined) { jsonSchemaType = 'number'; } else if (constraints.enum && constraints.enum.length > 0) { - // Infer from enum values const firstValue = constraints.enum[0]; if (typeof firstValue === 'number') { jsonSchemaType = 'number'; @@ -148,12 +225,30 @@ export function classToJsonSchemaWithConstraints(classConstructor: new () => any jsonSchemaType = 'string'; } } + propertySchema = { type: jsonSchemaType }; + } else { + // Default to string when no type info available + propertySchema = { type: 'string' }; + } + + // Merge constraints (description, enum, min/max, etc.) + if (constraints) { + // Keep the parsed items if present, don't override with constraints + const parsedItems = propertySchema.items; + propertySchema = { ...propertySchema, ...constraints }; + + // Restore parsed items if constraints didn't have items + if (parsedItems && !constraints.items) { + propertySchema.items = parsedItems; + } + } + + // Ensure arrays always have items (required by OpenAI) + if (propertySchema.type === 'array' && !propertySchema.items) { + propertySchema.items = { type: 'string' }; } - properties[propertyName] = { - type: jsonSchemaType, - ...(constraints || {}), - }; + properties[propertyName] = propertySchema; if (!isOptional) { required.push(propertyName); diff --git a/packages/core/src/type-parser.ts b/packages/core/src/type-parser.ts new file mode 100644 index 0000000..3c1f250 --- /dev/null +++ b/packages/core/src/type-parser.ts @@ -0,0 +1,249 @@ +/** + * TypeScript Source Type Parser + * + * Parses TypeScript source files at runtime to extract actual type annotations + * (e.g., `string[]`, `number[]`), which are erased by TypeScript compilation. + * + * This solves the problem of `reflect-metadata` only returning `Array` for + * array types, without the element type information needed for JSON Schema. + */ +import path from 'path'; +import fs from 'fs'; +import { createRequire } from 'node:module'; + +// Cache to avoid re-parsing the same file +const typeCache = new Map>(); + +// Track source file paths for classes +const classSourceMap = new Map(); + +// Lazy-loaded ts-morph references +let tsMorphProject: any = null; +let tsMorphNode: any = null; +let tsMorphLoaded = false; + +/** + * Synchronously load ts-morph + */ +function loadTsMorph(): boolean { + if (tsMorphLoaded) { + return tsMorphProject !== null; + } + + tsMorphLoaded = true; + + try { + // Create require that works in ESM + const customRequire = createRequire(import.meta.url); + const tsMorph = customRequire('ts-morph'); + tsMorphProject = tsMorph.Project; + tsMorphNode = tsMorph.Node; + return true; + } catch (error: any) { + // ts-morph not available - graceful fallback + console.warn(`[type-parser] ts-morph not available: ${error.message}`); + return false; + } +} + +/** + * Register the source file path for an input class. + * This is called during decorator execution to capture the source location. + */ +export function registerClassSource(className: string, sourceFile: string): void { + classSourceMap.set(className, sourceFile); +} + +/** + * Parse a TypeScript class and extract JSON Schema types for each property. + * Automatically detects array types like `string[]` and adds proper `items`. + * + * This is now SYNCHRONOUS for easier integration with existing code. + */ +export function parseClassTypesSync( + classConstructor: new () => any, + sourceFilePath?: string +): Map { + if (!loadTsMorph()) { + return new Map(); + } + + const className = classConstructor.name; + + // Try to find source file from multiple sources + let filePath = sourceFilePath || classSourceMap.get(className) || findSourceFile(); + + if (!filePath) { + return new Map(); + } + + // Handle .js files by looking for .ts equivalent + if (filePath.endsWith('.js')) { + const tsPath = filePath.replace(/\.js$/, '.ts'); + if (fs.existsSync(tsPath)) { + filePath = tsPath; + } + } + + const cacheKey = `${filePath}:${className}`; + + if (typeCache.has(cacheKey)) { + return typeCache.get(cacheKey)!; + } + + if (!fs.existsSync(filePath)) { + return new Map(); + } + + try { + const project = new tsMorphProject({ + skipAddingFilesFromTsConfig: true, + skipFileDependencyResolution: true, + useInMemoryFileSystem: false, + }); + + const sourceFile = project.addSourceFileAtPath(filePath); + + // Find the class by name + const classDecl = sourceFile.getClass(className); + if (!classDecl) { + // Class not found in this file + return new Map(); + } + + const propertyTypes = new Map(); + + // Get all properties (instance properties) + for (const prop of classDecl.getInstanceProperties()) { + if (tsMorphNode.isPropertyDeclaration(prop)) { + const propName = prop.getName(); + const typeNode = prop.getTypeNode(); + + if (typeNode) { + const typeText = typeNode.getText(); + const jsonSchemaType = typeTextToJsonSchema(typeText); + propertyTypes.set(propName, jsonSchemaType); + } + } + } + + typeCache.set(cacheKey, propertyTypes); + return propertyTypes; + } catch (error: any) { + // If parsing fails (e.g., syntax error), return empty map + console.warn(`[type-parser] Failed to parse ${filePath}: ${error.message}`); + return new Map(); + } +} + +// Alias for backwards compatibility +export const parseClassTypes = parseClassTypesSync; + +/** + * Convert TypeScript type annotation text to JSON Schema. + * Handles arrays, primitives, and nested types. + */ +function typeTextToJsonSchema(typeText: string): any { + // Remove whitespace and optional '?' suffix + const type = typeText.trim().replace(/\?$/, ''); + + // Array types: string[], number[], MyType[], etc. + if (type.endsWith('[]')) { + const elementType = type.slice(0, -2); + return { + type: 'array', + items: typeTextToJsonSchema(elementType), + }; + } + + // Array syntax + const arrayMatch = type.match(/^Array<(.+)>$/); + if (arrayMatch) { + return { + type: 'array', + items: typeTextToJsonSchema(arrayMatch[1]), + }; + } + + // Primitive types (case-insensitive match) + const lowerType = type.toLowerCase(); + switch (lowerType) { + case 'string': + return { type: 'string' }; + case 'number': + return { type: 'number' }; + case 'integer': + return { type: 'integer' }; + case 'boolean': + return { type: 'boolean' }; + case 'object': + return { type: 'object' }; + case 'any': + case 'unknown': + return {}; // No constraints + case 'null': + return { type: 'null' }; + } + + // Union types containing null (e.g., "string | null") + if (type.includes('|')) { + const parts = type.split('|').map((p) => p.trim()); + const nonNullParts = parts.filter((p) => p.toLowerCase() !== 'null'); + if (nonNullParts.length === 1) { + // Simple nullable type + return typeTextToJsonSchema(nonNullParts[0]); + } + // Multiple types - return anyOf (not commonly used in tools) + return { + anyOf: nonNullParts.map((p) => typeTextToJsonSchema(p)), + }; + } + + // For complex/custom types, default to object + return { type: 'object' }; +} + +/** + * Find the source file where the caller's input class is likely defined. + * Uses stack trace to locate the original .ts file. + */ +function findSourceFile(): string | null { + const originalPrepareStackTrace = Error.prepareStackTrace; + try { + Error.prepareStackTrace = (_, stack) => stack; + const err = new Error(); + const stack = err.stack as unknown as NodeJS.CallSite[]; + + // Walk the stack looking for user code (not node_modules, not this file) + for (const site of stack) { + const fileName = site.getFileName(); + if ( + fileName && + !fileName.includes('node_modules') && + !fileName.includes('type-parser') && + !fileName.includes('schema-generator') && + (fileName.endsWith('.ts') || fileName.endsWith('.js')) + ) { + // Prefer .ts file if .js was found + if (fileName.endsWith('.js')) { + const tsPath = fileName.replace(/\.js$/, '.ts'); + if (fs.existsSync(tsPath)) { + return tsPath; + } + } + return fileName; + } + } + } finally { + Error.prepareStackTrace = originalPrepareStackTrace; + } + + return null; +} + +/** + * Clear the type cache (useful for hot reloading) + */ +export function clearTypeCache(): void { + typeCache.clear(); +}