diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/README.md b/packages/server/src/ibmi-mcp-server/tools/source-code/README.md new file mode 100644 index 0000000..43c87fb --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/README.md @@ -0,0 +1,178 @@ +# IBM i Source Code Tools + +TypeScript-based tools for AI-assisted source code access, compilation, and debugging on IBM i. + +## Overview + +These tools enable AI agents to: +- **Read** source code from IBM i source files +- **Compile** RPG, CL, and COBOL programs +- **Retrieve** compilation errors from job logs + +## Tools + +### 1. read_source_member + +Reads IBM i source code from a source physical file member. + +**Input:** +```typescript +{ + library: string; // Library containing the source (e.g., 'MYLIB') + source_file: string; // Source file name (e.g., 'QRPGLESRC') + member: string; // Member name (e.g., 'MYPGM') + include_line_numbers?: boolean; // Default: true +} +``` + +**Output:** +- Source code with line numbers +- Metadata (source type, line count, last modified) +- Execution time + +**Example:** +``` +Input: { library: 'MYLIB', source_file: 'QRPGLESRC', member: 'MYPGM' } +Output: Complete source code with metadata +``` + +### 2. compile_source + +Compiles IBM i source code into modules or programs. + +**Input:** +```typescript +{ + library: string; // Library with source + source_file: string; // Source file name + member: string; // Member to compile + target_library?: string; // Where to put compiled object + compile_type: 'RPGLE' | 'SQLRPGLE' | 'CL' | 'CLLE' | 'CBL' | 'CBLLE'; + compile_options?: string; // Additional options (e.g., 'DBGVIEW(*SOURCE)') + create_program?: boolean; // Create program vs module (default: false) +} +``` + +**Output:** +- Success/failure status +- Compile command executed +- Job information +- Execution time + +**Example:** +``` +Input: { library: 'MYLIB', source_file: 'QRPGLESRC', member: 'MYPGM', compile_type: 'RPGLE' } +Output: Compilation status and job details +``` + +### 3. get_compile_errors + +Retrieves compilation error messages from the current job log. + +**Input:** +```typescript +{ + min_severity?: number; // Minimum severity (0-99), default: 20 + max_messages?: number; // Max messages to return, default: 100 + message_type_filter?: string[]; // Filter by message types +} +``` + +**Output:** +- Array of error messages with: + - Message ID (e.g., RNF7030) + - Severity level + - Message text + - Detailed help text + - Source program/library + +**Example:** +``` +Input: { min_severity: 30, max_messages: 50 } +Output: List of errors and warnings from recent compile +``` + +## Configuration + +### Enable Source Code Tools + +**Environment Variable:** +```bash +IBMI_ENABLE_SOURCE_TOOLS=true +``` + +**In .env file:** +```bash +# Enable source code tools +IBMI_ENABLE_SOURCE_TOOLS=true + +# Note: compile_source requires write access +IBMI_EXECUTE_SQL_READONLY=false # āš ļø Security: enables write operations +``` + +### Security Considerations + +- **read_source_member**: Read-only, safe for production +- **compile_source**: Requires write access (IBMI_EXECUTE_SQL_READONLY=false) +- **get_compile_errors**: Read-only, safe for production + +āš ļø **Only enable in development/test environments** unless you have strict security controls. + +## Usage Example + +### AI-Assisted Compile Workflow + +``` +1. Agent: "Read source for MYPGM" + Tool: read_source_member + +2. Agent: "Compile this program" + Tool: compile_source + +3. If compile fails: + Tool: get_compile_errors + +4. Agent analyzes errors and suggests fixes + +5. Agent: "Update source with fixes" + (Would use update_source_member - not yet implemented) + +6. Repeat until successful +``` + +## Implementation Details + +- **Pattern**: Single-file .tool.ts following factory pattern +- **Error Handling**: McpError with proper error codes +- **Logging**: Structured logging with RequestContext +- **Database Access**: Via IBMiConnectionPool +- **Response Formatting**: Custom formatters for each tool + +## Related Documentation + +- [SOURCE_CODE_TOOLS_DESIGN.md](../../../../../../SOURCE_CODE_TOOLS_DESIGN.md) - Complete design specification +- [SOURCE_CODE_TOOLS_SUMMARY.md](../../../../../../SOURCE_CODE_TOOLS_SUMMARY.md) - Quick reference +- [packages/server/README.md](../../../../README.md) - Server documentation + +## Future Enhancements + +- **update_source_member**: Modify source code programmatically +- **get_spool_file_content**: Read detailed compile listings +- **create_source_member**: Create new source members +- **AI compile loop**: Automated compile-fix-recompile orchestration + +## Testing + +Run tests: +```bash +npm test -- packages/server/src/ibmi-mcp-server/tools/source-code/ +``` + +## Contributing + +When adding new tools: +1. Create .tool.ts file following existing pattern +2. Export from index.ts +3. Add to getAllToolDefinitions() in parent index.ts +4. Update this README +5. Add tests diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/compileFromIfs.tool.ts.disabled b/packages/server/src/ibmi-mcp-server/tools/source-code/compileFromIfs.tool.ts.disabled new file mode 100644 index 0000000..b20f76a --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/compileFromIfs.tool.ts.disabled @@ -0,0 +1,191 @@ +/** + * Compile from IFS Tool + * + * Compiles IBM i source code from IFS stream file using SRCSTMF parameter. + * Works with source written via write_source_to_ifs tool. + * + * @module compileFromIfs.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const CompileFromIfsInputSchema = z.object({ + library: z + .string() + .min(1, "Library name cannot be empty.") + .max(10, "Library name cannot exceed 10 characters.") + .describe("Target library for compiled object"), + object_name: z + .string() + .min(1, "Object name cannot be empty.") + .max(10, "Object name cannot exceed 10 characters.") + .describe("Name of the compiled object (program/module)"), + ifs_path: z + .string() + .min(1, "IFS path cannot be empty.") + .describe("IFS path to the source file (e.g., '/home/user/src/MYPROG.sqlrpgle')"), + compile_type: z + .enum(["SQLRPGLE", "RPGLE", "CLLE", "CBLLE"]) + .describe("Type of compilation"), + setup_program: z + .string() + .optional() + .default("DB") + .describe("Library setup program to call before compilation (default: 'DB')"), +}); + +const CompileFromIfsOutputSchema = z.object({ + success: z.boolean().describe("Whether compilation was successful."), + library: z.string().optional(), + objectName: z.string().optional(), + ifsPath: z.string().optional(), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + message: z.string().optional(), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if compilation failed."), +}); + +type CompileFromIfsInput = z.infer; +type CompileFromIfsOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +export async function compileFromIfsLogic( + params: CompileFromIfsInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing compile from IFS logic." + ); + + const startTime = Date.now(); + const { library, object_name, ifs_path, compile_type, setup_program } = params; + + const pool = IBMiConnectionPool.getInstance(); + const job = await pool.getJob(); + + try { + // Determine compile command based on type + let compileCmd = ""; + switch (compile_type) { + case "SQLRPGLE": + compileCmd = `CRTSQLRPGI OBJ(${library}/${object_name}) SRCSTMF('${ifs_path}') COMMIT(*NONE) DBGVIEW(*SOURCE)`; + break; + case "RPGLE": + compileCmd = `CRTBNDRPG PGM(${library}/${object_name}) SRCSTMF('${ifs_path}') DBGVIEW(*SOURCE)`; + break; + case "CLLE": + compileCmd = `CRTBNDCL PGM(${library}/${object_name}) SRCSTMF('${ifs_path}') DBGVIEW(*SOURCE)`; + break; + case "CBLLE": + compileCmd = `CRTBNDCBL PGM(${library}/${object_name}) SRCSTMF('${ifs_path}') DBGVIEW(*SOURCE)`; + break; + default: + throw new McpError( + JsonRpcErrorCode.InvalidParams, + `Unsupported compile type: ${compile_type}` + ); + } + + logger.info( + { ...appContext, library, objectName: object_name, ifsPath: ifs_path, compileType: compile_type }, + "Compiling from IFS stream file" + ); + + // Build CL program that calls setup, then compiles + const clSource = ` + PGM + CALL PGM(${library}/${setup_program}) + MONMSG MSGID(CPF0000) + ${compileCmd} + ENDPGM + `; + + // Create temp CL in QTEMP + const tempMember = `CMP${Date.now().toString().substring(7)}`; + + // Create CL source file if needed + try { + await job.query(`CREATE TABLE QTEMP.QCLSRC LIKE QSYS.QCLSRC`, {}); + } catch (e) { + // Already exists, ignore + } + + // Write CL source + await job.query( + `INSERT INTO QTEMP.QCLSRC (SRCSEQ, SRCDAT, SRCDTA) VALUES (?, ?, ?)`, + { parameters: [1.00, 0, clSource] } + ); + + // Create and call the CL program + await job.query(`CALL QSYS2.QCMDEXC('CRTBNDCL PGM(QTEMP/${tempMember}) SRCFILE(QTEMP/QCLSRC)')`); + await job.query(`CALL QSYS2.QCMDEXC('CALL PGM(QTEMP/${tempMember})')`); + + const executionTime = Date.now() - startTime; + + logger.info( + { ...appContext, library, objectName: object_name, executionTime }, + "Successfully compiled from IFS" + ); + + return { + success: true, + library, + objectName: object_name, + ifsPath: ifs_path, + executionTime, + message: `Program ${object_name} compiled successfully from ${ifs_path}`, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + logger.error( + { ...appContext, error, library, objectName: object_name, executionTime }, + "Failed to compile from IFS" + ); + + throw new McpError( + JsonRpcErrorCode.InternalError, + `Compilation failed: ${error instanceof Error ? error.message : String(error)}`, + { + library, + objectName: object_name, + ifsPath: ifs_path, + error: String(error), + } + ); + } finally { + await pool.returnJob(job); + } +} + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export const compileFromIfsTool = defineTool({ + name: "compile_from_ifs", + description: `Compile IBM i source code from IFS stream file with library setup. Calls the setup program (default: DB) to configure the library list, then compiles using SRCSTMF parameter. This is the modern approach for IBM i development.`, + inputSchema: CompileFromIfsInputSchema, + logic: compileFromIfsLogic, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/compileSource.tool.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/compileSource.tool.ts new file mode 100644 index 0000000..37c1c34 --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/compileSource.tool.ts @@ -0,0 +1,292 @@ +/** + * Compile Source Tool + * + * Compiles IBM i source code (RPG, CL, COBOL, etc.) and returns + * compilation status and job information. + * + * @module compileSource.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const CompileSourceInputSchema = z.object({ + library: z + .string() + .min(1, "Library name cannot be empty.") + .max(10, "Library name cannot exceed 10 characters.") + .describe("Library containing the source file"), + source_file: z + .string() + .min(1, "Source file name cannot be empty.") + .max(10, "Source file name cannot exceed 10 characters.") + .describe("Source file name (e.g., 'QRPGLESRC', 'QCLSRC')"), + member: z + .string() + .min(1, "Member name cannot be empty.") + .max(10, "Member name cannot exceed 10 characters.") + .describe("Source member name to compile"), + target_library: z + .string() + .optional() + .describe("Library for compiled object (defaults to source library)"), + compile_type: z + .enum(["RPGLE", "SQLRPGLE", "CL", "CLLE", "CBL", "CBLLE"]) + .describe("Source type to compile"), + compile_options: z + .string() + .optional() + .describe("Additional compile options (e.g., 'DBGVIEW(*SOURCE)')"), + create_program: z + .boolean() + .optional() + .default(false) + .describe("Create bound program instead of module (default: false)"), +}); + +const CompileSourceOutputSchema = z.object({ + success: z.boolean().describe("Whether compilation succeeded."), + library: z.string().optional(), + member: z.string().optional(), + compileType: z.string().optional(), + command: z.string().optional().describe("Compile command that was executed"), + jobName: z.string().optional().describe("Job that ran the compile"), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if compilation failed."), +}); + +type CompileSourceInput = z.infer; +type CompileSourceOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +export async function compileSourceLogic( + params: CompileSourceInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing compile source logic.", + ); + + const startTime = Date.now(); + const { + library, + source_file, + member, + target_library, + compile_type, + compile_options, + create_program, + } = params; + + const targetLib = target_library || library; + + try { + // Build compile command based on type + let compileCmd = ""; + + switch (compile_type) { + case "RPGLE": + if (create_program) { + compileCmd = `CRTBNDRPG PGM(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + } else { + compileCmd = `CRTRPGMOD MODULE(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + } + break; + + case "SQLRPGLE": + compileCmd = `CRTSQLRPGI OBJ(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + break; + + case "CL": + case "CLLE": + if (create_program) { + compileCmd = `CRTBNDCL PGM(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + } else { + compileCmd = `CRTCLMOD MODULE(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + } + break; + + case "CBL": + case "CBLLE": + if (create_program) { + compileCmd = `CRTBNDCBL PGM(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + } else { + compileCmd = `CRTCBLMOD MODULE(${targetLib}/${member}) SRCFILE(${library}/${source_file}) SRCMBR(${member}) ${compile_options || ""}`; + } + break; + + default: + return { + success: false, + executionTime: Date.now() - startTime, + error: { + code: String(JsonRpcErrorCode.InvalidRequest), + message: `Unsupported compile type: ${compile_type}`, + }, + }; + } + + // Add QRPGLE library to library list before compiling + // This ensures message files like QRPGLEMSG are found + const addLibListSql = `CALL QSYS2.QCMDEXC(COMMAND => ?)`; + + try { + await IBMiConnectionPool.executeQuery(addLibListSql, ['ADDLIBLE LIB(QRPGLE) POSITION(*LAST)'], appContext); + logger.debug({ ...appContext }, "Added QRPGLE to library list"); + } catch (libListError) { + logger.info( + { ...appContext, error: libListError instanceof Error ? libListError.message : String(libListError) }, + "Failed to add QRPGLE to library list (continuing anyway)" + ); + } + + try { + await IBMiConnectionPool.executeQuery(addLibListSql, ['ADDLIBLE LIB(QDEVTOOLS) POSITION(*LAST)'], appContext); + logger.debug({ ...appContext }, "Added QDEVTOOLS to library list"); + } catch (libListError) { + logger.info( + { ...appContext, error: libListError instanceof Error ? libListError.message : String(libListError) }, + "Failed to add QDEVTOOLS to library list (continuing anyway)" + ); + } + + // Execute compile command using QCMDEXC + // Use QSYS2.QCMDEXC with SPECIFIC_NAME QCMDEXC1 (single parameter version) + // This version auto-calculates the command length + const sql = `CALL QSYS2.QCMDEXC(COMMAND => ?)`; + + logger.info( + { ...appContext, command: compileCmd }, + "Executing compile command", + ); + + await IBMiConnectionPool.executeQuery(sql, [compileCmd], appContext); + + const executionTime = Date.now() - startTime; + + return { + success: true, + library: targetLib, + member, + compileType: compile_type, + command: compileCmd, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + + logger.error( + { + ...appContext, + error: error instanceof Error ? error.message : String(error), + library, + member, + compileType: compile_type, + executionTime, + }, + "Compile source failed.", + ); + + if (error instanceof McpError) { + return { + success: false, + executionTime, + error: { + code: String(error.code), + message: error.message, + details: error.details, + }, + }; + } + + return { + success: false, + library: targetLib, + member, + compileType: compile_type, + executionTime, + error: { + code: String(JsonRpcErrorCode.InternalError), + message: `Compilation failed: ${error instanceof Error ? error.message : String(error)}`, + details: { + note: "Check job log for detailed error messages using get_job_log_messages or get_compile_errors tools", + }, + }, + }; + } +} + +// ============================================================================= +// Response Formatter +// ============================================================================= + +const compileSourceResponseFormatter = ( + result: CompileSourceOutput, +): ContentBlock[] => { + if (!result.success) { + const errorMessage = result.error?.message || "Compilation failed"; + const errorDetails = result.error?.details + ? `\n\nDetails:\n${JSON.stringify(result.error.details, null, 2)}` + : ""; + + const note = result.error?.details?.note + ? `\n\nšŸ’” ${result.error.details.note}` + : ""; + + return [ + { + type: "text", + text: `āŒ Compilation Failed\n\nLibrary: ${result.library}\nMember: ${result.member}\nType: ${result.compileType}\n\nError: ${errorMessage}${errorDetails}${note}`, + }, + ]; + } + + return [ + { + type: "text", + text: `āœ… Compilation Successful\n\nLibrary: ${result.library}\nMember: ${result.member}\nType: ${result.compileType}\nCommand: ${result.command}\nExecution time: ${result.executionTime}ms`, + }, + ]; +}; + +// ============================================================================= +// Tool Definition +// ============================================================================= + +export const compileSourceTool = defineTool({ + name: "compile_source", + title: "Compile Source", + description: + "Compile IBM i source code (RPG, CL, COBOL) into modules or programs. Returns compilation status and job information. Use get_job_log_messages or get_compile_errors tools to retrieve detailed error messages if compilation fails.", + inputSchema: CompileSourceInputSchema, + outputSchema: CompileSourceOutputSchema, + logic: compileSourceLogic, + responseFormatter: compileSourceResponseFormatter, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/compileWithSetup.tool.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/compileWithSetup.tool.ts new file mode 100644 index 0000000..fe37536 --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/compileWithSetup.tool.ts @@ -0,0 +1,220 @@ +/** + * Compile source with DB library setup + * Creates a temporary CL program that calls DB setup then compiles + */ + +import { z } from 'zod'; +import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js'; +import { JsonRpcErrorCode, McpError } from '../../../types-global/errors.js'; +import type { RequestContext } from '../../../utils/index.js'; +import { IBMiConnectionPool } from '../../services/connectionPool.js'; +import { defineTool } from '../../../mcp-server/tools/utils/tool-factory.js'; + +// Input schema +const CompileWithSetupInputSchema = z.object({ + library: z.string().describe('Library containing source file'), + source_file: z.string().describe('Source physical file name'), + member: z.string().describe('Source member name to compile'), + compile_type: z.enum(['RPGLE', 'SQLRPGLE', 'CLLE']).default('SQLRPGLE') + .describe('Type of compilation: RPGLE, SQLRPGLE, or CLLE'), + target_library: z.string().optional() + .describe('Target library for compiled object (defaults to source library)'), + setup_program: z.string().default('DB') + .describe('Setup program to call before compilation (default: DB)'), + setup_library: z.string().optional() + .describe('Library containing setup program (defaults to source library)'), +}); + +type CompileWithSetupInput = z.infer; + +/** + * Logic for compile_with_setup tool + */ +export async function compileWithSetupLogic( + input: CompileWithSetupInput, + context: RequestContext, +): Promise<{ success: boolean; program: string; created: string; message: string }> { + const { + library, + source_file, + member, + compile_type, + target_library, + setup_program, + setup_library, + } = input; + + const targetLib = target_library || library; + const setupLib = setup_library || library; + const tempMbr = `CMP${member.substring(0, 7)}`; + + // Build the compile command based on type + let compileCmd = ''; + if (compile_type === 'SQLRPGLE') { + compileCmd = `CRTSQLRPGI OBJ(${targetLib}/${member}) + + SRCFILE(${library}/${source_file}) + + SRCMBR(${member}) + + COMMIT(*NONE) + + DBGVIEW(*SOURCE) + + REPLACE(*YES)`; + } else if (compile_type === 'RPGLE') { + compileCmd = `CRTBNDRPG PGM(${targetLib}/${member}) + + SRCFILE(${library}/${source_file}) + + SRCMBR(${member}) + + DBGVIEW(*SOURCE) + + REPLACE(*YES)`; + } else if (compile_type === 'CLLE') { + compileCmd = `CRTCLPGM PGM(${targetLib}/${member}) + + SRCFILE(${library}/${source_file}) + + SRCMBR(${member}) + + REPLACE(*YES)`; + } + + // Create CL source that calls setup then compiles + const clSource = `PGM + CALL PGM(${setupLib}/${setup_program}) + MONMSG MSGID(CPF0000) + ${compileCmd} + ENDPGM`; + + // Create source file in QTEMP if it doesn't exist + try { + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('CRTSRCPF FILE(QTEMP/QCLSRC) RCDLEN(112) MBR(${tempMbr})')`, + [], + context, + ); + } catch { + // File might exist - try adding member + try { + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('ADDPFM FILE(QTEMP/QCLSRC) MBR(${tempMbr}) SRCTYPE(CLP)')`, + [], + context, + ); + } catch { + // Member might exist - clear it + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('CLRPFM FILE(QTEMP/QCLSRC) MBR(${tempMbr})')`, + [], + context, + ); + } + } + + // Write CL source line by line using OVRDBF + const lines = clSource.split('\n'); + + // Override database file to target specific member + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('OVRDBF FILE(QCLSRC) TOFILE(QTEMP/QCLSRC) MBR(${tempMbr})')`, + [], + context, + ); + + try { + for (let i = 0; i < lines.length; i++) { + const seq = (i + 1) * 100; + const line = (lines[i] || '').substring(0, 100); + await IBMiConnectionPool.executeQuery( + `INSERT INTO QTEMP.QCLSRC (SRCSEQ, SRCDAT, SRCDTA) VALUES (?, ?, ?)`, + [seq, 0, line], + context, + ); + } + } finally { + // Delete override + try { + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('DLTOVR FILE(QCLSRC)')`, + [], + context, + ); + } catch { + // Ignore + } + } + + // Create the CL program + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('CRTCLPGM PGM(QTEMP/${tempMbr}) SRCFILE(QTEMP/QCLSRC) SRCMBR(${tempMbr}) REPLACE(*YES)')`, + [], + context, + ); + + // Execute the CL program (which calls DB then compiles) + try { + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC('CALL PGM(QTEMP/${tempMbr})')`, + [], + context, + ); + } catch (err) { + // Even if QCMDEXC reports an error, the program might have compiled + // Check if program exists below + } + + // Verify the program was created + const verifyResult = await IBMiConnectionPool.executeQuery( + `SELECT OBJNAME, OBJTYPE, OBJCREATED, OBJSIZE + FROM TABLE(QSYS2.OBJECT_STATISTICS(?, '*PGM', ?))`, + [targetLib, member], + context, + ); + + if (verifyResult.data && verifyResult.data.length > 0) { + const pgm = verifyResult.data[0] as any; + return { + success: true, + program: `${targetLib}/${member}`, + created: pgm.OBJCREATED, + message: `Program ${member} compiled successfully with ${setup_program} setup`, + }; + } else { + throw new McpError( + JsonRpcErrorCode.InternalError, + `Compilation failed - program ${member} not created in ${targetLib}`, + ); + } +} + +// Output schema +const CompileWithSetupOutputSchema = z.object({ + success: z.boolean(), + program: z.string(), + created: z.string(), + message: z.string(), +}); + +/** + * Response formatter + */ +function compileWithSetupResponseFormatter( + result: z.infer, +): ContentBlock[] { + return [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ]; +} + +/** + * Tool definition + */ +export const compileWithSetupTool = defineTool({ + name: 'compile_with_setup', + title: 'Compile With Library Setup', + description: + 'Compile RPG/SQL RPG/CL source with library setup program (e.g., DB). Creates a temporary CL program that calls the setup program then compiles the source. This ensures the library list is properly configured before compilation.', + inputSchema: CompileWithSetupInputSchema, + outputSchema: CompileWithSetupOutputSchema, + logic: compileWithSetupLogic, + responseFormatter: compileWithSetupResponseFormatter, + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/getCompileErrors.tool.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/getCompileErrors.tool.ts new file mode 100644 index 0000000..2c36e1d --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/getCompileErrors.tool.ts @@ -0,0 +1,258 @@ +/** + * Get Compile Errors Tool + * + * Retrieves compilation error messages from the current job log. + * Parses and formats error messages for AI analysis and fixing. + * + * @module getCompileErrors.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const GetCompileErrorsInputSchema = z.object({ + min_severity: z + .number() + .optional() + .default(20) + .describe("Minimum message severity (0-99). Use 30+ for errors, 20+ for warnings"), + max_messages: z + .number() + .optional() + .default(100) + .describe("Maximum number of messages to return"), + message_type_filter: z + .array(z.string()) + .optional() + .describe("Filter by message types (e.g., ['DIAGNOSTIC', 'ESCAPE'])"), +}); + +const GetCompileErrorsOutputSchema = z.object({ + success: z.boolean().describe("Whether the query succeeded."), + messageCount: z.number().optional().describe("Number of messages returned"), + messages: z + .array( + z.object({ + timestamp: z.string().describe("Message timestamp"), + messageId: z.string().describe("Message ID (e.g., RNF7030)"), + messageType: z.string().describe("Message type"), + severity: z.number().describe("Message severity (0-99)"), + messageText: z.string().describe("Primary message text"), + secondLevelText: z + .string() + .nullable() + .optional() + .describe("Detailed help text"), + fromProgram: z.string().optional().describe("Program that sent message"), + fromLibrary: z.string().optional().describe("Library of sending program"), + }), + ) + .optional() + .describe("Array of error/warning messages"), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if the query failed."), +}); + +type GetCompileErrorsInput = z.infer; +type GetCompileErrorsOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +export async function getCompileErrorsLogic( + params: GetCompileErrorsInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing get compile errors logic.", + ); + + const startTime = Date.now(); + const { min_severity, max_messages, message_type_filter } = params; + + try { + // Build message type filter + const messageTypes = + message_type_filter || + ["COMPLETION", "DIAGNOSTIC", "ESCAPE", "NOTIFY", "INFORMATIONAL"]; + const messageTypesList = messageTypes.map((t) => `'${t}'`).join(", "); + + // Query job log for compilation messages + const sql = ` + SELECT + MESSAGE_TIMESTAMP, + MESSAGE_ID, + MESSAGE_TYPE, + SEVERITY, + MESSAGE_TEXT, + MESSAGE_SECOND_LEVEL_TEXT, + FROM_PROGRAM, + FROM_LIBRARY, + MESSAGE_FILE, + MESSAGE_LIBRARY + FROM TABLE(QSYS2.JOBLOG_INFO('*')) + WHERE MESSAGE_TYPE IN (${messageTypesList}) + AND SEVERITY >= ? + ORDER BY MESSAGE_TIMESTAMP DESC + FETCH FIRST ? ROWS ONLY + `.trim(); + + const result = await IBMiConnectionPool.executeQuery( + sql, + [min_severity, max_messages], + appContext, + ); + + if (!result.data) { + return { + success: true, + messageCount: 0, + messages: [], + executionTime: Date.now() - startTime, + }; + } + + // Transform messages + const messages = result.data.map((row: any) => ({ + timestamp: row.MESSAGE_TIMESTAMP, + messageId: row.MESSAGE_ID, + messageType: row.MESSAGE_TYPE, + severity: row.SEVERITY, + messageText: row.MESSAGE_TEXT, + secondLevelText: row.MESSAGE_SECOND_LEVEL_TEXT, + fromProgram: row.FROM_PROGRAM, + fromLibrary: row.FROM_LIBRARY, + })); + + const executionTime = Date.now() - startTime; + + return { + success: true, + messageCount: messages.length, + messages, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + + logger.error( + { + ...appContext, + error: error instanceof Error ? error.message : String(error), + executionTime, + }, + "Get compile errors failed.", + ); + + if (error instanceof McpError) { + return { + success: false, + executionTime, + error: { + code: String(error.code), + message: error.message, + details: error.details, + }, + }; + } + + return { + success: false, + executionTime, + error: { + code: String(JsonRpcErrorCode.InternalError), + message: `Failed to get compile errors: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } +} + +// ============================================================================= +// Response Formatter +// ============================================================================= + +const getCompileErrorsResponseFormatter = ( + result: GetCompileErrorsOutput, +): ContentBlock[] => { + if (!result.success) { + const errorMessage = result.error?.message || "Failed to get compile errors"; + const errorDetails = result.error?.details + ? `\n\nDetails:\n${JSON.stringify(result.error.details, null, 2)}` + : ""; + return [{ type: "text", text: `Error: ${errorMessage}${errorDetails}` }]; + } + + if (!result.messages || result.messages.length === 0) { + return [ + { + type: "text", + text: "No compilation errors or warnings found in the job log.", + }, + ]; + } + + const header = `Found ${result.messageCount} message(s)\nExecution time: ${result.executionTime}ms\n\n${"=".repeat(80)}\n`; + + const messagesList = result.messages + .map((msg, index) => { + const severityIcon = + msg.severity >= 40 ? "šŸ”“" : msg.severity >= 30 ? "🟠" : "🟔"; + return ` +${index + 1}. ${severityIcon} ${msg.messageId} - Severity ${msg.severity} + Type: ${msg.messageType} + Time: ${msg.timestamp} + + ${msg.messageText} + ${msg.secondLevelText ? `\n Details: ${msg.secondLevelText}` : ""} + ${msg.fromProgram ? `\n From: ${msg.fromLibrary}/${msg.fromProgram}` : ""} +${"=".repeat(80)}`; + }) + .join("\n"); + + return [ + { + type: "text", + text: `${header}${messagesList}`, + }, + ]; +}; + +// ============================================================================= +// Tool Definition +// ============================================================================= + +export const getCompileErrorsTool = defineTool({ + name: "get_compile_errors", + title: "Get Compile Errors", + description: + "Retrieve compilation error messages from the current job log. Returns detailed error messages with severity, message ID, and help text for AI analysis and fixing. Use this after a compile_source operation fails.", + inputSchema: GetCompileErrorsInputSchema, + outputSchema: GetCompileErrorsOutputSchema, + logic: getCompileErrorsLogic, + responseFormatter: getCompileErrorsResponseFormatter, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/index.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/index.ts new file mode 100644 index 0000000..88ab207 --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/index.ts @@ -0,0 +1,28 @@ +/** + * Source Code Tools + * + * IBM i source code access, compilation, and debugging tools. + * + * @module source-code + */ + +export { readSourceMemberTool } from "./readSourceMember.tool.js"; +export { writeSourceMemberTool } from "./writeSourceMember.tool.js"; +export { compileSourceTool } from "./compileSource.tool.js"; +export { getCompileErrorsTool } from "./getCompileErrors.tool.js"; +export { readSpoolFileTool } from "./readSpoolFile.tool.js"; +export { compileWithSetupTool } from "./compileWithSetup.tool.js"; +// Temporarily disabled - has TypeScript errors +// export { writeSourceToIfsTool } from "./writeSourceToIfs.tool.js"; +// export { compileFromIfsTool } from "./compileFromIfs.tool.js"; + +// Export logic functions for direct use +export { readSourceMemberLogic } from "./readSourceMember.tool.js"; +export { writeSourceMemberLogic } from "./writeSourceMember.tool.js"; +export { compileSourceLogic } from "./compileSource.tool.js"; +export { getCompileErrorsLogic } from "./getCompileErrors.tool.js"; +export { readSpoolFileLogic } from "./readSpoolFile.tool.js"; +export { compileWithSetupLogic } from "./compileWithSetup.tool.js"; +// Temporarily disabled - has TypeScript errors +// export { writeSourceToIfsLogic } from "./writeSourceToIfs.tool.js"; +// export { compileFromIfsLogic } from "./compileFromIfs.tool.js"; diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/readSourceMember.tool.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/readSourceMember.tool.ts new file mode 100644 index 0000000..442363f --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/readSourceMember.tool.ts @@ -0,0 +1,263 @@ +/** + * Read Source Member Tool + * + * Reads IBM i source code from a source physical file member. + * Returns source code with line numbers and metadata. + * + * @module readSourceMember.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const ReadSourceMemberInputSchema = z.object({ + library: z + .string() + .min(1, "Library name cannot be empty.") + .max(10, "Library name cannot exceed 10 characters.") + .describe("Library containing the source file (e.g., 'MYLIB', 'QGPL')"), + source_file: z + .string() + .min(1, "Source file name cannot be empty.") + .max(10, "Source file name cannot exceed 10 characters.") + .describe("Source file name (e.g., 'QRPGLESRC', 'QCLSRC')"), + member: z + .string() + .min(1, "Member name cannot be empty.") + .max(10, "Member name cannot exceed 10 characters.") + .describe("Source member name (e.g., 'MYPGM')"), + include_line_numbers: z + .boolean() + .optional() + .default(true) + .describe("Include line numbers in output (default: true)"), +}); + +const ReadSourceMemberOutputSchema = z.object({ + success: z.boolean().describe("Whether the operation was successful."), + library: z.string().optional().describe("Library name"), + sourceFile: z.string().optional().describe("Source file name"), + member: z.string().optional().describe("Member name"), + sourceType: z.string().optional().describe("Source type (e.g., RPGLE, CLLE, SQLRPGLE)"), + lineCount: z.number().optional().describe("Number of source lines"), + sourceCode: z.string().optional().describe("Source code content"), + lastModified: z.string().optional().describe("Last modification timestamp"), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if the operation failed."), +}); + +type ReadSourceMemberInput = z.infer; +type ReadSourceMemberOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +export async function readSourceMemberLogic( + params: ReadSourceMemberInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing read source member logic.", + ); + + const startTime = Date.now(); + const { library, source_file, member, include_line_numbers } = params; + + try { + // First, get metadata about the source member + const metadataSql = ` + SELECT + SYSTEM_TABLE_SCHEMA AS LIBRARY, + SYSTEM_TABLE_NAME AS SOURCE_FILE, + SYSTEM_TABLE_MEMBER AS MEMBER, + SOURCE_TYPE, + NUMBER_ROWS AS LINE_COUNT, + LAST_SOURCE_UPDATE_TIMESTAMP + FROM QSYS2.SYSPARTITIONSTAT + WHERE SYSTEM_TABLE_SCHEMA = UPPER(?) + AND SYSTEM_TABLE_NAME = UPPER(?) + AND SYSTEM_TABLE_MEMBER = UPPER(?) + `.trim(); + + const metadataResult = await IBMiConnectionPool.executeQuery( + metadataSql, + [library, source_file, member], + appContext, + ); + + if (!metadataResult.data || metadataResult.data.length === 0) { + return { + success: false, + executionTime: Date.now() - startTime, + error: { + code: String(JsonRpcErrorCode.InvalidRequest), + message: `Source member not found: ${library}/${source_file}(${member})`, + }, + }; + } + + const metadata = metadataResult.data[0] as Record; + + // Build IFS path to source member + const ifsPath = `/QSYS.LIB/${library.toUpperCase()}.LIB/${source_file.toUpperCase()}.FILE/${member.toUpperCase()}.MBR`; + + // Read source code using IFS_READ_UTF8 + const sourceSql = ` + SELECT LINE_NUMBER, LINE + FROM TABLE(QSYS2.IFS_READ_UTF8(PATH_NAME => '${ifsPath}')) + ORDER BY LINE_NUMBER + `.trim(); + + const sourceResult = await IBMiConnectionPool.executeQuery( + sourceSql, + [], + appContext, + ); + + if (!sourceResult.data) { + return { + success: false, + executionTime: Date.now() - startTime, + error: { + code: String(JsonRpcErrorCode.InternalError), + message: "Failed to read source code", + }, + }; + } + + // Format source code with optional line numbers + const sourceCode = sourceResult.data + .map((row: any) => { + const lineNum = row.LINE_NUMBER || 0; + const line = row.LINE || ""; + if (include_line_numbers) { + return `${String(lineNum).padStart(6, " ")} ${line}`; + } + return line; + }) + .join("\n"); + + const executionTime = Date.now() - startTime; + + return { + success: true, + library: metadata.LIBRARY, + sourceFile: metadata.SOURCE_FILE, + member: metadata.MEMBER, + sourceType: metadata.SOURCE_TYPE, + lineCount: metadata.LINE_COUNT, + sourceCode, + lastModified: metadata.LAST_SOURCE_UPDATE_TIMESTAMP, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + + logger.error( + { + ...appContext, + error: error instanceof Error ? error.message : String(error), + library, + sourceFile: source_file, + member, + executionTime, + }, + "Read source member failed.", + ); + + if (error instanceof McpError) { + return { + success: false, + executionTime, + error: { + code: String(error.code), + message: error.message, + details: error.details, + }, + }; + } + + return { + success: false, + executionTime, + error: { + code: String(JsonRpcErrorCode.InternalError), + message: `Failed to read source member: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } +} + +// ============================================================================= +// Response Formatter +// ============================================================================= + +const readSourceMemberResponseFormatter = ( + result: ReadSourceMemberOutput, +): ContentBlock[] => { + if (!result.success) { + const errorMessage = + result.error?.message || "Failed to read source member"; + const errorDetails = result.error?.details + ? `\n\nDetails:\n${JSON.stringify(result.error.details, null, 2)}` + : ""; + return [{ type: "text", text: `Error: ${errorMessage}${errorDetails}` }]; + } + + const header = `Source: ${result.library}/${result.sourceFile}(${result.member}) +Type: ${result.sourceType} +Lines: ${result.lineCount} +Last Modified: ${result.lastModified} +Execution time: ${result.executionTime}ms + +Source Code: +${"=".repeat(80)} +`; + + return [ + { + type: "text", + text: `${header}${result.sourceCode}\n${"=".repeat(80)}`, + }, + ]; +}; + +// ============================================================================= +// Tool Definition +// ============================================================================= + +export const readSourceMemberTool = defineTool({ + name: "read_source_member", + title: "Read Source Member", + description: + "Read IBM i source code from a source physical file member. Returns the complete source code with line numbers and metadata including source type, line count, and last modification timestamp.", + inputSchema: ReadSourceMemberInputSchema, + outputSchema: ReadSourceMemberOutputSchema, + logic: readSourceMemberLogic, + responseFormatter: readSourceMemberResponseFormatter, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/readSpoolFile.tool.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/readSpoolFile.tool.ts new file mode 100644 index 0000000..06f1191 --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/readSpoolFile.tool.ts @@ -0,0 +1,289 @@ +/** + * Read Spool File Tool + * + * Retrieves the contents of an IBM i spool file (e.g., compilation listings). + * This tool is essential for reading detailed compiler error messages. + * + * @module readSpoolFile.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const ReadSpoolFileInputSchema = z.object({ + job_name: z + .string() + .optional() + .describe("Job name in format NUMBER/USER/NAME (e.g., '123456/USER/QZDASOINIT'). Use '*' for current job"), + spool_file: z + .string() + .optional() + .default("*LAST") + .describe("Spool file name or '*LAST' for most recent spool file"), + spool_number: z + .number() + .optional() + .describe("Spool file number (if multiple files with same name)"), + max_records: z + .number() + .optional() + .default(500) + .describe("Maximum number of records to retrieve"), +}); + +const ReadSpoolFileOutputSchema = z.object({ + success: z.boolean().describe("Whether the operation succeeded."), + spoolFile: z.string().optional().describe("Spool file name"), + jobName: z.string().optional().describe("Job name"), + fileNumber: z.number().optional().describe("Spool file number"), + recordCount: z.number().optional().describe("Number of records retrieved"), + content: z.string().optional().describe("Spool file content"), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if operation failed."), +}); + +type ReadSpoolFileInput = z.infer; +type ReadSpoolFileOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +export async function readSpoolFileLogic( + params: ReadSpoolFileInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing read spool file logic.", + ); + + const startTime = Date.now(); + const { job_name, spool_file, spool_number, max_records } = params; + + try { + // Query to get spool file data using OUTPUT_QUEUE_ENTRIES_BASIC + // and SPOOL_FILE_DATA services + const jobFilter = job_name || "*"; + const spoolFilter = spool_file || "*LAST"; + + // First, find the spool file entry + let findSpoolSql = ` + SELECT + JOB_NAME, + SPOOLED_FILE_NAME, + FILE_NUMBER, + USER_NAME, + CREATE_TIMESTAMP + FROM QSYS2.OUTPUT_QUEUE_ENTRIES_BASIC + WHERE 1=1 + `; + + const sqlParams: any[] = []; + + if (jobFilter !== "*") { + findSpoolSql += ` AND JOB_NAME = ?`; + sqlParams.push(jobFilter); + } + + if (spoolFilter !== "*LAST") { + findSpoolSql += ` AND SPOOLED_FILE_NAME = ?`; + sqlParams.push(spoolFilter); + } + + if (spool_number) { + findSpoolSql += ` AND FILE_NUMBER = ?`; + sqlParams.push(spool_number); + } + + findSpoolSql += ` + ORDER BY CREATE_TIMESTAMP DESC + FETCH FIRST 1 ROW ONLY + `; + + const spoolEntry = await IBMiConnectionPool.executeQuery( + findSpoolSql, + sqlParams, + appContext, + ); + + if (!spoolEntry.data || spoolEntry.data.length === 0) { + return { + success: false, + executionTime: Date.now() - startTime, + error: { + code: String(JsonRpcErrorCode.InvalidRequest), + message: `No spool file found matching criteria: job=${jobFilter}, file=${spoolFilter}`, + }, + }; + } + + const entry = spoolEntry.data[0] as any; + const fullJobName = entry.JOB_NAME as string; + const fileName = entry.SPOOLED_FILE_NAME as string; + const fileNum = entry.FILE_NUMBER as number; + + // Use SYSTOOLS.SPOOLED_FILE_DATA - available on IBM i 7.5+ + // This is the recommended IBM i method for reading spool file content + const readSpoolSql = ` + SELECT ORDINAL_POSITION, SPOOLED_DATA + FROM TABLE(SYSTOOLS.SPOOLED_FILE_DATA( + JOB_NAME => ?, + SPOOLED_FILE_NAME => ?, + SPOOLED_FILE_NUMBER => ? + )) + ORDER BY ORDINAL_POSITION + FETCH FIRST ? ROWS ONLY + `; + + // Note: We pass max_records as BOTH a SQL parameter (in FETCH FIRST ? ROWS) + // AND as the rowsToFetch argument to ensure Mapepire fetches that many rows. + // Mapepire defaults to 100 rows if rowsToFetch is not specified. + const spoolData = await IBMiConnectionPool.executeQuery( + readSpoolSql, + [fullJobName, fileName, fileNum, max_records], + appContext, + max_records, // Pass max_records as rowsToFetch parameter + ); + + if (!spoolData.data || spoolData.data.length === 0) { + return { + success: true, + spoolFile: fileName, + jobName: fullJobName, + fileNumber: fileNum, + recordCount: 0, + content: "", + executionTime: Date.now() - startTime, + }; + } + + // Combine all spool data lines + const content = spoolData.data + .map((row: any) => row.SPOOLED_DATA) + .join("\n"); + + const executionTime = Date.now() - startTime; + + return { + success: true, + spoolFile: fileName, + jobName: fullJobName, + fileNumber: fileNum, + recordCount: spoolData.data.length, + content, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + + logger.error( + { + ...appContext, + error: error instanceof Error ? error.message : String(error), + executionTime, + }, + "Read spool file failed.", + ); + + if (error instanceof McpError) { + return { + success: false, + executionTime, + error: { + code: String(error.code), + message: error.message, + details: error.details, + }, + }; + } + + return { + success: false, + executionTime, + error: { + code: String(JsonRpcErrorCode.InternalError), + message: `Failed to read spool file: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } +} + +// ============================================================================= +// Response Formatter +// ============================================================================= + +const readSpoolFileResponseFormatter = ( + result: ReadSpoolFileOutput, +): ContentBlock[] => { + if (!result.success) { + const errorMessage = result.error?.message || "Failed to read spool file"; + const errorDetails = result.error?.details + ? `\n\nDetails:\n${JSON.stringify(result.error.details, null, 2)}` + : ""; + return [ + { + type: "text", + text: `Error reading spool file: ${errorMessage}${errorDetails}`, + }, + ]; + } + + const header = `Spool File: ${result.spoolFile || "N/A"} +Job: ${result.jobName || "N/A"} +File Number: ${result.fileNumber || "N/A"} +Records: ${result.recordCount || 0} +Execution time: ${result.executionTime}ms + +Spool File Content: +${"=".repeat(80)} +${result.content || "(empty)"} +${"=".repeat(80)}`; + + return [ + { + type: "text", + text: header, + }, + ]; +}; + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export const readSpoolFileTool = defineTool({ + name: "read_spool_file", + title: "Read Spool File", + description: + "Read the contents of an IBM i spool file (e.g., compilation listing). " + + "Use this to retrieve detailed compiler error messages after a compilation failure. " + + "Specify job_name or use '*' for current job, and spool_file name or '*LAST' for most recent.", + inputSchema: ReadSpoolFileInputSchema, + outputSchema: ReadSpoolFileOutputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + logic: readSpoolFileLogic, + responseFormatter: readSpoolFileResponseFormatter, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/writeSourceMember.tool.ts b/packages/server/src/ibmi-mcp-server/tools/source-code/writeSourceMember.tool.ts new file mode 100644 index 0000000..483eab9 --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/writeSourceMember.tool.ts @@ -0,0 +1,423 @@ +/** + * Write Source Member Tool + * + * Writes or updates IBM i source code in a source physical file member. + * This enables AI-driven code generation and automated fixes. + * + * Uses IFS + CPYFRMSTMF approach for atomic, reliable writes that bypass + * connection pool transaction issues. + * + * @module writeSourceMember.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const WriteSourceMemberInputSchema = z.object({ + library: z + .string() + .min(1, "Library name cannot be empty.") + .max(10, "Library name cannot exceed 10 characters.") + .describe("Library containing the source file"), + source_file: z + .string() + .min(1, "Source file name cannot be empty.") + .max(10, "Source file name cannot exceed 10 characters.") + .describe("Source file name (e.g., 'QRPGLESRC', 'QCLSRC')"), + member: z + .string() + .min(1, "Member name cannot be empty.") + .max(10, "Member name cannot exceed 10 characters.") + .describe("Source member name to write"), + source_code: z + .string() + .min(1, "Source code cannot be empty.") + .describe("Complete source code content to write to the member"), + source_type: z + .string() + .optional() + .default("RPGLE") + .describe("Source type (e.g., 'RPGLE', 'CLLE', 'SQLRPGLE')"), + text_description: z + .string() + .optional() + .describe("Member text description"), + replace_existing: z + .boolean() + .optional() + .default(true) + .describe("Replace existing member if it exists (default: true)"), +}); + +const WriteSourceMemberOutputSchema = z.object({ + success: z.boolean().describe("Whether the operation was successful."), + library: z.string().optional(), + sourceFile: z.string().optional(), + member: z.string().optional(), + sourceType: z.string().optional(), + linesWritten: z.number().optional().describe("Number of lines written"), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if the operation failed."), +}); + +type WriteSourceMemberInput = z.infer; +type WriteSourceMemberOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +/** + * Helper: Write text to IFS using QSYS2.IFS_WRITE_UTF8 + */ +async function writeToIFS( + ifsPath: string, + content: string, + context: RequestContext +): Promise { + logger.debug({ ...context, ifsPath }, "Writing content to IFS"); + + // Split content into lines - IFS_WRITE_UTF8 writes line by line + const lines = content.split('\n'); + + // First line: REPLACE mode (overwrites file) + if (lines.length > 0) { + const firstLineSQL = ` + CALL QSYS2.IFS_WRITE_UTF8( + PATH_NAME => ?, + LINE => ?, + OVERWRITE => 'REPLACE', + END_OF_LINE => 'LF' + ) + `; + await IBMiConnectionPool.executeQuery( + firstLineSQL, + [ifsPath, lines[0] || ''], + context + ); + } + + // Remaining lines: APPEND mode + for (let i = 1; i < lines.length; i++) { + const appendLineSQL = ` + CALL QSYS2.IFS_WRITE_UTF8( + PATH_NAME => ?, + LINE => ?, + OVERWRITE => 'APPEND', + END_OF_LINE => 'LF' + ) + `; + await IBMiConnectionPool.executeQuery( + appendLineSQL, + [ifsPath, lines[i] || ''], + context + ); + } + + logger.info({ ...context, ifsPath, lines: lines.length }, "Successfully wrote content to IFS"); +} + +/** + * Helper: Delete IFS file + */ +async function deleteFromIFS( + ifsPath: string, + context: RequestContext +): Promise { + try { + // Simple rm command - ignore errors if file doesn't exist + const rmCmd = `rm -f ${ifsPath}`; + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC(?)`, + [rmCmd], + context + ); + logger.debug({ ...context, ifsPath }, "Deleted IFS file"); + } catch (error) { + // Ignore errors - file might not exist or already deleted + logger.debug({ ...context, ifsPath, error }, "Could not delete IFS file (may not exist)"); + } +} + +export async function writeSourceMemberLogic( + params: WriteSourceMemberInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing write source member logic using IFS+CPYFRMSTMF approach.", + ); + + const startTime = Date.now(); + const { + library, + source_file, + member, + source_code, + source_type, + text_description, + replace_existing, + } = params; + + // Generate unique temporary IFS path + const timestamp = Date.now(); + const ifsPath = `/tmp/mcp_${member}_${timestamp}.tmp`; + + try { + const lines = source_code.split('\n'); + logger.info( + { ...appContext, library, sourceFile: source_file, member, lines: lines.length }, + "Writing source member using IFS+CPYFRMSTMF" + ); + + // Step 1: Prepare source member (create or clear) + const textDesc = text_description || `AI-generated ${source_type} code`; + + if (replace_existing) { + // Try to remove existing member, then create new one + try { + const rmvCmd = `RMVM FILE(${library}/${source_file}) MBR(${member})`; + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC(?)`, + [rmvCmd], + appContext + ); + logger.info({ ...appContext, member }, "Removed existing member"); + } catch (rmvError) { + // Member doesn't exist - that's OK + logger.debug({ ...appContext, member }, "Member didn't exist (OK)"); + } + } + + // Create the member (will fail if exists and replace_existing=false) + const createCmd = `ADDPFM FILE(${library}/${source_file}) MBR(${member}) SRCTYPE(${source_type}) TEXT('${textDesc}')`; + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC(?)`, + [createCmd], + appContext + ); + logger.info({ ...appContext, member, sourceType: source_type }, "Created source member"); + + // Step 2: Write source code to temporary IFS file + logger.info({ ...appContext, ifsPath }, "Writing source to temporary IFS file"); + await writeToIFS(ifsPath, source_code, appContext); + + // Step 3: Copy from IFS to source member using CPYFRMSTMF (ATOMIC operation!) + const memberPath = `/QSYS.LIB/${library}.LIB/${source_file}.FILE/${member}.MBR`; + const copyCmd = `CPYFRMSTMF FROMSTMF('${ifsPath}') TOMBR('${memberPath}') MBROPT(*REPLACE)`; + + logger.info( + { ...appContext, ifsPath, memberPath }, + "Copying from IFS to source member (atomic operation)" + ); + + await IBMiConnectionPool.executeQuery( + `CALL QSYS2.QCMDEXC(?)`, + [copyCmd], + appContext + ); + + logger.info({ ...appContext, member }, "Successfully copied to source member"); + + // Step 4: Clean up temporary IFS file + await deleteFromIFS(ifsPath, appContext); + + // Step 5: VERIFY the data was actually persisted + // Note: Old-style source files don't have SRCMBR column, so we verify using the member path + logger.debug({ ...appContext, member }, "Verifying source member was persisted"); + + try { + // Use QSYS2.SYSPARTITIONSTAT to verify member exists and has data + const verifySQL = ` + SELECT NUMBER_ROWS as LINE_COUNT + FROM QSYS2.SYSPARTITIONSTAT + WHERE TABLE_SCHEMA = '${library}' + AND TABLE_NAME = '${source_file}' + AND SYSTEM_TABLE_MEMBER = '${member}' + `; + const verifyResult = await IBMiConnectionPool.executeQuery( + verifySQL, + [], + appContext + ); + + const actualLines = (verifyResult.data?.[0] as any)?.LINE_COUNT ?? 0; + + if (actualLines === 0) { + logger.error( + { ...appContext, member, expected: lines.length, actual: actualLines }, + "Verification failed: member has no rows" + ); + + throw new McpError( + JsonRpcErrorCode.InternalError, + `Verification failed: Member ${library}/${source_file}(${member}) exists but has no data`, + { + expected: lines.length, + actual: actualLines, + library, + sourceFile: source_file, + member + } + ); + } + + logger.info( + { ...appContext, member, verifiedLines: actualLines }, + "Verification successful: member has data" + ); + } catch (verifyError) { + if (verifyError instanceof McpError) { + throw verifyError; + } + + logger.error( + { ...appContext, member, error: verifyError }, + "Verification query failed" + ); + + throw new McpError( + JsonRpcErrorCode.InternalError, + `Could not verify source member was persisted: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`, + { + originalError: verifyError instanceof Error ? verifyError.message : String(verifyError) + } + ); + } + + const executionTime = Date.now() - startTime; + + logger.info( + { ...appContext, member, linesWritten: lines.length, executionTime }, + "Source member written and verified successfully", + ); + + return { + success: true, + library, + sourceFile: source_file, + member, + sourceType: source_type, + linesWritten: lines.length, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + + // Clean up temporary IFS file on error + await deleteFromIFS(ifsPath, appContext); + + logger.error( + { + ...appContext, + error: error instanceof Error ? error.message : String(error), + library, + member, + executionTime, + }, + "Write source member failed.", + ); + + if (error instanceof McpError) { + return { + success: false, + executionTime, + error: { + code: String(error.code), + message: error.message, + details: error.details, + }, + }; + } + + return { + success: false, + executionTime, + error: { + code: String(JsonRpcErrorCode.InternalError), + message: `Failed to write source member: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } +} + +// ============================================================================= +// Response Formatter +// ============================================================================= + +const writeSourceMemberResponseFormatter = ( + result: WriteSourceMemberOutput, +): ContentBlock[] => { + if (!result.success) { + const errorMessage = result.error?.message || "Failed to write source member"; + const errorDetails = result.error?.details + ? `\n\nDetails:\n${JSON.stringify(result.error.details, null, 2)}` + : ""; + return [ + { + type: "text", + text: `Error writing source member: ${errorMessage}${errorDetails}`, + }, + ]; + } + + const summary = `āœ… Source Member Written Successfully + +Library: ${result.library} +Source File: ${result.sourceFile} +Member: ${result.member} +Type: ${result.sourceType} +Lines Written: ${result.linesWritten} +Execution Time: ${result.executionTime}ms + +The source code has been written to ${result.library}/${result.sourceFile}(${result.member}). +You can now compile it using the compile_source tool.`; + + return [ + { + type: "text", + text: summary, + }, + ]; +}; + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export const writeSourceMemberTool = defineTool({ + name: "write_source_member", + title: "Write Source Member", + description: + "Write or update IBM i source code in a source physical file member. " + + "This tool enables AI-driven code generation and automated fixes. " + + "Use this to create new source members or update existing ones with corrected code. " + + "The source code should be provided as a complete multi-line string.", + inputSchema: WriteSourceMemberInputSchema, + outputSchema: WriteSourceMemberOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, // This modifies source code! + openWorldHint: false, + }, + logic: writeSourceMemberLogic, + responseFormatter: writeSourceMemberResponseFormatter, +}); diff --git a/packages/server/src/ibmi-mcp-server/tools/source-code/writeSourceToIfs.tool.ts.disabled b/packages/server/src/ibmi-mcp-server/tools/source-code/writeSourceToIfs.tool.ts.disabled new file mode 100644 index 0000000..bb01559 --- /dev/null +++ b/packages/server/src/ibmi-mcp-server/tools/source-code/writeSourceToIfs.tool.ts.disabled @@ -0,0 +1,147 @@ +/** + * Write Source to IFS Tool + * + * Writes IBM i source code to IFS using QSYS2.IFS_WRITE_UTF8. + * This is cleaner than writing to source physical file members. + * + * @module writeSourceToIfs.tool + */ + +import { z } from "zod"; +import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { JsonRpcErrorCode, McpError } from "../../../types-global/errors.js"; +import type { RequestContext } from "../../../utils/index.js"; +import { logger } from "../../../utils/internal/logger.js"; +import { IBMiConnectionPool } from "../../services/connectionPool.js"; +import { defineTool } from "../../../mcp-server/tools/utils/tool-factory.js"; +import type { SdkContext } from "../../../mcp-server/tools/utils/types.js"; + +// ============================================================================= +// Schemas +// ============================================================================= + +const WriteSourceToIfsInputSchema = z.object({ + ifs_path: z + .string() + .min(1, "IFS path cannot be empty.") + .describe( + "IFS path for the source file (e.g., '/home/user/src/MYPROG.sqlrpgle')" + ), + source_code: z + .string() + .min(1, "Source code cannot be empty.") + .describe("Complete source code content to write"), + overwrite: z + .boolean() + .optional() + .default(true) + .describe("Overwrite file if it exists (default: true)"), +}); + +const WriteSourceToIfsOutputSchema = z.object({ + success: z.boolean().describe("Whether the operation was successful."), + ifsPath: z.string().optional(), + linesWritten: z.number().optional().describe("Number of lines written"), + executionTime: z.number().optional().describe("Execution time in milliseconds"), + error: z + .object({ + code: z.string().describe("Error code"), + message: z.string().describe("Error message"), + details: z.record(z.unknown()).optional().describe("Error details"), + }) + .optional() + .describe("Error information if the operation failed."), +}); + +type WriteSourceToIfsInput = z.infer; +type WriteSourceToIfsOutput = z.infer; + +// ============================================================================= +// Business Logic +// ============================================================================= + +export async function writeSourceToIfsLogic( + params: WriteSourceToIfsInput, + appContext: RequestContext, + _sdkContext: SdkContext, +): Promise { + logger.debug( + { ...appContext, toolInput: params }, + "Processing write source to IFS logic." + ); + + const startTime = Date.now(); + const { ifs_path, source_code, overwrite } = params; + + const pool = IBMiConnectionPool.getInstance(); + const job = await pool.getJob(); + + try { + // Count lines for reporting + const lines = source_code.split("\n"); + const lineCount = lines.length; + + logger.info({ ...appContext, ifsPath: ifs_path, lineCount }, "Writing source to IFS"); + + // Use QSYS2.IFS_WRITE_UTF8 to write the source code + const overwriteMode = overwrite ? "REPLACE" : "APPEND"; + + // Build SQL statement - we need to write line by line or use a stored procedure + // For simplicity, we'll write the entire content as a single call + const sql = ` + CALL QSYS2.IFS_WRITE_UTF8( + PATH_NAME => ?, + LINE => ?, + OVERWRITE => '${overwriteMode}', + END_OF_LINE => 'LF' + ) + `; + + // Execute the write + await job.query(sql, { + parameters: [ifs_path, source_code], + }); + + const executionTime = Date.now() - startTime; + + logger.info( + { ...appContext, ifsPath: ifs_path, linesWritten: lineCount, executionTime }, + "Successfully wrote source to IFS" + ); + + return { + success: true, + ifsPath: ifs_path, + linesWritten: lineCount, + executionTime, + }; + } catch (error) { + const executionTime = Date.now() - startTime; + logger.error( + { ...appContext, error, ifsPath: ifs_path, executionTime }, + "Failed to write source to IFS" + ); + + throw new McpError( + JsonRpcErrorCode.InternalError, + `Failed to write source to IFS: ${error instanceof Error ? error.message : String(error)}`, + { + ifsPath: ifs_path, + error: String(error), + } + ); + } finally { + await pool.returnJob(job); + } +} + +// ============================================================================= +// Tool Registration +// ============================================================================= + +export const writeSourceToIfsTool = defineTool({ + name: "write_source_to_ifs", + description: `Write IBM i source code to IFS (Integrated File System) using QSYS2.IFS_WRITE_UTF8. This is the recommended approach for modern IBM i development as it's cleaner than writing to source physical file members. The source can then be compiled directly from the stream file using SRCSTMF parameter.`, + inputSchema: WriteSourceToIfsInputSchema, + logic: writeSourceToIfsLogic, +});