From 5fc584fc66ada0c43b0db08e60216edb2b26e799 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 17 Dec 2025 14:02:26 +1000 Subject: [PATCH] feat(tpmjs-spec): add auto-discovery of tools and rename exportName to name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes to the TPMJS specification: 1. Auto-Discovery: The `tools` array is now optional. If omitted, TPMJS automatically scans package exports and registers any export with `description` and `execute` properties (standard AI SDK tool format). 2. Renamed `exportName` to `name` in tool definitions for cleaner spec. 3. Added `/list-exports` endpoint to Railway executor that: - Lists all exports from a package - Identifies valid AI SDK tools - Extracts descriptions for auto-discovered tools 4. Added `toolDiscoverySource` field to track 'auto' vs 'manual' discovery. 5. Updated tool page UI with: - Auto-discovery warning banner - Badge showing discovery source 6. Updated all documentation pages (docs, spec, publish) to reflect: - Optional tools array with auto-discovery - Use of `name` instead of `exportName` - Auto-extraction of schema and description 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- apps/railway-executor/server.ts | 99 ++++++++++++++++++ apps/web/src/app/api/sync/changes/route.ts | 82 +++++++++++---- apps/web/src/app/api/sync/keyword/route.ts | 87 ++++++++++++---- apps/web/src/app/docs/page.tsx | 40 ++++++-- apps/web/src/app/publish/page.tsx | 46 ++++++--- apps/web/src/app/spec/page.tsx | 114 +++++++++++++-------- apps/web/src/app/tool/[...slug]/page.tsx | 26 +++++ apps/web/src/lib/schema-extraction.ts | 83 +++++++++++++++ packages/db/prisma/schema.prisma | 3 + packages/types/src/tpmjs.ts | 33 ++++-- 10 files changed, 501 insertions(+), 112 deletions(-) diff --git a/apps/railway-executor/server.ts b/apps/railway-executor/server.ts index 055d1ac..1a1501d 100644 --- a/apps/railway-executor/server.ts +++ b/apps/railway-executor/server.ts @@ -725,6 +725,102 @@ async function executeTool(req: Request): Promise { } } +/** + * List all exports from a package and identify which are valid AI SDK tools + */ +async function listExports(req: Request): Promise { + try { + const body = await req.json(); + const { packageName, version, importUrl, env } = body; + + if (!packageName || !version) { + return Response.json( + { + success: false, + error: 'Missing required fields: packageName, version', + }, + { status: 400 } + ); + } + + // Dynamic import from esm.sh + const url = importUrl || `https://esm.sh/${packageName}@${version}`; + console.log(`📦 Listing exports from: ${url}`); + + const module = await import(url); + const allExports = Object.keys(module); + + // Filter out 'default' and identify which exports are valid tools + const tools: Array<{ + name: string; + isValidTool: boolean; + description?: string; + error?: string; + }> = []; + + for (const exportName of allExports) { + if (exportName === 'default') continue; + + let rawExport = module[exportName]; + + // Check if it's a factory function + if (typeof rawExport === 'function' && !rawExport.description && !rawExport.execute) { + // Try to call factory with no args + try { + const factoryResult = rawExport(); + if (factoryResult?.description && factoryResult?.execute) { + rawExport = factoryResult; + } else if (env && typeof env === 'object') { + // Try with env config + const configResult = rawExport({ ...env }); + if (configResult?.description && configResult?.execute) { + rawExport = configResult; + } + } + } catch { + // Factory call failed, continue checking + } + } + + // Check if it's a valid AI SDK tool + if (rawExport?.description && rawExport?.execute) { + tools.push({ + name: exportName, + isValidTool: true, + description: rawExport.description, + }); + } else if (typeof rawExport === 'object' && rawExport !== null) { + // It's an object but not a valid tool - might be a factory that needs specific config + tools.push({ + name: exportName, + isValidTool: false, + error: 'Not a valid AI SDK tool (missing description or execute)', + }); + } + // Skip non-object exports (they're definitely not tools) + } + + console.log(`✅ Found ${tools.length} potential tool exports in ${packageName}`); + + return Response.json({ + success: true, + packageName, + version, + exports: allExports, + tools, + }); + } catch (error) { + console.error('❌ Failed to list exports:', error); + return Response.json( + { + success: false, + error: error.message, + }, + { status: 500 } + ); + } +} + /** * Health check */ @@ -796,6 +892,8 @@ async function handler(req: Request): Promise { response = health(); } else if (url.pathname === '/load-and-describe' && req.method === 'POST') { response = await loadAndDescribe(req); + } else if (url.pathname === '/list-exports' && req.method === 'POST') { + response = await listExports(req); } else if (url.pathname === '/execute-tool' && req.method === 'POST') { response = await executeTool(req); } else if (url.pathname === '/cache/stats' && req.method === 'GET') { @@ -832,6 +930,7 @@ console.log('📦 HTTP imports: ENABLED'); console.log(`🔗 Health check: http://localhost:${port}/health`); console.log('🛠️ Endpoints:'); console.log(' POST /load-and-describe - Load tool and get schema'); +console.log(' POST /list-exports - List all exports and identify valid tools'); console.log(' POST /execute-tool - Execute a tool with params'); console.log(' POST /cache/clear - Clear module cache'); console.log(' GET /cache/stats - Get cache statistics'); diff --git a/apps/web/src/app/api/sync/changes/route.ts b/apps/web/src/app/api/sync/changes/route.ts index 8b8ab3a..54f97dc 100644 --- a/apps/web/src/app/api/sync/changes/route.ts +++ b/apps/web/src/app/api/sync/changes/route.ts @@ -1,10 +1,15 @@ import { prisma } from '@tpmjs/db'; import { fetchChanges, fetchLatestPackageWithMetadata } from '@tpmjs/npm-client'; import { validateTpmjsField } from '@tpmjs/types/tpmjs'; +import type { TpmjsToolDefinition } from '@tpmjs/types/tpmjs'; import { type NextRequest, NextResponse } from 'next/server'; import { env } from '~/env'; import { performHealthCheck } from '~/lib/health-check/health-check-service'; -import { convertJsonSchemaToParameters, extractToolSchema } from '~/lib/schema-extraction'; +import { + convertJsonSchemaToParameters, + extractToolSchema, + listToolExports, +} from '~/lib/schema-extraction'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -131,19 +136,55 @@ export async function POST(request: NextRequest) { where: { packageId: packageRecord.id }, }); - // Upsert each tool in the tools array - for (const toolDef of validation.tools) { + // Determine the tools to process + let toolsToProcess: TpmjsToolDefinition[] = validation.tools || []; + let toolDiscoverySource: 'auto' | 'manual' = 'manual'; + + // If tools need auto-discovery, call the executor to list exports + if (validation.needsAutoDiscovery) { + console.log(`Auto-discovering tools for ${pkg.name}...`); + const exportsResult = await listToolExports(pkg.name, pkg.version, null); + + if (exportsResult.success) { + // Convert discovered tools to TpmjsToolDefinition format + toolsToProcess = exportsResult.tools + .filter((t) => t.isValidTool) + .map((t) => ({ + name: t.name, + description: t.description, + })); + toolDiscoverySource = 'auto'; + console.log( + `Auto-discovered ${toolsToProcess.length} tools for ${pkg.name}: ${toolsToProcess.map((t) => t.name).join(', ')}` + ); + } else { + console.log(`Failed to auto-discover tools for ${pkg.name}: ${exportsResult.error}`); + // Skip this package if we can't discover tools + skipped++; + continue; + } + } + + // Upsert each tool + for (const toolDef of toolsToProcess) { + // Use 'name' field (new) or fall back to 'exportName' (legacy support) + const toolName = toolDef.name || (toolDef as { exportName?: string }).exportName; + if (!toolName) { + console.warn(`Skipping tool without name in ${pkg.name}`); + continue; + } + const upsertedTool = await prisma.tool.upsert({ where: { packageId_exportName: { packageId: packageRecord.id, - exportName: toolDef.exportName, + exportName: toolName, }, }, create: { packageId: packageRecord.id, - exportName: toolDef.exportName, - description: toolDef.description, + exportName: toolName, + description: toolDef.description || 'No description provided', // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround @@ -153,29 +194,25 @@ export async function POST(request: NextRequest) { qualityScore: null, // Will be calculated by metrics sync // Schema will be extracted below schemaSource: toolDef.parameters ? 'author' : null, + toolDiscoverySource, }, update: { - description: toolDef.description, + description: toolDef.description || undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround returns: toolDef.returns ? (toolDef.returns as any) : undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined, + toolDiscoverySource, }, }); // Extract schema synchronously from executor - // Note: We pass null for env as schema extraction doesn't need env values - const schemaResult = await extractToolSchema( - pkg.name, - toolDef.exportName, - pkg.version, - null - ); + const schemaResult = await extractToolSchema(pkg.name, toolName, pkg.version, null); if (schemaResult.success) { - // Update tool with extracted schema + // Update tool with extracted schema (and description if not provided) await prisma.tool.update({ where: { id: upsertedTool.id }, data: { @@ -186,13 +223,17 @@ export async function POST(request: NextRequest) { parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any, schemaSource: 'extracted', schemaExtractedAt: new Date(), + // Update description if not provided by author + ...(!toolDef.description && schemaResult.description + ? { description: schemaResult.description } + : {}), }, }); - console.log(`Schema extracted for ${pkg.name}/${toolDef.exportName}`); + console.log(`Schema extracted for ${pkg.name}/${toolName}`); } else { // Extraction failed - mark schema source appropriately console.log( - `Schema extraction failed for ${pkg.name}/${toolDef.exportName}: ${schemaResult.error}` + `Schema extraction failed for ${pkg.name}/${toolName}: ${schemaResult.error}` ); await prisma.tool.update({ where: { id: upsertedTool.id }, @@ -205,7 +246,7 @@ export async function POST(request: NextRequest) { // Trigger health check (non-blocking) for execution testing performHealthCheck(upsertedTool.id, 'sync').catch((err) => { console.error( - `Health check failed for ${pkg.name}/${toolDef.exportName} (${upsertedTool.id}):`, + `Health check failed for ${pkg.name}/${toolName} (${upsertedTool.id}):`, err ); }); @@ -214,7 +255,10 @@ export async function POST(request: NextRequest) { // Delete orphaned tools (tools removed from package.json) const orphanedTools = existingTools.filter( (existingTool) => - !validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName) + !toolsToProcess.some((toolDef) => { + const toolName = toolDef.name || (toolDef as { exportName?: string }).exportName; + return toolName === existingTool.exportName; + }) ); if (orphanedTools.length > 0) { diff --git a/apps/web/src/app/api/sync/keyword/route.ts b/apps/web/src/app/api/sync/keyword/route.ts index 25a7b98..20b7f6e 100644 --- a/apps/web/src/app/api/sync/keyword/route.ts +++ b/apps/web/src/app/api/sync/keyword/route.ts @@ -1,10 +1,15 @@ import { prisma } from '@tpmjs/db'; import { fetchLatestPackageWithMetadata, searchByKeyword } from '@tpmjs/npm-client'; import { validateTpmjsField } from '@tpmjs/types/tpmjs'; +import type { TpmjsToolDefinition } from '@tpmjs/types/tpmjs'; import { type NextRequest, NextResponse } from 'next/server'; import { env } from '~/env'; import { performHealthCheck } from '~/lib/health-check/health-check-service'; -import { convertJsonSchemaToParameters, extractToolSchema } from '~/lib/schema-extraction'; +import { + convertJsonSchemaToParameters, + extractToolSchema, + listToolExports, +} from '~/lib/schema-extraction'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -145,19 +150,60 @@ export async function POST(request: NextRequest) { where: { packageId: packageRecord.id }, }); - // Upsert each tool in the tools array - for (const toolDef of validation.tools) { + // Determine the tools to process + let toolsToProcess: TpmjsToolDefinition[] = validation.tools || []; + let toolDiscoverySource: 'auto' | 'manual' = 'manual'; + + // If tools need auto-discovery, call the executor to list exports + if (validation.needsAutoDiscovery) { + console.log(`Auto-discovering tools for ${pkg.name}...`); + const exportsResult = await listToolExports(pkg.name, pkg.version, null); + + if (exportsResult.success) { + // Convert discovered tools to TpmjsToolDefinition format + toolsToProcess = exportsResult.tools + .filter((t) => t.isValidTool) + .map((t) => ({ + name: t.name, + description: t.description, + })); + toolDiscoverySource = 'auto'; + console.log( + `Auto-discovered ${toolsToProcess.length} tools for ${pkg.name}: ${toolsToProcess.map((t) => t.name).join(', ')}` + ); + } else { + console.log(`Failed to auto-discover tools for ${pkg.name}: ${exportsResult.error}`); + // Skip this package if we can't discover tools + skipped++; + skippedPackages.push({ + name: pkg.name, + author: authorName, + reason: `auto-discovery failed: ${exportsResult.error}`, + }); + continue; + } + } + + // Upsert each tool + for (const toolDef of toolsToProcess) { + // Use 'name' field (new) or fall back to 'exportName' (legacy support) + const toolName = toolDef.name || (toolDef as { exportName?: string }).exportName; + if (!toolName) { + console.warn(`Skipping tool without name in ${pkg.name}`); + continue; + } + const upsertedTool = await prisma.tool.upsert({ where: { packageId_exportName: { packageId: packageRecord.id, - exportName: toolDef.exportName, + exportName: toolName, }, }, create: { packageId: packageRecord.id, - exportName: toolDef.exportName, - description: toolDef.description, + exportName: toolName, + description: toolDef.description || 'No description provided', // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround @@ -167,29 +213,25 @@ export async function POST(request: NextRequest) { qualityScore: null, // Will be calculated by metrics sync // Schema will be extracted below schemaSource: toolDef.parameters ? 'author' : null, + toolDiscoverySource, }, update: { - description: toolDef.description, + description: toolDef.description || undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround returns: toolDef.returns ? (toolDef.returns as any) : undefined, // biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined, + toolDiscoverySource, }, }); // Extract schema synchronously from executor - // Note: We pass null for env as schema extraction doesn't need env values - const schemaResult = await extractToolSchema( - pkg.name, - toolDef.exportName, - pkg.version, - null - ); + const schemaResult = await extractToolSchema(pkg.name, toolName, pkg.version, null); if (schemaResult.success) { - // Update tool with extracted schema + // Update tool with extracted schema (and description if not provided) await prisma.tool.update({ where: { id: upsertedTool.id }, data: { @@ -200,13 +242,17 @@ export async function POST(request: NextRequest) { parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any, schemaSource: 'extracted', schemaExtractedAt: new Date(), + // Update description if not provided by author + ...(!toolDef.description && schemaResult.description + ? { description: schemaResult.description } + : {}), }, }); - console.log(`Schema extracted for ${pkg.name}/${toolDef.exportName}`); + console.log(`Schema extracted for ${pkg.name}/${toolName}`); } else { // Extraction failed - mark schema source appropriately console.log( - `Schema extraction failed for ${pkg.name}/${toolDef.exportName}: ${schemaResult.error}` + `Schema extraction failed for ${pkg.name}/${toolName}: ${schemaResult.error}` ); await prisma.tool.update({ where: { id: upsertedTool.id }, @@ -219,7 +265,7 @@ export async function POST(request: NextRequest) { // Trigger health check (non-blocking) for execution testing performHealthCheck(upsertedTool.id, 'sync').catch((err) => { console.error( - `Health check failed for ${pkg.name}/${toolDef.exportName} (${upsertedTool.id}):`, + `Health check failed for ${pkg.name}/${toolName} (${upsertedTool.id}):`, err ); }); @@ -228,7 +274,10 @@ export async function POST(request: NextRequest) { // Delete orphaned tools (tools removed from package.json) const orphanedTools = existingTools.filter( (existingTool) => - !validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName) + !toolsToProcess.some((toolDef) => { + const toolName = toolDef.name || (toolDef as { exportName?: string }).exportName; + return toolName === existingTool.exportName; + }) ); if (orphanedTools.length > 0) { diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index 3fa6ba7..70529eb 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -862,13 +862,22 @@ while (true) { "frameworks": ["vercel-ai"], "tools": [ { - "exportName": "myTool", + "name": "myTool", "description": "What your tool does (20-500 chars)" } ] } }`} /> +
+

+ 🔍 Auto-Discovery: The{' '} + tools array is optional! If you omit it, + TPMJS will automatically discover all exported tools from your package. Each + export that has a description and{' '} + execute property is treated as a valid tool. +

+
@@ -878,7 +887,8 @@ while (true) {

- TPMJS now auto-extracts parameter schemas, simplifying what you need to provide. + TPMJS auto-extracts parameter schemas and can auto-discover your tools, simplifying + what you need to provide.

@@ -886,10 +896,7 @@ while (true) { Required

- category,{' '} - tools (with{' '} - exportName +{' '} - description) + category - The only truly required field!

@@ -897,6 +904,7 @@ while (true) { Optional

+ tools (auto-discovered if omitted),{' '} env (API keys),{' '} frameworks (compatibility)

@@ -906,9 +914,19 @@ while (true) { Auto-extracted

- parameters,{' '} - returns,{' '} - aiAgent - extracted from your tool code + description,{' '} + parameters - extracted from your tool code +

+ +
+
+ Auto-discovered +
+

+ If you omit tools, TPMJS scans your + package exports and registers any export with{' '} + description +{' '} + execute properties as a tool.

@@ -1080,6 +1098,10 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`} q: 'How long does it take for my tool to appear?', a: 'Tools are discovered within 2-15 minutes of publishing to npm. Make sure you have the "tpmjs-tool" keyword in your package.json.', }, + { + q: 'What is auto-discovery?', + a: 'If you omit the "tools" array from your tpmjs field, TPMJS will automatically scan your package exports and register any export that looks like an AI SDK tool (has description and execute properties). You can override this by explicitly listing tools.', + }, { q: 'How does schema extraction work?', a: "TPMJS automatically loads your tool in a sandbox and extracts the inputSchema from your Zod definition. You don't need to manually document parameters.", diff --git a/apps/web/src/app/publish/page.tsx b/apps/web/src/app/publish/page.tsx index 405b321..bbfd7b4 100644 --- a/apps/web/src/app/publish/page.tsx +++ b/apps/web/src/app/publish/page.tsx @@ -142,13 +142,32 @@ export default function PublishPage(): React.ReactElement { + {/* Auto-discovery callout */} +
+
+
🔍
+
+

+ Auto-Discovery of Tools +

+

+ You can omit the tools array entirely! + TPMJS will automatically scan your package exports and register any export that + has description and{' '} + execute properties (standard AI SDK + tool format). +

+
+
+
+ {/* Minimal Example */}
- Required Fields + Minimal (Auto-Discovery) - All you need to provide + Let TPMJS find your tools

- That's it! Parameters are automatically extracted from your tool code. + That's it! Tools and parameters are automatically discovered and extracted.

@@ -175,10 +188,10 @@ export default function PublishPage(): React.ReactElement {
- With Optional Fields + With Explicit Tools - Add env vars and framework compatibility + Override auto-discovery with explicit tools

- Add env for API keys and{' '} + Add tools to explicitly register specific + tools. Add env for API keys and{' '} frameworks for compatibility info.

@@ -307,7 +321,7 @@ npm publish --access public "frameworks": ["vercel-ai", "langchain"], "tools": [ { - "exportName": "createBlogPostTool", + "name": "createBlogPostTool", "description": "Creates structured blog posts with frontmatter and SEO metadata" } ] @@ -316,7 +330,7 @@ npm publish --access public />

Note: Parameters are automatically extracted from the tool code - no need to list them - in package.json! + in package.json! You can also omit the tools array entirely for auto-discovery.

diff --git a/apps/web/src/app/spec/page.tsx b/apps/web/src/app/spec/page.tsx index 2e5e437..114c966 100644 --- a/apps/web/src/app/spec/page.tsx +++ b/apps/web/src/app/spec/page.tsx @@ -181,54 +181,63 @@ export default function SpecPage(): React.ReactElement { ))} - -
-

- tools * -

-

- Array of tools exported by your package. Each tool needs: -

-
    -
  • - exportName - The exported - function name (required) -
  • -
  • - description - What the tool does, - 20-500 chars (required) -
  • -
-
-
Minimal Example:
+
+ Minimal Example (auto-discovery): +

- That's it! TPMJS will automatically extract the inputSchema from your - tool when it syncs. + That's it! TPMJS will automatically discover your exported tools and + extract their schemas.

+ {/* Auto-Discovery */} +
+
+
🔍
+
+

+ Auto-Discovery of Tools +

+

+ When you omit the tools array, TPMJS + automatically scans your package exports and registers any export that looks + like an AI SDK tool (has description{' '} + and execute properties). +

+
+
+ Automatic +

+ Works with any AI SDK compatible tool +

+
+
+ Override +

+ Add explicit tools to control what gets registered +

+
+
+
+
+
+ {/* Optional Fields */}
@@ -242,6 +251,26 @@ export default function SpecPage(): React.ReactElement {
+
+

+ tools +

+

+ Array of tools to register. If omitted, tools are auto-discovered. Each tool + has: +

+
    +
  • + name - The exported function name + (required) +
  • +
  • + description - What the tool does + (optional, auto-extracted if omitted) +
  • +
+
+

env @@ -290,7 +319,7 @@ export default function SpecPage(): React.ReactElement {
- Complete Example with Optional Fields: + Example with explicit tools:
- {/* Deprecated Fields */} + {/* Auto-Extracted Fields */}
- Deprecated + Auto-Extracted - Now auto-extracted (kept for backward compatibility) + No need to specify (kept for backward compatibility)

- The following fields are now automatically extracted from your tool code. You no - longer need to specify them manually: + The following fields are automatically extracted from your tool code. You can + optionally provide them to override the extracted values:

    +
  • + + description + + → Auto-extracted from tool's description property +
  • parameters @@ -453,11 +488,10 @@ export default function SpecPage(): React.ReactElement { tools array + No - Yes - - - Array of tool definitions (exportName + description) + Array of tool definitions (name + description). Auto-discovered if + omitted. diff --git a/apps/web/src/app/tool/[...slug]/page.tsx b/apps/web/src/app/tool/[...slug]/page.tsx index 547daca..6a5ee8d 100644 --- a/apps/web/src/app/tool/[...slug]/page.tsx +++ b/apps/web/src/app/tool/[...slug]/page.tsx @@ -50,6 +50,7 @@ interface Tool { inputSchema: Record | null; schemaSource: 'extracted' | 'author' | null; schemaExtractedAt: string | null; + toolDiscoverySource: 'auto' | 'manual' | null; returns: { type: string; description: string; @@ -294,9 +295,34 @@ export default function ToolDetailPage({ {pkg.category} v{pkg.npmVersion} {pkg.npmLicense && {pkg.npmLicense}} + {tool.toolDiscoverySource === 'auto' && ( + + Auto-discovered + + )}

+ {/* Auto-discovery info banner */} + {tool.toolDiscoverySource === 'auto' && ( +
+
+ 🔍 +
+

+ Auto-discovered tool +

+

+ This tool was automatically discovered from the package exports. The author did + not explicitly register it in their{' '} + package.json. Schema and description were + auto-extracted. +

+
+
+
+ )} + {/* Health warning banner */} {(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
diff --git a/apps/web/src/lib/schema-extraction.ts b/apps/web/src/lib/schema-extraction.ts index 0ea81bf..b42dcf2 100644 --- a/apps/web/src/lib/schema-extraction.ts +++ b/apps/web/src/lib/schema-extraction.ts @@ -7,6 +7,89 @@ import { env } from '~/env'; const RAILWAY_EXECUTOR_URL = env.RAILWAY_EXECUTOR_URL; +/** + * Result from listing exports + */ +export interface ListExportsSuccess { + success: true; + packageName: string; + version: string; + exports: string[]; + tools: Array<{ + name: string; + isValidTool: boolean; + description?: string; + error?: string; + }>; +} + +export interface ListExportsFailure { + success: false; + error: string; +} + +export type ListExportsResult = ListExportsSuccess | ListExportsFailure; + +/** + * List all exports from a package and identify valid tools + * Calls the executor's /list-exports endpoint + * + * @param packageName - NPM package name + * @param version - Package version + * @param packageEnv - Package-level environment variables (optional) + * @returns List of exports with tool validation info + */ +export async function listToolExports( + packageName: string, + version: string, + packageEnv?: Record | null +): Promise { + try { + const response = await fetch(`${RAILWAY_EXECUTOR_URL}/list-exports`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + packageName, + version, + env: packageEnv || {}, + }), + signal: AbortSignal.timeout(15000), // 15 second timeout + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + return { success: false, error: `HTTP ${response.status}: ${errorText || 'Request failed'}` }; + } + + const data = await response.json(); + + if (!data.success) { + return { success: false, error: data.error || 'Failed to list exports' }; + } + + return { + success: true, + packageName: data.packageName, + version: data.version, + exports: data.exports, + tools: data.tools, + }; + } catch (error) { + // Handle timeout specifically + if (error instanceof Error && error.name === 'TimeoutError') { + return { + success: false, + error: 'Listing exports timed out after 15 seconds', + }; + } + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error listing exports', + }; + } +} + export interface SchemaExtractionSuccess { success: true; inputSchema: Record; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 137a121..342c484 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -75,6 +75,9 @@ model Tool { schemaSource String? @map("schema_source") @db.VarChar(20) // 'extracted' | 'author' | null schemaExtractedAt DateTime? @map("schema_extracted_at") + // Tool Discovery Fields + toolDiscoverySource String? @map("tool_discovery_source") @db.VarChar(20) // 'auto' | 'manual' | null + // Tool Metrics qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00 diff --git a/packages/types/src/tpmjs.ts b/packages/types/src/tpmjs.ts index 282e7fe..e74820a 100644 --- a/packages/types/src/tpmjs.ts +++ b/packages/types/src/tpmjs.ts @@ -80,8 +80,10 @@ export type TpmjsAiAgent = z.infer; * Individual tool definition within a multi-tool package * * Required fields: - * - exportName: The export name of the tool from the package - * - description: A description of what the tool does (20-500 chars) + * - name: The export name of the tool from the package + * + * Optional fields (auto-extracted if not provided): + * - description: A description of what the tool does (20-500 chars) - auto-extracted from tool * * @deprecated fields (now auto-extracted, kept for backward compatibility): * - parameters: Tool input parameters - auto-extracted from inputSchema @@ -89,8 +91,9 @@ export type TpmjsAiAgent = z.infer; * - aiAgent: AI agent guidance - auto-extracted from tool */ export const TpmjsToolDefinitionSchema = z.object({ - exportName: z.string().min(1, 'Export name is required'), - description: z.string().min(20, 'Description must be at least 20 characters').max(500), + name: z.string().min(1, 'Tool name is required'), + // Optional - auto-extracted from tool if not provided + description: z.string().min(20, 'Description must be at least 20 characters').max(500).optional(), // @deprecated - now auto-extracted from tool's inputSchema parameters: z.array(TpmjsParameterSchema).optional(), // @deprecated - now auto-extracted from tool @@ -103,13 +106,17 @@ export type TpmjsToolDefinition = z.infer; /** * Multi-tool format - NEW SCHEMA - * Package-level metadata with array of tools + * Package-level metadata with optional array of tools + * + * If tools is not provided, TPMJS will auto-discover exports from the package. + * Authors can override auto-discovery by providing explicit tool definitions. */ export const TpmjsMultiToolSchema = z.object({ category: z.enum(TPMJS_CATEGORIES, { message: `Category must be one of: ${TPMJS_CATEGORIES.join(', ')}`, }), - tools: z.array(TpmjsToolDefinitionSchema).min(1, 'At least one tool is required'), + // Optional - if not provided, tools are auto-discovered from package exports + tools: z.array(TpmjsToolDefinitionSchema).optional(), env: z.array(TpmjsEnvSchema).optional(), frameworks: z .array(z.enum(['vercel-ai', 'langchain', 'llamaindex', 'haystack', 'semantic-kernel'])) @@ -179,6 +186,8 @@ export interface ValidationResult { }; tools?: TpmjsToolDefinition[]; wasLegacyFormat?: boolean; + // When true, tools need to be auto-discovered from package exports + needsAutoDiscovery?: boolean; } /** @@ -191,9 +200,12 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult { if (multiResult.success) { const data = multiResult.data; + // Check if tools need auto-discovery + const needsAutoDiscovery = !data.tools || data.tools.length === 0; + // Determine tier based on tool richness const hasRichFields = - data.tools.some((tool) => tool.parameters || tool.returns || tool.aiAgent) || + (data.tools?.some((tool) => tool.parameters || tool.returns || tool.aiAgent) ?? false) || data.env || data.frameworks; @@ -208,6 +220,7 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult { }, tools: data.tools, wasLegacyFormat: false, + needsAutoDiscovery, }; } @@ -218,7 +231,7 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult { // Auto-migrate to multi-tool format const tool: TpmjsToolDefinition = { - exportName: 'default', + name: 'default', description: legacyData.description, parameters: legacyData.parameters, returns: legacyData.returns, @@ -243,6 +256,7 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult { }, tools: [tool], wasLegacyFormat: true, + needsAutoDiscovery: false, }; } @@ -251,7 +265,7 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult { if (minimalResult.success) { // Auto-migrate to multi-tool format const tool: TpmjsToolDefinition = { - exportName: 'default', + name: 'default', description: minimalResult.data.description, }; @@ -264,6 +278,7 @@ export function validateTpmjsField(tpmjs: unknown): ValidationResult { }, tools: [tool], wasLegacyFormat: true, + needsAutoDiscovery: false, }; }