feat(schema-extraction): auto-extract inputSchema via executor during sync

- Add inputSchema, schemaSource, schemaExtractedAt fields to Tool model
- Create schema extraction helper that calls executor's /load-and-describe
- Update sync routes to extract schema synchronously after tool upsert
- Reduce changes feed batch size from 100 to 30 for extraction time
- Update /api/tools/update-schema to store full JSON Schema
- Add /api/tools/extract-schema endpoint for manual re-extraction
- Update tool page UI with schema source badge and re-extract button
- Add deprecation comments to parameters, returns, aiAgent in types

Authors no longer need to define inputSchema in package.json - it's now
automatically extracted from the tool at sync time. Falls back to
author-provided parameters if extraction fails.
This commit is contained in:
Ajax Davis 2025-12-17 12:56:25 +10:00
parent 65efebe692
commit 0062cfee8d
8 changed files with 556 additions and 53 deletions

View file

@ -4,6 +4,7 @@ import { validateTpmjsField } 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';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -42,10 +43,10 @@ export async function POST(request: NextRequest) {
? String((checkpoint.checkpoint as { lastSeq?: string })?.lastSeq || '0')
: '0';
// Fetch changes from NPM (limit to 100 per run to avoid timeouts)
// Fetch changes from NPM (limit to 30 per run to allow time for schema extraction)
const changesResult = await fetchChanges({
since: lastSeq,
limit: 100,
limit: 30,
includeDocs: false,
});
@ -150,6 +151,8 @@ export async function POST(request: NextRequest) {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
// Schema will be extracted below
schemaSource: toolDef.parameters ? 'author' : null,
},
update: {
description: toolDef.description,
@ -162,7 +165,44 @@ export async function POST(request: NextRequest) {
},
});
// Trigger immediate health check (non-blocking)
// 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
);
if (schemaResult.success) {
// Update tool with extracted schema
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: schemaResult.inputSchema as any,
// Also update parameters array for backward compatibility
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
},
});
console.log(`Schema extracted for ${pkg.name}/${toolDef.exportName}`);
} else {
// Extraction failed - mark schema source appropriately
console.log(
`Schema extraction failed for ${pkg.name}/${toolDef.exportName}: ${schemaResult.error}`
);
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
schemaSource: toolDef.parameters ? 'author' : null,
},
});
}
// 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}):`,

View file

@ -4,6 +4,7 @@ import { validateTpmjsField } 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';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -164,6 +165,8 @@ export async function POST(request: NextRequest) {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
qualityScore: null, // Will be calculated by metrics sync
// Schema will be extracted below
schemaSource: toolDef.parameters ? 'author' : null,
},
update: {
description: toolDef.description,
@ -176,7 +179,44 @@ export async function POST(request: NextRequest) {
},
});
// Trigger immediate health check (non-blocking)
// 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
);
if (schemaResult.success) {
// Update tool with extracted schema
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: schemaResult.inputSchema as any,
// Also update parameters array for backward compatibility
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
},
});
console.log(`Schema extracted for ${pkg.name}/${toolDef.exportName}`);
} else {
// Extraction failed - mark schema source appropriately
console.log(
`Schema extraction failed for ${pkg.name}/${toolDef.exportName}: ${schemaResult.error}`
);
await prisma.tool.update({
where: { id: upsertedTool.id },
data: {
schemaSource: toolDef.parameters ? 'author' : null,
},
});
}
// 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}):`,

View file

