Skip to content

Commit 5d2819a

Browse files
authored
Feat/markdown formatter (#22)
* feat(tests): add comprehensive tests for SQL formatting utilities and table formatter with NULL handling - Implemented tests for SQL formatting utilities including column alignment and header formatting. - Added integration tests for TableFormatter to handle NULL values and various formatting styles. - Enhanced test coverage for handling edge cases, including empty arrays and mixed data types. - Updated sample YAML configuration to specify response format as markdown. - Mocked logger in test setup to suppress logging during test execution. * update tool docs * docs: update README to include additional tableFormat styles * fun formatter
1 parent 1017157 commit 5d2819a

File tree

14 files changed

+2826
-829
lines changed

14 files changed

+2826
-829
lines changed

CLAUDE.md

Lines changed: 0 additions & 751 deletions
This file was deleted.

server/src/ibmi-mcp-server/schemas/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,23 @@ export const SqlToolConfigSchema = z
165165
security: SqlToolSecurityConfigSchema.optional().describe(
166166
"Security configuration for tool execution",
167167
),
168+
tableFormat: z
169+
.enum(["markdown", "ascii", "grid", "compact"])
170+
.optional()
171+
.default("markdown")
172+
.describe(
173+
"Table formatting style for SQL results (default: markdown). Options: markdown (GitHub-style), ascii (plain text), grid (Unicode boxes), compact (minimal spacing)",
174+
),
175+
maxDisplayRows: z
176+
.number()
177+
.int()
178+
.min(1, "maxDisplayRows must be at least 1")
179+
.max(1000, "maxDisplayRows cannot exceed 1000")
180+
.optional()
181+
.default(100)
182+
.describe(
183+
"Maximum number of rows to display in result tables (default: 100). Rows beyond this limit will show a truncation message",
184+
),
168185

169186
// Legacy deprecated fields (for backward compatibility)
170187
readOnlyHint: z

server/src/ibmi-mcp-server/schemas/json/sql-tools-config.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,19 @@
245245
"additionalProperties": false,
246246
"description": "Security configuration for tool execution"
247247
},
248+
"tableFormat": {
249+
"type": "string",
250+
"enum": ["markdown", "ascii", "grid", "compact"],
251+
"default": "markdown",
252+
"description": "Table formatting style for SQL results (default: markdown). Options: markdown (GitHub-style), ascii (plain text), grid (Unicode boxes), compact (minimal spacing)"
253+
},
254+
"maxDisplayRows": {
255+
"type": "integer",
256+
"minimum": 1,
257+
"maximum": 1000,
258+
"default": 100,
259+
"description": "Maximum number of rows to display in result tables (default: 100). Rows beyond this limit will show a truncation message"
260+
},
248261
"readOnlyHint": {
249262
"type": "boolean",
250263
"description": "@deprecated Use annotations.readOnlyHint instead"

server/src/ibmi-mcp-server/utils/config/toolConfigBuilder.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -464,12 +464,23 @@ export class ToolConfigBuilder {
464464
}
465465

466466
/**
467-
* Determines the appropriate response formatter based on tool configuration
467+
* Determines the appropriate response formatter based on tool configuration.
468+
* For SQL formatters, extracts and passes formatting configuration (tableFormat, maxDisplayRows).
468469
*/
469470
private getResponseFormatter(config: SqlToolConfig) {
470-
return config.responseFormat === "markdown"
471-
? sqlResponseFormatter
472-
: defaultResponseFormatter;
471+
if (config.responseFormat === "markdown") {
472+
// Extract formatting configuration from tool config
473+
const formatterConfig = {
474+
tableFormat: config.tableFormat,
475+
maxDisplayRows: config.maxDisplayRows,
476+
};
477+
478+
// Return a wrapper that passes the config to sqlResponseFormatter
479+
return (result: StandardSqlToolOutput) =>
480+
sqlResponseFormatter(result, formatterConfig);
481+
}
482+
483+
return defaultResponseFormatter;
473484
}
474485

475486
private buildAnnotations(

server/src/ibmi-mcp-server/utils/config/toolDefinitions.ts

Lines changed: 154 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ import {
2727
StandardSqlToolOutput,
2828
} from "@/ibmi-mcp-server/schemas/index.js";
2929

30+
// Import formatting utilities
31+
import {
32+
markdown,
33+
tableFormatter,
34+
buildColumnAlignmentMap,
35+
formatColumnHeader,
36+
type TableStyle,
37+
} from "@/utils/formatting/index.js";
38+
3039
/**
3140
* Represents the complete, self-contained definition of an MCP tool.
3241
*/
@@ -84,87 +93,137 @@ export interface ToolDefinition<
8493
export const standardSqlToolOutputSchema = StandardSqlToolOutputSchema;
8594

8695
/**
87-
* Formats SQL tool output into a well-formatted markdown table with metadata.
88-
* Provides a user-friendly representation of database query results.
96+
* Configuration options for SQL response formatting.
97+
*/
98+
export interface SqlFormatterConfig {
99+
/**
100+
* Table formatting style (default: 'markdown').
101+
*/
102+
tableFormat?: TableStyle;
103+
104+
/**
105+
* Maximum number of rows to display (default: 100).
106+
*/
107+
maxDisplayRows?: number;
108+
}
109+
110+
/**
111+
* Formats SQL tool output into a professionally formatted markdown response.
112+
* Uses MarkdownBuilder for structure and TableFormatter for type-aware table rendering.
113+
* Provides column type indicators, NULL tracking, and performance metrics.
114+
*
115+
* @param result - The SQL tool execution result
116+
* @param config - Optional formatting configuration
117+
* @returns Array of content blocks for MCP response
118+
*
119+
* @example
120+
* ```typescript
121+
* const result = await executeSqlTool(params);
122+
* const formatted = sqlResponseFormatter(result, {
123+
* tableFormat: 'grid',
124+
* maxDisplayRows: 50
125+
* });
126+
* ```
89127
*/
90128
export const sqlResponseFormatter = (
91129
result: StandardSqlToolOutput,
130+
config?: SqlFormatterConfig,
92131
): ContentBlock[] => {
132+
const tableFormat = config?.tableFormat || "markdown";
133+
const maxDisplayRows = config?.maxDisplayRows || 100;
134+
135+
// Handle error cases
93136
if (!result.success || !result.data) {
94-
// Handle error cases
95137
const errorMessage = result.error || "Unknown error occurred";
96138
const { metadata } = result;
97139

98-
let errorResponse = `❌ **SQL Query Failed**\n\n`;
140+
const errorBuilder = markdown()
141+
.alert("caution", "❌ SQL Query Failed")
142+
.blankLine();
99143

100144
if (metadata?.toolName) {
101-
errorResponse += `**Tool:** ${metadata.toolName}\n\n`;
145+
errorBuilder.keyValue("Tool", metadata.toolName);
102146
}
103147

104-
errorResponse += `**Error:** ${errorMessage}`;
148+
errorBuilder.keyValue("Error", errorMessage);
105149

106150
if (result.errorCode) {
107-
errorResponse += `\n**Error Code:** ${result.errorCode}`;
151+
errorBuilder.keyValue("Error Code", String(result.errorCode));
108152
}
109153

110154
if (metadata?.sqlStatement) {
111155
const truncatedSql =
112156
metadata.sqlStatement.length > 200
113157
? metadata.sqlStatement.substring(0, 197) + "..."
114158
: metadata.sqlStatement;
115-
errorResponse += `\n\n**SQL Statement:**\n\`\`\`sql\n${truncatedSql}\n\`\`\``;
159+
errorBuilder
160+
.blankLine()
161+
.h3("SQL Statement")
162+
.codeBlock(truncatedSql, "sql");
116163
}
117164

118-
return [{ type: "text", text: errorResponse }];
165+
const errorMarkdown = errorBuilder.build();
166+
167+
return [{ type: "text", text: errorMarkdown }];
119168
}
120169

121170
const { data, metadata } = result;
122171
const rowCount = data.length;
123-
const columnCount = metadata?.columns?.length || 0;
124172

125-
// Build structured response
126-
let response = "";
173+
// Start building the markdown response
174+
const mdBuilder = markdown();
127175

128176
// Tool header
129177
if (metadata?.toolName) {
130-
response += `## ${metadata.toolName}\n\n`;
178+
mdBuilder.h2(metadata.toolName);
131179
}
132180

133-
// Success indicator and row count
134-
response += `✅ **Query completed successfully**\n\n`;
135-
response += `Found **${rowCount} row${rowCount !== 1 ? "s" : ""}** from the database query\n\n`;
181+
// Success indicator
182+
mdBuilder
183+
.alert("tip", "✅ Query completed successfully")
184+
.blankLine()
185+
.paragraph(
186+
`Found **${rowCount} row${rowCount !== 1 ? "s" : ""}** from the database query`,
187+
);
136188

137189
// SQL Statement section
138190
if (metadata?.sqlStatement) {
139191
const truncatedSql =
140192
metadata.sqlStatement.length > 500
141193
? metadata.sqlStatement.substring(0, 497) + "..."
142194
: metadata.sqlStatement;
143-
response += `**SQL Statement:**\n\`\`\`sql\n${truncatedSql}\n\`\`\`\n\n`;
195+
mdBuilder.h3("SQL Statement").codeBlock(truncatedSql, "sql");
144196
}
145197

146198
// Parameters section
147199
if (metadata?.parameters && Object.keys(metadata.parameters).length > 0) {
148-
response += `**Parameters:**\n`;
149-
Object.entries(metadata.parameters).forEach(([key, value]) => {
150-
const displayValue =
151-
value === null || value === undefined
152-
? "NULL"
153-
: typeof value === "string" && value.length > 100
154-
? `${String(value).substring(0, 97)}...`
155-
: String(value);
156-
response += `- \`${key}\`: ${displayValue}\n`;
157-
});
158-
response += `\n`;
200+
mdBuilder.h3("Parameters");
201+
const paramList = Object.entries(metadata.parameters).map(
202+
([key, value]) => {
203+
const displayValue =
204+
value === null || value === undefined
205+
? "NULL"
206+
: typeof value === "string" && value.length > 100
207+
? `${String(value).substring(0, 97)}...`
208+
: String(value);
209+
return `\`${key}\`: ${displayValue}`;
210+
},
211+
);
212+
mdBuilder.list(paramList);
159213
}
160214

161215
// Handle empty results
162216
if (rowCount === 0) {
163-
response += `No rows returned from the query.\n\n`;
164-
response += `**Execution Summary:**\n`;
165-
response += `- Execution time: ${metadata?.executionTime ? `${metadata.executionTime}ms` : "N/A"}\n`;
166-
response += `- Parameters used: ${metadata?.parameterCount || 0}\n`;
167-
return [{ type: "text", text: response }];
217+
mdBuilder
218+
.paragraph("No rows returned from the query.")
219+
.h3("Execution Summary")
220+
.keyValue(
221+
"Execution time",
222+
metadata?.executionTime ? `${metadata.executionTime}ms` : "N/A",
223+
)
224+
.keyValue("Parameters used", String(metadata?.parameterCount || 0));
225+
226+
return [{ type: "text", text: mdBuilder.build() }];
168227
}
169228

170229
// Extract column information
@@ -179,68 +238,90 @@ export const sqlResponseFormatter = (
179238
label: key,
180239
}));
181240

182-
// Build markdown table
183-
let tableMarkdown = "";
184-
185-
// Header row with cleaner formatting
186-
const headers = allColumns.map((col) => {
187-
const header = col.label || col.name;
188-
return col.type ? `${header} (${col.type})` : header;
189-
});
190-
tableMarkdown += `| ${headers.join(" | ")} |\n`;
241+
// Prepare data for table formatter
242+
const displayRows = data.slice(0, maxDisplayRows);
243+
const columnCount = allColumns.length;
191244

192-
// Separator row
193-
tableMarkdown += `|${headers.map(() => "----------").join("|")}|\n`;
245+
// Format headers with type indicators
246+
const headers = allColumns.map((col) =>
247+
formatColumnHeader(col.label || col.name, col.type),
248+
);
194249

195-
// Data rows (limit to first 500 rows for better performance)
196-
const maxDisplayRows = 500;
197-
const displayRows = data.slice(0, maxDisplayRows);
250+
// Build column alignment map based on types
251+
const alignment = buildColumnAlignmentMap(allColumns);
198252

199-
displayRows.forEach((row) => {
200-
const values = allColumns.map((col) => {
253+
// Convert data rows to string arrays
254+
const rows = displayRows.map((row) =>
255+
allColumns.map((col) => {
201256
const value = row[col.name];
202-
if (value === null || value === undefined) return "NULL";
257+
if (value === null || value === undefined) return null;
203258
return String(value);
204-
});
205-
tableMarkdown += `| ${values.join(" | ")} |\n`;
259+
}),
260+
);
261+
262+
// Format table with metadata tracking
263+
const tableResult = tableFormatter.formatRawWithMetadata(headers, rows, {
264+
style: tableFormat,
265+
alignment,
266+
nullReplacement: "-",
267+
maxWidth: 50,
268+
truncate: true,
206269
});
207270

208-
// Show row limitation notice if applicable
271+
// Add truncation notice if applicable
209272
if (displayRows.length < rowCount) {
210-
response += `*Showing first ${displayRows.length} of ${rowCount} rows*\n\n`;
273+
mdBuilder.alert(
274+
"note",
275+
`Showing ${displayRows.length} of ${rowCount} rows. ${rowCount - displayRows.length} rows omitted.`,
276+
);
211277
}
212278

213-
response += `**Results:**\n\n${tableMarkdown}\n`;
279+
// Results section
280+
mdBuilder.h3("Results").raw(tableResult.table);
214281

215-
// Count null values across all displayed data
216-
let nullCount = 0;
217-
displayRows.forEach((row) => {
218-
allColumns.forEach((col) => {
219-
if (row[col.name] === null || row[col.name] === undefined) {
220-
nullCount++;
221-
}
222-
});
223-
});
282+
// NULL value summary (only if there are NULLs)
283+
const nullCounts = tableResult.metadata.nullCounts;
284+
const totalNulls = Object.values(nullCounts).reduce(
285+
(sum, count) => sum + count,
286+
0,
287+
);
224288

225-
// Execution summary
226-
response += `**Summary:**\n`;
227-
response += `- Total rows: ${rowCount}\n`;
228-
response += `- Columns: ${columnCount}\n`;
229-
response += `- Null values: ${nullCount}\n`;
289+
// Summary section
290+
const summaryItems: string[] = [
291+
`**Total rows**: ${rowCount}`,
292+
`**Columns**: ${columnCount}`,
293+
];
230294

231295
if (metadata?.executionTime) {
232-
response += `- Execution time: ${metadata.executionTime}ms\n`;
296+
summaryItems.push(`**Execution time**: ${metadata.executionTime}ms`);
297+
}
298+
299+
if (totalNulls > 0) {
300+
const nullDetails = Object.entries(nullCounts)
301+
.filter(([, count]) => count > 0)
302+
.map(([col, count]) => {
303+
// Convert column index to name for display
304+
const colIndex = parseInt(col);
305+
const colName = isNaN(colIndex)
306+
? col
307+
: allColumns[colIndex]?.name || col;
308+
return `${colName} (${count})`;
309+
})
310+
.join(", ");
311+
summaryItems.push(`**NULL values**: ${totalNulls} total - ${nullDetails}`);
233312
}
234313

235314
if (metadata?.affectedRows !== undefined) {
236-
response += `- Affected rows: ${metadata.affectedRows}\n`;
315+
summaryItems.push(`**Affected rows**: ${metadata.affectedRows}`);
237316
}
238317

239318
if (metadata?.parameterCount) {
240-
response += `- Parameters processed: ${metadata.parameterCount}\n`;
319+
summaryItems.push(`**Parameters processed**: ${metadata.parameterCount}`);
241320
}
242321

243-
return [{ type: "text", text: response }];
322+
mdBuilder.h3("Summary").list(summaryItems);
323+
324+
return [{ type: "text", text: mdBuilder.build() }];
244325
};
245326

246327
/**

0 commit comments

Comments
 (0)