/** * Resource Management Tools * MCP tools for managing Mandu resources */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { defineResource, parseResourceSchema, generateResourceArtifacts, type ResourceDefinition, type ResourceField, type FieldType, FieldTypes, } from "@mandujs/core"; import path from "path"; import fs from "fs/promises"; // ============================================ // Tool Definitions // ============================================ export const resourceToolDefinitions: Tool[] = [ { name: "mandu.resource.create", annotations: { destructiveHint: true, readOnlyHint: false, }, description: "Create a new resource with schema definition. " + "Generates schema file in spec/resources/{name}/schema.ts and creates " + "CRUD handlers, types, contracts, and API clients based on the schema.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Resource name in singular form (e.g., 'user', 'post', 'product')", }, fields: { type: "object", description: "Field definitions as key-value pairs", additionalProperties: { type: "object", properties: { type: { type: "string", enum: FieldTypes, description: "Field data type", }, required: { type: "boolean", description: "Whether this field is required (default: false)", }, default: { description: "Default value for this field", }, description: { type: "string", description: "Field description", }, items: { type: "string", enum: FieldTypes, description: "Array element type (required if type is 'array')", }, }, required: ["type"], }, }, description: { type: "string", description: "Resource description", }, tags: { type: "array", items: { type: "string" }, description: "API tags for categorization", }, endpoints: { type: "object", description: "Which endpoints to enable (default: all true)", properties: { list: { type: "boolean" }, get: { type: "boolean" }, create: { type: "boolean" }, update: { type: "boolean" }, delete: { type: "boolean" }, }, }, }, required: ["name", "fields"], }, }, { name: "mandu.resource.list", annotations: { readOnlyHint: true, }, description: "List all resources in the project. " + "Scans spec/resources/ directory and returns resource names with field summaries.", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "mandu.resource.get", annotations: { readOnlyHint: true, }, description: "Get detailed information about a specific resource. " + "Returns full schema definition including fields, types, and options.", inputSchema: { type: "object", properties: { resourceName: { type: "string", description: "The resource name to retrieve (e.g., 'user', 'post')", }, }, required: ["resourceName"], }, }, { name: "mandu.resource.addField", annotations: { readOnlyHint: false, }, description: "Add a new field to an existing resource schema. " + "⚠️ IMPORTANT: Preserves custom slot logic by using force: false during regeneration. " + "Updates schema file and regenerates artifacts without overwriting slot implementations.", inputSchema: { type: "object", properties: { resourceName: { type: "string", description: "The resource to modify (e.g., 'user')", }, fieldName: { type: "string", description: "Name of the new field (e.g., 'phoneNumber', 'email')", }, fieldType: { type: "string", enum: FieldTypes, description: "Data type of the new field", }, required: { type: "boolean", description: "Whether the field is required (default: false)", }, default: { description: "Default value for the field", }, description: { type: "string", description: "Field description", }, items: { type: "string", enum: FieldTypes, description: "Array element type (required if fieldType is 'array')", }, force: { type: "boolean", description: "⚠️ WARNING: Overwrites custom slot logic if true. " + "Only use when you want to reset slot to default template. (default: false)", }, }, required: ["resourceName", "fieldName", "fieldType"], }, }, { name: "mandu.resource.removeField", annotations: { destructiveHint: true, readOnlyHint: false, }, description: "Remove a field from an existing resource schema. " + "Updates schema file and regenerates artifacts with force: false to preserve slots.", inputSchema: { type: "object", properties: { resourceName: { type: "string", description: "The resource to modify", }, fieldName: { type: "string", description: "Name of the field to remove", }, }, required: ["resourceName", "fieldName"], }, }, ]; // ============================================ // Helper Functions // ============================================ /** * Get spec/resources directory path */ function getResourcesDir(projectRoot: string): string { return path.join(projectRoot, "spec", "resources"); } /** * Get resource schema file path */ function getResourceSchemaPath(projectRoot: string, resourceName: string): string { return path.join(getResourcesDir(projectRoot), resourceName, "schema.ts"); } /** * Check if file exists */ async function fileExists(filePath: string): Promise { try { await fs.access(filePath); return true; } catch { return false; } } /** * Ensure directory exists */ async function ensureDir(dirPath: string): Promise { await fs.mkdir(dirPath, { recursive: true }); } /** * List all resource directories */ async function listResourceDirs(projectRoot: string): Promise { const resourcesDir = getResourcesDir(projectRoot); try { const entries = await fs.readdir(resourcesDir, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); } catch { return []; } } /** * Generate resource schema file content */ function generateSchemaFileContent(definition: ResourceDefinition): string { const { name, fields, options } = definition; const fieldsCode = Object.entries(fields) .map(([fieldName, field]) => { const props: string[] = [`type: "${field.type}"`]; if (field.required) props.push("required: true"); if (field.default !== undefined) props.push(`default: ${JSON.stringify(field.default)}`); if (field.description) props.push(`description: "${field.description}"`); if (field.items) props.push(`items: "${field.items}"`); return ` ${fieldName}: { ${props.join(", ")} },`; }) .join("\n"); const optionsCode = options ? ` options: { ${options.description ? ` description: "${options.description}",\n` : ""}${ options.tags ? ` tags: ${JSON.stringify(options.tags)},\n` : "" }${ options.endpoints ? ` endpoints: ${JSON.stringify(options.endpoints, null, 6).replace(/\n/g, "\n ")},\n` : "" } },` : ""; return `import { defineResource } from "@mandujs/core"; export const ${name}Resource = defineResource({ name: "${name}", fields: { ${fieldsCode} }, ${optionsCode} }); export default ${name}Resource; `; } /** * Parse schema file to extract ResourceDefinition */ async function readResourceDefinition( projectRoot: string, resourceName: string ): Promise { const schemaPath = getResourceSchemaPath(projectRoot, resourceName); if (!(await fileExists(schemaPath))) { return null; } try { // Use parseResourceSchema from core const parsed = await parseResourceSchema(schemaPath); return parsed.definition; } catch (error) { throw new Error( `Failed to parse resource schema: ${error instanceof Error ? error.message : String(error)}`, { cause: error } ); } } // ============================================ // Tool Handlers // ============================================ export function resourceTools(projectRoot: string) { return { /** * Create a new resource */ "mandu.resource.create": async (args: Record) => { const { name, fields, description, tags, endpoints } = args as { name: string; fields: Record; description?: string; tags?: string[]; endpoints?: { list?: boolean; get?: boolean; create?: boolean; update?: boolean; delete?: boolean; }; }; // Validation if (!name || !fields) { return { error: "Missing required parameters: name and fields", tip: "Provide resource name (singular) and field definitions", }; } // Check if resource already exists const schemaPath = getResourceSchemaPath(projectRoot, name); if (await fileExists(schemaPath)) { return { error: `Resource '${name}' already exists`, tip: "Use mandu.resource.get to view existing resource or mandu.resource.addField to add fields", existingPath: schemaPath, }; } try { // Define resource const definition = defineResource({ name, fields, options: { description, tags, endpoints, }, }); // Create schema file const resourceDir = path.dirname(schemaPath); await ensureDir(resourceDir); const schemaContent = generateSchemaFileContent(definition); await fs.writeFile(schemaPath, schemaContent, "utf-8"); // Parse and generate artifacts const parsed = await parseResourceSchema(schemaPath); const result = await generateResourceArtifacts(parsed, { rootDir: projectRoot, force: false, }); return { success: true, resourceName: name, schemaFile: schemaPath, generated: { created: result.created, skipped: result.skipped, }, fieldCount: Object.keys(fields).length, message: `Resource '${name}' created successfully with ${Object.keys(fields).length} fields`, tip: "Run mandu.resource.get to view full resource details or start implementing slot logic", }; } catch (error) { return { error: `Failed to create resource: ${error instanceof Error ? error.message : String(error)}`, tip: "Check field definitions and resource name format", }; } }, /** * List all resources */ "mandu.resource.list": async () => { try { const resourceDirs = await listResourceDirs(projectRoot); if (resourceDirs.length === 0) { return { resources: [], total: 0, message: "No resources found in spec/resources/", tip: "Use mandu.resource.create to create your first resource", }; } // Read each resource const resources = await Promise.all( resourceDirs.map(async (resourceName) => { try { const definition = await readResourceDefinition(projectRoot, resourceName); if (!definition) { return null; } return { name: resourceName, fieldCount: Object.keys(definition.fields).length, fields: Object.keys(definition.fields), description: definition.options?.description, tags: definition.options?.tags, }; } catch { return null; } }) ); const validResources = resources.filter((r) => r !== null); return { resources: validResources, total: validResources.length, tip: "Use mandu.resource.get with resourceName to see full details", }; } catch (error) { return { error: `Failed to list resources: ${error instanceof Error ? error.message : String(error)}`, tip: "Ensure spec/resources/ directory exists", }; } }, /** * Get resource details */ "mandu.resource.get": async (args: Record) => { const { resourceName } = args as { resourceName: string }; if (!resourceName) { return { error: "Missing required parameter: resourceName", tip: "Use mandu.resource.list to see available resources", }; } try { const definition = await readResourceDefinition(projectRoot, resourceName); if (!definition) { return { error: `Resource '${resourceName}' not found`, tip: "Use mandu.resource.list to see available resources or mandu.resource.create to create it", }; } return { name: definition.name, fields: definition.fields, options: definition.options, fieldCount: Object.keys(definition.fields).length, schemaFile: getResourceSchemaPath(projectRoot, resourceName), tip: "Use mandu.resource.addField to add new fields or mandu.resource.removeField to remove fields", }; } catch (error) { return { error: `Failed to read resource: ${error instanceof Error ? error.message : String(error)}`, tip: "Check if schema file is valid TypeScript", }; } }, /** * Add field to resource */ "mandu.resource.addField": async (args: Record) => { const { resourceName, fieldName, fieldType, required, default: defaultValue, description, items, force = false } = args as { resourceName: string; fieldName: string; fieldType: FieldType; required?: boolean; default?: unknown; description?: string; items?: FieldType; force?: boolean; }; // Validation if (!resourceName || !fieldName || !fieldType) { return { error: "Missing required parameters: resourceName, fieldName, fieldType", tip: "Provide all required parameters", }; } try { // Read existing resource const definition = await readResourceDefinition(projectRoot, resourceName); if (!definition) { return { error: `Resource '${resourceName}' not found`, tip: "Use mandu.resource.list to see available resources", }; } // Check if field already exists if (definition.fields[fieldName]) { return { error: `Field '${fieldName}' already exists in resource '${resourceName}'`, tip: "Use mandu.resource.get to view existing fields or choose a different field name", }; } // Add field const newField: ResourceField = { type: fieldType, required, default: defaultValue, description, items, }; definition.fields[fieldName] = newField; // Update schema file const schemaPath = getResourceSchemaPath(projectRoot, resourceName); const schemaContent = generateSchemaFileContent(definition); await fs.writeFile(schemaPath, schemaContent, "utf-8"); // Regenerate artifacts (force: false to preserve slots!) const parsed = await parseResourceSchema(schemaPath); const result = await generateResourceArtifacts(parsed, { rootDir: projectRoot, force: force, // Use force parameter from args }); return { success: true, resourceName, fieldAdded: fieldName, fieldType, filesUpdated: [schemaPath, ...result.created], slotsPreserved: result.skipped, forceUsed: force, message: `Field '${fieldName}' added to resource '${resourceName}'. ${force ? "⚠️ Slots overwritten!" : "Slots preserved."}`, tip: force ? "⚠️ Custom slot logic was overwritten because force: true was used" : "Custom slot logic preserved. Run mandu.generate to apply changes to all resources.", }; } catch (error) { return { error: `Failed to add field: ${error instanceof Error ? error.message : String(error)}`, tip: "Check field type and resource name", }; } }, /** * Remove field from resource */ "mandu.resource.removeField": async (args: Record) => { const { resourceName, fieldName } = args as { resourceName: string; fieldName: string; }; // Validation if (!resourceName || !fieldName) { return { error: "Missing required parameters: resourceName, fieldName", tip: "Provide both resourceName and fieldName", }; } try { // Read existing resource const definition = await readResourceDefinition(projectRoot, resourceName); if (!definition) { return { error: `Resource '${resourceName}' not found`, tip: "Use mandu.resource.list to see available resources", }; } // Check if field exists if (!definition.fields[fieldName]) { return { error: `Field '${fieldName}' not found in resource '${resourceName}'`, tip: "Use mandu.resource.get to view existing fields", availableFields: Object.keys(definition.fields), }; } // Remove field delete definition.fields[fieldName]; // Validate at least one field remains if (Object.keys(definition.fields).length === 0) { return { error: `Cannot remove field '${fieldName}': resource must have at least one field`, tip: "Add other fields before removing this one, or delete the entire resource", }; } // Update schema file const schemaPath = getResourceSchemaPath(projectRoot, resourceName); const schemaContent = generateSchemaFileContent(definition); await fs.writeFile(schemaPath, schemaContent, "utf-8"); // Regenerate artifacts (force: false to preserve slots!) const parsed = await parseResourceSchema(schemaPath); const result = await generateResourceArtifacts(parsed, { rootDir: projectRoot, force: false, }); return { success: true, resourceName, fieldRemoved: fieldName, filesUpdated: [schemaPath, ...result.created], slotsPreserved: result.skipped, remainingFields: Object.keys(definition.fields), message: `Field '${fieldName}' removed from resource '${resourceName}'. Slots preserved.`, tip: "Run mandu.generate to apply changes to all resources", }; } catch (error) { return { error: `Failed to remove field: ${error instanceof Error ? error.message : String(error)}`, tip: "Check field name and resource name", }; } }, }; }