@ -0,0 +1,159 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { convertJsonSchemaToParameters, extractToolSchema } from '~/lib/schema-extraction';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/tools/extract-schema
* Manually trigger schema extraction for a tool
*
* Body:
* - packageName: npm package name
* - exportName: exported function name
*
* Rate limited to 1 extraction per minute per tool
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { packageName, exportName } = body;
if (!packageName || !exportName) {
return NextResponse.json(
{ success: false, error: 'packageName and exportName are required' },
{ status: 400 }
);
}
console.log('[Extract Schema] Looking up tool:', { packageName, exportName });
// Find the tool by package name and export name
const tool = await prisma.tool.findFirst({
where: {
exportName,
package: {
npmPackageName: packageName,
},
},
include: {
package: {
select: {
npmPackageName: true,
npmVersion: true,
env: true,
},
},
},
});
if (!tool) {
console.log('[Extract Schema] Tool not found:', { packageName, exportName });
return NextResponse.json({ success: false, error: 'Tool not found' }, { status: 404 });
}
// Rate limit: 1 minute cooldown
if (tool.schemaExtractedAt) {
const timeSinceLastExtraction = Date.now() - tool.schemaExtractedAt.getTime();
const cooldownMs = 60000; // 1 minute
if (timeSinceLastExtraction < cooldownMs) {
const retryAfter = Math.ceil((cooldownMs - timeSinceLastExtraction) / 1000);
return NextResponse.json(
{
success: false,
error: 'Rate limited',
message: `Please wait ${retryAfter} seconds before trying again`,
retryAfter,
},
{ status: 429 }
);
}
}
console.log('[Extract Schema] Extracting schema for:', {
packageName: tool.package.npmPackageName,
exportName: tool.exportName,
version: tool.package.npmVersion,
});
// Extract schema from executor
const schemaResult = await extractToolSchema(
tool.package.npmPackageName,
tool.exportName,
tool.package.npmVersion,
tool.package.env as Record<string, unknown> | null
);
if (schemaResult.success) {
// Update tool with extracted schema
const updatedTool = await prisma.tool.update({
where: { id: tool.id },
data: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: schemaResult.inputSchema as any,
// Also update parameters array for backward compatibility
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
},
select: {
id: true,
exportName: true,
inputSchema: true,
parameters: true,
schemaSource: true,
schemaExtractedAt: true,
},
});
console.log('[Extract Schema] Schema extracted successfully:', {
toolId: updatedTool.id,
exportName: updatedTool.exportName,
schemaSource: updatedTool.schemaSource,
});
return NextResponse.json({
success: true,
message: 'Schema extracted successfully',
schemaSource: 'extracted',
tool: updatedTool,
});
}
// Extraction failed
console.log('[Extract Schema] Extraction failed:', {
packageName,
exportName,
error: schemaResult.error,
});
// Update tool to mark extraction attempt
await prisma.tool.update({
where: { id: tool.id },
data: {
schemaExtractedAt: new Date(), // Update timestamp even on failure for rate limiting
},
});
return NextResponse.json({
success: false,
error: 'Schema extraction failed',
message: schemaResult.error,
schemaSource: tool.schemaSource,
});
} catch (error) {
console.error('[Extract Schema] Error:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to extract schema',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View file

@ -1,15 +1,17 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
import { convertJsonSchemaToParameters } from '~/lib/schema-extraction';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/tools/update-schema
* Update a tool's input schema (parameters field)
* Update a tool's input schema
*
* Called by the executor when it loads a tool and discovers its schema.
* Looks up tool by packageName + exportName (unique constraint).
* Stores the full JSON Schema and also converts to parameters array for backward compatibility.
*
* Body:
* - packageName: npm package name
@ -48,7 +50,8 @@ export async function POST(request: NextRequest) {
},
select: {
id: true,
parameters: true,
inputSchema: true,
schemaSource: true,
},
});
@ -60,49 +63,39 @@ export async function POST(request: NextRequest) {
);
}
// Convert JSON Schema to our parameters format
const parameters: Array<{
name: string;
type: string;
required: boolean;
description: string;
}> = [];
if (inputSchema.properties) {
for (const [name, prop] of Object.entries(inputSchema.properties)) {
const propDef = prop as { type?: string; description?: string };
parameters.push({
name,
type: propDef.type || 'string',
required: inputSchema.required?.includes(name) || false,
description: propDef.description || '',
});
}
}
// Convert JSON Schema to parameters array for backward compatibility
const parameters = convertJsonSchemaToParameters(inputSchema);
// Check if parameters already match (avoid unnecessary updates)
const existingParams = tool.parameters as Array<{ name: string }> | null;
const existingParamNames =
existingParams
?.map((p) => p.name)
.sort()
.join(',') || '';
const newParamNames = parameters
.map((p) => p.name)
.sort()
.join(',');
if (existingParamNames === newParamNames && parameters.length > 0) {
// Check if schema already matches (avoid unnecessary updates)
const existingSchema = tool.inputSchema as Record<string, unknown> | null;
if (
existingSchema &&
tool.schemaSource === 'extracted' &&
JSON.stringify(existingSchema) === JSON.stringify(inputSchema)
) {
console.log('[Update Schema] Schema already up to date:', { packageName, exportName });
return NextResponse.json({
success: true,
updated: false,
message: 'Schema already up to date',
schemaSource: tool.schemaSource,
});
}
// Update the tool
const updateData: { parameters: typeof parameters; description?: string } = {
// Build update data
const updateData: {
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
inputSchema: any;
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
parameters: any;
schemaSource: string;
schemaExtractedAt: Date;
description?: string;
} = {
inputSchema,
parameters,
schemaSource: 'extracted',
schemaExtractedAt: new Date(),
};
if (description) {
@ -117,6 +110,9 @@ export async function POST(request: NextRequest) {
exportName: true,
description: true,
parameters: true,
inputSchema: true,
schemaSource: true,
schemaExtractedAt: true,
},
});
@ -125,11 +121,13 @@ export async function POST(request: NextRequest) {
exportName: updatedTool.exportName,
parameterCount: parameters.length,
parameterNames: parameters.map((p) => p.name),
schemaSource: updatedTool.schemaSource,
});
return NextResponse.json({
success: true,
updated: true,
schemaSource: updatedTool.schemaSource,
tool: updatedTool,
});
} catch (error) {

View file

@ -47,6 +47,9 @@ interface Tool {
required: boolean;
default?: unknown;
}> | null;
inputSchema: Record<string, unknown> | null;
schemaSource: 'extracted' | 'author' | null;
schemaExtractedAt: string | null;
returns: {
type: string;
description: string;
@ -77,6 +80,7 @@ export default function ToolDetailPage({
const [error, setError] = useState<string | null>(null);
const [slug, setSlug] = useState<string>('');
const [recheckLoading, setRecheckLoading] = useState(false);
const [extractSchemaLoading, setExtractSchemaLoading] = useState(false);
useEffect(() => {
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
@ -208,6 +212,40 @@ export default function ToolDetailPage({
}
};
const extractSchema = async () => {
if (!tool) return;
setExtractSchemaLoading(true);
try {
const response = await fetch('/api/tools/extract-schema', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName: tool.package.npmPackageName,
exportName: tool.exportName,
}),
});
const data = await response.json();
if (!response.ok) {
alert(data.message || data.error || 'Schema extraction failed');
return;
}
if (data.success) {
// Refresh page to show updated schema
window.location.reload();
} else {
alert(data.message || 'Schema extraction failed');
}
} catch {
alert('Failed to extract schema');
} finally {
setExtractSchemaLoading(false);
}
};
return (
<div className="min-h-screen bg-background">
<script
@ -410,14 +448,33 @@ console.log(result.text);`}
</Card>
)}
{/* Parameters */}
{tool.parameters && tool.parameters.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Parameters</CardTitle>
<CardDescription>Available configuration options</CardDescription>
</CardHeader>
<CardContent>
{/* Parameters / Input Schema */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Parameters</CardTitle>
<CardDescription>Available configuration options</CardDescription>
</div>
<div className="flex items-center gap-2">
{tool.schemaSource === 'extracted' ? (
<Badge variant="default" size="sm">
Auto-extracted
</Badge>
) : tool.schemaSource === 'author' ? (
<Badge variant="secondary" size="sm">
Author-provided
</Badge>
) : (
<Badge variant="outline" size="sm">
No schema
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
{tool.parameters && tool.parameters.length > 0 ? (
<div className="space-y-4">
{tool.parameters.map((param) => (
<div key={param.name} className="border-b border-border pb-4 last:border-0">
@ -446,10 +503,60 @@ console.log(result.text);`}
)}
</div>
))}
{tool.schemaExtractedAt && (
<p className="text-xs text-foreground-tertiary">
Schema extracted: {new Date(tool.schemaExtractedAt).toLocaleString()}
</p>
)}
</div>
</CardContent>
</Card>
)}
) : (
<div className="text-center py-6">
<p className="text-sm text-foreground-secondary mb-4">
No schema available for this tool.
</p>
<Button
variant="outline"
size="sm"
onClick={extractSchema}
disabled={extractSchemaLoading}
>
{extractSchemaLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Extracting...
</>
) : (
'Extract Schema'
)}
</Button>
</div>
)}
{tool.schemaSource !== 'extracted' &&
tool.parameters &&
tool.parameters.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={extractSchema}
disabled={extractSchemaLoading}
>
{extractSchemaLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Extracting...
</>
) : (
'Re-extract Schema'
)}
</Button>
<p className="text-xs text-foreground-tertiary mt-2">
Try to auto-extract schema from the package
</p>
</div>
)}
</CardContent>
</Card>
{/* README */}
{pkg.npmReadme && (

View file

@ -0,0 +1,132 @@
/**
* Schema Extraction Service
* Extracts inputSchema from tools via the Railway executor's /load-and-describe endpoint
*/
import { env } from '~/env';
const RAILWAY_EXECUTOR_URL = env.RAILWAY_EXECUTOR_URL;
export interface SchemaExtractionSuccess {
success: true;
inputSchema: Record<string, unknown>;
description?: string;
}
export interface SchemaExtractionFailure {
success: false;
error: string;
}
export type SchemaExtractionResult = SchemaExtractionSuccess | SchemaExtractionFailure;
/**
* Extract inputSchema from a tool by calling the executor's /load-and-describe endpoint
*
* @param packageName - NPM package name (e.g., "@tpmjs/hello-world")
* @param exportName - Export name (e.g., "helloWorldTool" or "default")
* @param version - Package version (e.g., "1.0.0")
* @param packageEnv - Package-level environment variables (optional)
* @returns Schema extraction result with inputSchema or error
*/
export async function extractToolSchema(
packageName: string,
exportName: string,
version: string,
packageEnv?: Record<string, unknown> | null
): Promise<SchemaExtractionResult> {
try {
const response = await fetch(`${RAILWAY_EXECUTOR_URL}/load-and-describe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
packageName,
exportName,
version,
env: packageEnv || {},
}),
signal: AbortSignal.timeout(10000), // 10 second timeout per extraction
});
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 || 'Extraction failed without error message',
};
}
if (!data.tool?.inputSchema) {
return {
success: false,
error: 'No inputSchema returned from executor',
};
}
return {
success: true,
inputSchema: data.tool.inputSchema,
description: data.tool.description,
};
} catch (error) {
// Handle timeout specifically
if (error instanceof Error && error.name === 'TimeoutError') {
return {
success: false,
error: 'Schema extraction timed out after 10 seconds',
};
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during extraction',
};
}
}
/**
* Convert JSON Schema to parameters array format (for backward compatibility)
*
* @param inputSchema - JSON Schema object from executor
* @returns Array of parameter objects in legacy format
*/
export function convertJsonSchemaToParameters(inputSchema: Record<string, unknown>): Array<{
name: string;
type: string;
required: boolean;
description: string;
}> {
const parameters: Array<{
name: string;
type: string;
required: boolean;
description: string;
}> = [];
const properties = inputSchema.properties as
| Record<string, { type?: string; description?: string }>
| undefined;
const required = (inputSchema.required as string[]) || [];
if (properties) {
for (const [name, prop] of Object.entries(properties)) {
parameters.push({
name,
type: prop.type || 'string',
required: required.includes(name),
description: prop.description || '',
});
}
}
return parameters;
}

View file

@ -66,9 +66,14 @@ model Tool {
// Tool Metadata
description String @db.Text
parameters Json? @db.JsonB
returns Json? @db.JsonB
aiAgent Json? @map("ai_agent") @db.JsonB
parameters Json? @db.JsonB // Legacy/fallback - author-provided parameters array
returns Json? @db.JsonB // @deprecated - will be auto-extracted in future
aiAgent Json? @map("ai_agent") @db.JsonB // @deprecated - will be auto-extracted in future
// Schema Extraction Fields
inputSchema Json? @map("input_schema") @db.JsonB // Full JSON Schema from executor
schemaSource String? @map("schema_source") @db.VarChar(20) // 'extracted' | 'author' | null
schemaExtractedAt DateTime? @map("schema_extracted_at")
// Tool Metrics
qualityScore Decimal? @map("quality_score") @db.Decimal(3, 2) // 0.00 to 1.00

View file

@ -22,6 +22,10 @@ export type TpmjsCategory = (typeof TPMJS_CATEGORIES)[number];
/**
* Tool parameter schema
*
* @deprecated Parameters are now auto-extracted from the tool's inputSchema at runtime.
* You no longer need to manually specify parameters in your package.json.
* This schema is kept for backward compatibility as a fallback if auto-extraction fails.
*/
export const TpmjsParameterSchema = z.object({
name: z.string().min(1),
@ -35,6 +39,9 @@ export type TpmjsParameter = z.infer<typeof TpmjsParameterSchema>;
/**
* Return value schema
*
* @deprecated Return type information is now auto-extracted from the tool at runtime.
* You no longer need to manually specify returns in your package.json.
*/
export const TpmjsReturnsSchema = z.object({
type: z.string().min(1),
@ -57,6 +64,9 @@ export type TpmjsEnv = z.infer<typeof TpmjsEnvSchema>;
/**
* AI Agent guidance schema
*
* @deprecated AI agent guidance is now auto-extracted from the tool at runtime.
* You no longer need to manually specify aiAgent in your package.json.
*/
export const TpmjsAiAgentSchema = z.object({
useCase: z.string().min(10),
@ -68,12 +78,24 @@ 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)
*
* @deprecated fields (now auto-extracted, kept for backward compatibility):
* - parameters: Tool input parameters - auto-extracted from inputSchema
* - returns: Tool return type - auto-extracted from tool
* - 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),
// @deprecated - now auto-extracted from tool's inputSchema
parameters: z.array(TpmjsParameterSchema).optional(),
// @deprecated - now auto-extracted from tool
returns: TpmjsReturnsSchema.optional(),
// @deprecated - now auto-extracted from tool
aiAgent: TpmjsAiAgentSchema.optional(),
});