feat(tpmjs-spec): add auto-discovery of tools and rename exportName to name

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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-17 14:02:26 +10:00
parent 70d112982e
commit 5fc584fc66
10 changed files with 501 additions and 112 deletions

View file

@ -725,6 +725,102 @@ async function executeTool(req: Request): Promise<Response> {
}
}
/**
* List all exports from a package and identify which are valid AI SDK tools
*/
async function listExports(req: Request): Promise<Response> {
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> {
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');

View file

@ -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) {

View file

@ -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) {

View file

@ -862,13 +862,22 @@ while (true) {
"frameworks": ["vercel-ai"],
"tools": [
{
"exportName": "myTool",
"name": "myTool",
"description": "What your tool does (20-500 chars)"
}
]
}
}`}
/>
<div className="mt-6 p-4 border border-primary/30 rounded-lg bg-primary/5">
<p className="text-sm text-foreground-secondary">
<strong className="text-foreground">🔍 Auto-Discovery:</strong> The{' '}
<code className="text-primary">tools</code> array is optional! If you omit it,
TPMJS will automatically discover all exported tools from your package. Each
export that has a <code className="text-primary">description</code> and{' '}
<code className="text-primary">execute</code> property is treated as a valid tool.
</p>
</div>
<div className="mt-6">
<Link href="/spec">
<Button variant="outline">View Full Specification</Button>
@ -878,7 +887,8 @@ while (true) {
<DocSection id="metadata-tiers" title="Metadata Fields">
<p className="text-foreground-secondary mb-6">
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.
</p>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg bg-surface">
@ -886,10 +896,7 @@ while (true) {
<Badge variant="default">Required</Badge>
</div>
<p className="text-sm text-foreground-secondary">
<code className="text-primary">category</code>,{' '}
<code className="text-primary">tools</code> (with{' '}
<code className="text-primary">exportName</code> +{' '}
<code className="text-primary">description</code>)
<code className="text-primary">category</code> - The only truly required field!
</p>
</div>
<div className="p-4 border border-border rounded-lg bg-surface">
@ -897,6 +904,7 @@ while (true) {
<Badge variant="success">Optional</Badge>
</div>
<p className="text-sm text-foreground-secondary">
<code className="text-primary">tools</code> (auto-discovered if omitted),{' '}
<code className="text-primary">env</code> (API keys),{' '}
<code className="text-primary">frameworks</code> (compatibility)
</p>
@ -906,9 +914,19 @@ while (true) {
<Badge variant="outline">Auto-extracted</Badge>
</div>
<p className="text-sm text-foreground-secondary">
<code className="text-primary">parameters</code>,{' '}
<code className="text-primary">returns</code>,{' '}
<code className="text-primary">aiAgent</code> - extracted from your tool code
<code className="text-primary">description</code>,{' '}
<code className="text-primary">parameters</code> - extracted from your tool code
</p>
</div>
<div className="p-4 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-2 mb-2">
<Badge variant="warning">Auto-discovered</Badge>
</div>
<p className="text-sm text-foreground-secondary">
If you omit <code className="text-primary">tools</code>, TPMJS scans your
package exports and registers any export with{' '}
<code className="text-primary">description</code> +{' '}
<code className="text-primary">execute</code> properties as a tool.
</p>
</div>
</div>
@ -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.",

View file

@ -142,13 +142,32 @@ export default function PublishPage(): React.ReactElement {
</div>
</div>
{/* Auto-discovery callout */}
<div className="mb-8 p-6 border-2 border-amber-500/30 rounded-lg bg-amber-500/5">
<div className="flex items-start gap-4">
<div className="text-3xl">🔍</div>
<div>
<h3 className="text-xl font-bold text-foreground mb-2">
Auto-Discovery of Tools
</h3>
<p className="text-foreground-secondary">
You can omit the <code className="text-foreground">tools</code> array entirely!
TPMJS will automatically scan your package exports and register any export that
has <code className="text-foreground">description</code> and{' '}
<code className="text-foreground">execute</code> properties (standard AI SDK
tool format).
</p>
</div>
</div>
</div>
{/* Minimal Example */}
<div className="mb-8 p-6 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-3 mb-4">
<span className="px-3 py-1 bg-primary/20 rounded text-sm font-medium text-foreground">
Required Fields
Minimal (Auto-Discovery)
</span>
<span className="text-foreground-secondary">All you need to provide</span>
<span className="text-foreground-secondary">Let TPMJS find your tools</span>
</div>
<CodeBlock
language="json"
@ -156,18 +175,12 @@ export default function PublishPage(): React.ReactElement {
"name": "@yourname/my-awesome-tool",
"keywords": ["tpmjs-tool"],
"tpmjs": {
"category": "text-analysis",
"tools": [
{
"exportName": "myTool",
"description": "A concise description of what your tool does (20-500 chars)"
}
]
"category": "text-analysis"
}
}`}
/>
<p className="mt-4 text-sm text-foreground-secondary">
That&apos;s it! Parameters are automatically extracted from your tool code.
That&apos;s it! Tools and parameters are automatically discovered and extracted.
</p>
</div>
@ -175,10 +188,10 @@ export default function PublishPage(): React.ReactElement {
<div className="mb-8 p-6 border border-border rounded-lg bg-surface">
<div className="flex items-center gap-3 mb-4">
<span className="px-3 py-1 bg-success/20 rounded text-sm font-medium text-foreground">
With Optional Fields
With Explicit Tools
</span>
<span className="text-foreground-secondary">
Add env vars and framework compatibility
Override auto-discovery with explicit tools
</span>
</div>
<CodeBlock
@ -198,7 +211,7 @@ export default function PublishPage(): React.ReactElement {
],
"tools": [
{
"exportName": "sentimentAnalysisTool",
"name": "sentimentAnalysisTool",
"description": "Advanced sentiment analysis with emotion detection"
}
]
@ -206,7 +219,8 @@ export default function PublishPage(): React.ReactElement {
}`}
/>
<p className="mt-4 text-sm text-foreground-secondary">
Add <code className="text-foreground">env</code> for API keys and{' '}
Add <code className="text-foreground">tools</code> to explicitly register specific
tools. Add <code className="text-foreground">env</code> for API keys and{' '}
<code className="text-foreground">frameworks</code> for compatibility info.
</p>
</div>
@ -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
/>
<p className="mt-4 text-sm text-foreground-secondary">
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.
</p>
</section>

View file

@ -181,54 +181,63 @@ export default function SpecPage(): React.ReactElement {
))}
</div>
</div>
<div>
<h4 className="text-lg font-semibold text-foreground mb-2">
<code>tools</code> <span className="text-red-500">*</span>
</h4>
<p className="text-sm text-foreground-secondary mb-3">
Array of tools exported by your package. Each tool needs:
</p>
<ul className="list-disc list-inside space-y-1 text-sm text-foreground-secondary ml-4">
<li>
<code className="text-foreground">exportName</code> - The exported
function name (required)
</li>
<li>
<code className="text-foreground">description</code> - What the tool does,
20-500 chars (required)
</li>
</ul>
</div>
</div>
<div className="mt-6">
<h5 className="text-sm font-semibold text-foreground mb-3">Minimal Example:</h5>
<h5 className="text-sm font-semibold text-foreground mb-3">
Minimal Example (auto-discovery):
</h5>
<CodeBlock
language="json"
code={`{
"name": "@yourname/my-tool",
"keywords": ["tpmjs-tool"],
"tpmjs": {
"category": "text-analysis",
"tools": [
{
"exportName": "sentimentAnalysisTool",
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
}
]
"category": "text-analysis"
}
}`}
/>
<p className="text-sm text-foreground-secondary mt-4">
That&apos;s it! TPMJS will automatically extract the inputSchema from your
tool when it syncs.
That&apos;s it! TPMJS will automatically discover your exported tools and
extract their schemas.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Auto-Discovery */}
<div className="mb-12 p-6 border-2 border-amber-500/30 rounded-lg bg-amber-500/5">
<div className="flex items-start gap-4">
<div className="text-3xl">🔍</div>
<div>
<h3 className="text-xl font-bold text-foreground mb-2">
Auto-Discovery of Tools
</h3>
<p className="text-foreground-secondary mb-4">
When you omit the <code className="text-foreground">tools</code> array, TPMJS
automatically scans your package exports and registers any export that looks
like an AI SDK tool (has <code className="text-foreground">description</code>{' '}
and <code className="text-foreground">execute</code> properties).
</p>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div className="p-3 bg-background rounded border border-border">
<strong className="text-foreground">Automatic</strong>
<p className="text-foreground-secondary mt-1">
Works with any AI SDK compatible tool
</p>
</div>
<div className="p-3 bg-background rounded border border-border">
<strong className="text-foreground">Override</strong>
<p className="text-foreground-secondary mt-1">
Add explicit <code>tools</code> to control what gets registered
</p>
</div>
</div>
</div>
</div>
</div>
{/* Optional Fields */}
<div className="mb-12">
<div className="flex items-center gap-3 mb-4">
@ -242,6 +251,26 @@ export default function SpecPage(): React.ReactElement {
<Card>
<CardContent className="pt-6">
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold text-foreground mb-2">
<code>tools</code>
</h4>
<p className="text-sm text-foreground-secondary mb-2">
Array of tools to register. If omitted, tools are auto-discovered. Each tool
has:
</p>
<ul className="list-disc list-inside space-y-1 text-sm text-foreground-secondary ml-4">
<li>
<code className="text-foreground">name</code> - The exported function name
(required)
</li>
<li>
<code className="text-foreground">description</code> - What the tool does
(optional, auto-extracted if omitted)
</li>
</ul>
</div>
<div>
<h4 className="text-lg font-semibold text-foreground mb-2">
<code>env</code>
@ -290,7 +319,7 @@ export default function SpecPage(): React.ReactElement {
<div className="mt-6">
<h5 className="text-sm font-semibold text-foreground mb-3">
Complete Example with Optional Fields:
Example with explicit tools:
</h5>
<CodeBlock
language="json"
@ -309,7 +338,7 @@ export default function SpecPage(): React.ReactElement {
],
"tools": [
{
"exportName": "sentimentAnalysisTool",
"name": "sentimentAnalysisTool",
"description": "Advanced sentiment analysis with emotion detection"
}
]
@ -321,23 +350,29 @@ export default function SpecPage(): React.ReactElement {
</Card>
</div>
{/* Deprecated Fields */}
{/* Auto-Extracted Fields */}
<div className="mb-12">
<div className="flex items-center gap-3 mb-4">
<Badge variant="outline" size="lg">
Deprecated
Auto-Extracted
</Badge>
<span className="text-foreground-secondary">
Now auto-extracted (kept for backward compatibility)
No need to specify (kept for backward compatibility)
</span>
</div>
<Card>
<CardContent className="pt-6">
<p className="text-foreground-secondary mb-4">
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:
</p>
<ul className="space-y-3 text-sm text-foreground-secondary">
<li className="flex items-start gap-2">
<code className="text-foreground bg-surface px-2 py-1 rounded">
description
</code>
<span> Auto-extracted from tool&apos;s description property</span>
</li>
<li className="flex items-start gap-2">
<code className="text-foreground bg-surface px-2 py-1 rounded">
parameters
@ -453,11 +488,10 @@ export default function SpecPage(): React.ReactElement {
<code className="text-foreground">tools</code>
</td>
<td className="py-3 px-4">array</td>
<td className="py-3 px-4">No</td>
<td className="py-3 px-4">
<span className="text-red-500">Yes</span>
</td>
<td className="py-3 px-4">
Array of tool definitions (exportName + description)
Array of tool definitions (name + description). Auto-discovered if
omitted.
</td>
</tr>
<tr className="border-b border-border">

View file

@ -50,6 +50,7 @@ interface Tool {
inputSchema: Record<string, unknown> | 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({
<Badge variant="secondary">{pkg.category}</Badge>
<Badge variant="outline">v{pkg.npmVersion}</Badge>
{pkg.npmLicense && <Badge variant="outline">{pkg.npmLicense}</Badge>}
{tool.toolDiscoverySource === 'auto' && (
<Badge variant="warning" size="sm">
Auto-discovered
</Badge>
)}
</div>
</div>
{/* Auto-discovery info banner */}
{tool.toolDiscoverySource === 'auto' && (
<div className="mb-6 p-4 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900">
<div className="flex items-start gap-3">
<span className="text-xl mt-0.5">🔍</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-1">
Auto-discovered tool
</h3>
<p className="text-sm text-amber-700 dark:text-amber-400">
This tool was automatically discovered from the package exports. The author did
not explicitly register it in their{' '}
<code className="font-mono">package.json</code>. Schema and description were
auto-extracted.
</p>
</div>
</div>
</div>
)}
{/* Health warning banner */}
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">

View file

@ -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<string, unknown> | null
): Promise<ListExportsResult> {
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<string, unknown>;

View file

@ -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

View file

@ -80,8 +80,10 @@ export type TpmjsAiAgent = z.infer<typeof TpmjsAiAgentSchema>;
* 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<typeof TpmjsAiAgentSchema>;
* - 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<typeof TpmjsToolDefinitionSchema>;
/**
* 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,
};
}