Skip to content

Commit 4b6f571

Browse files
tianzhouclaude
andauthored
refactor: consolidate tool handler utilities (#221)
* refactor: consolidate tool handler utilities Extract common patterns from tool handlers into shared utilities to reduce duplication and improve maintainability. Changes: - Add src/utils/tool-handler-helpers.ts with shared utilities: - getEffectiveSourceId(): Normalize source ID handling - validateReadonlySQL(): Validate SQL against readonly constraints - trackToolRequest(): Centralize request tracking logic - withRequestTracking(): HOF wrapper (available for future use) - Refactor custom-tool-handler.ts to use new utilities - Refactor execute-sql.ts to use new utilities - Refactor search-objects.ts to use new utilities Benefits: - Each handler reduced by ~20-30 lines - Centralizes cross-cutting concerns (tracking, validation) - Handlers focus on core logic - Sets pattern for future tool handlers All tests passing (70/70 tool tests). Zero behavior changes. Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: address PR review feedback Address all three issues from code review: 1. Preserve READONLY_VIOLATION error code - Replace validateReadonlySQL() with two separate functions: - isAllowedInReadonlyMode(): Check if SQL is allowed - createReadonlyViolationMessage(): Format error message - Keep error handling inline to preserve error code 2. Remove redundant readonly parameter - New functions don't need the boolean flag - Validation only called when readonly=true 3. Remove unused import - Removed getEffectiveSourceId from custom-tool-handler.ts - Not needed since sourceId comes from toolConfig All tests passing (70/70). Build successful. True zero behavior changes. Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: improve type safety and code quality Address code review feedback on type safety and clarity: 1. Fix RequestMetadata type safety - Change sql from optional (sql?) to required (sql: string) - Aligns with Request interface in requestStore - All callers already provide sql, preventing runtime errors 2. Simplify isAllowedInReadonlyMode wrapper - Use re-export instead of wrapper function - Reduces indirection while maintaining clear naming - No added value from thin wrapper All tests passing (70/70). Build successful. Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 9340818 commit 4b6f571

File tree

4 files changed

+155
-41
lines changed

4 files changed

+155
-41
lines changed

src/tools/custom-tool-handler.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
createToolErrorResponse,
1212
} from "../utils/response-formatter.js";
1313
import { mapArgumentsToArray } from "../utils/parameter-mapper.js";
14-
import { isReadOnlySQL, allowedKeywords } from "../utils/allowed-keywords.js";
15-
import { requestStore } from "../requests/index.js";
16-
import { getClientIdentifier } from "../utils/client-identifier.js";
14+
import {
15+
isAllowedInReadonlyMode,
16+
createReadonlyViolationMessage,
17+
trackToolRequest,
18+
} from "../utils/tool-handler-helpers.js";
1719

1820
/**
1921
* Build a Zod schema from parameter definitions
@@ -177,8 +179,8 @@ export function createCustomToolHandler(toolConfig: ToolConfig) {
177179

178180
// 4. Check if SQL is allowed based on readonly mode
179181
const isReadonly = executeOptions.readonly === true;
180-
if (isReadonly && !isReadOnlySQL(toolConfig.statement, connector.id)) {
181-
errorMessage = `Tool '${toolConfig.name}' cannot execute in readonly mode for source '${toolConfig.source}'. Only read-only SQL operations are allowed: ${allowedKeywords[connector.id]?.join(", ") || "none"}`;
182+
if (isReadonly && !isAllowedInReadonlyMode(toolConfig.statement, connector.id)) {
183+
errorMessage = createReadonlyViolationMessage(toolConfig.name, toolConfig.source, connector.id);
182184
success = false;
183185
return createToolErrorResponse(errorMessage, "READONLY_VIOLATION");
184186
}
@@ -220,17 +222,17 @@ export function createCustomToolHandler(toolConfig: ToolConfig) {
220222
return createToolErrorResponse(errorMessage, "EXECUTION_ERROR");
221223
} finally {
222224
// Track the request
223-
requestStore.add({
224-
id: crypto.randomUUID(),
225-
timestamp: new Date().toISOString(),
226-
sourceId: toolConfig.source,
227-
toolName: toolConfig.name,
228-
sql: toolConfig.statement,
229-
durationMs: Date.now() - startTime,
230-
client: getClientIdentifier(extra),
225+
trackToolRequest(
226+
{
227+
sourceId: toolConfig.source,
228+
toolName: toolConfig.name,
229+
sql: toolConfig.statement,
230+
},
231+
startTime,
232+
extra,
231233
success,
232-
error: errorMessage,
233-
});
234+
errorMessage
235+
);
234236
}
235237
};
236238
}

src/tools/execute-sql.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { ConnectorManager } from "../connectors/manager.js";
33
import { createToolSuccessResponse, createToolErrorResponse } from "../utils/response-formatter.js";
44
import { isReadOnlySQL, allowedKeywords } from "../utils/allowed-keywords.js";
55
import { ConnectorType } from "../connectors/interface.js";
6-
import { requestStore } from "../requests/index.js";
7-
import { getClientIdentifier } from "../utils/client-identifier.js";
86
import { getToolRegistry } from "./registry.js";
97
import { BUILTIN_TOOL_EXECUTE_SQL } from "./builtin-tools.js";
8+
import {
9+
getEffectiveSourceId,
10+
trackToolRequest,
11+
} from "../utils/tool-handler-helpers.js";
1012

1113
// Schema for execute_sql tool
1214
export const executeSqlSchema = {
@@ -45,7 +47,7 @@ export function createExecuteSqlToolHandler(sourceId?: string) {
4547
return async (args: any, extra: any) => {
4648
const { sql } = args as { sql: string };
4749
const startTime = Date.now();
48-
const effectiveSourceId = sourceId || "default";
50+
const effectiveSourceId = getEffectiveSourceId(sourceId);
4951
let success = true;
5052
let errorMessage: string | undefined;
5153
let result: any;
@@ -89,17 +91,17 @@ export function createExecuteSqlToolHandler(sourceId?: string) {
8991
return createToolErrorResponse(errorMessage, "EXECUTION_ERROR");
9092
} finally {
9193
// Track the request
92-
requestStore.add({
93-
id: crypto.randomUUID(),
94-
timestamp: new Date().toISOString(),
95-
sourceId: effectiveSourceId,
96-
toolName: effectiveSourceId === "default" ? "execute_sql" : `execute_sql_${effectiveSourceId}`,
97-
sql,
98-
durationMs: Date.now() - startTime,
99-
client: getClientIdentifier(extra),
94+
trackToolRequest(
95+
{
96+
sourceId: effectiveSourceId,
97+
toolName: effectiveSourceId === "default" ? "execute_sql" : `execute_sql_${effectiveSourceId}`,
98+
sql,
99+
},
100+
startTime,
101+
extra,
100102
success,
101-
error: errorMessage,
102-
});
103+
errorMessage
104+
);
103105
}
104106
};
105107
}

src/tools/search-objects.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { ConnectorManager } from "../connectors/manager.js";
33
import { createToolSuccessResponse, createToolErrorResponse } from "../utils/response-formatter.js";
44
import type { Connector } from "../connectors/interface.js";
55
import { quoteQualifiedIdentifier } from "../utils/identifier-quoter.js";
6-
import { requestStore } from "../requests/index.js";
7-
import { getClientIdentifier } from "../utils/client-identifier.js";
6+
import {
7+
getEffectiveSourceId,
8+
trackToolRequest,
9+
} from "../utils/tool-handler-helpers.js";
810

911
/**
1012
* Object types that can be searched
@@ -474,7 +476,7 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) {
474476
};
475477

476478
const startTime = Date.now();
477-
const effectiveSourceId = sourceId || "default";
479+
const effectiveSourceId = getEffectiveSourceId(sourceId);
478480
let success = true;
479481
let errorMessage: string | undefined;
480482

@@ -551,17 +553,17 @@ export function createSearchDatabaseObjectsToolHandler(sourceId?: string) {
551553
);
552554
} finally {
553555
// Track the request
554-
requestStore.add({
555-
id: crypto.randomUUID(),
556-
timestamp: new Date().toISOString(),
557-
sourceId: effectiveSourceId,
558-
toolName: effectiveSourceId === "default" ? "search_objects" : `search_objects_${effectiveSourceId}`,
559-
sql: `search_objects(object_type=${object_type}, pattern=${pattern}, schema=${schema || "all"}, table=${table || "all"}, detail_level=${detail_level})`,
560-
durationMs: Date.now() - startTime,
561-
client: getClientIdentifier(extra),
556+
trackToolRequest(
557+
{
558+
sourceId: effectiveSourceId,
559+
toolName: effectiveSourceId === "default" ? "search_objects" : `search_objects_${effectiveSourceId}`,
560+
sql: `search_objects(object_type=${object_type}, pattern=${pattern}, schema=${schema || "all"}, table=${table || "all"}, detail_level=${detail_level})`,
561+
},
562+
startTime,
563+
extra,
562564
success,
563-
error: errorMessage,
564-
});
565+
errorMessage
566+
);
565567
}
566568
};
567569
}

src/utils/tool-handler-helpers.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Tool Handler Helpers
3+
* Shared utilities for MCP tool handlers to reduce boilerplate
4+
*/
5+
6+
import { ConnectorType } from "../connectors/interface.js";
7+
import { isReadOnlySQL, allowedKeywords } from "./allowed-keywords.js";
8+
import { requestStore } from "../requests/index.js";
9+
import { getClientIdentifier } from "./client-identifier.js";
10+
11+
/**
12+
* Request metadata for tracking
13+
*/
14+
export interface RequestMetadata {
15+
sourceId: string;
16+
toolName: string;
17+
sql: string;
18+
}
19+
20+
/**
21+
* Normalize source ID to handle optional parameter
22+
* @param sourceId Optional source ID from tool arguments
23+
* @returns Effective source ID ("default" if not provided)
24+
*/
25+
export function getEffectiveSourceId(sourceId?: string): string {
26+
return sourceId || "default";
27+
}
28+
29+
/**
30+
* Re-export isReadOnlySQL for readonly mode validation
31+
* Checks if SQL statement is read-only (SELECT, WITH, etc.)
32+
*/
33+
export { isReadOnlySQL as isAllowedInReadonlyMode };
34+
35+
/**
36+
* Create a readonly violation error message
37+
* @param toolName Tool name for error message
38+
* @param sourceId Source ID for error message
39+
* @param connectorType Database connector type
40+
* @returns Formatted error message
41+
*/
42+
export function createReadonlyViolationMessage(
43+
toolName: string,
44+
sourceId: string,
45+
connectorType: ConnectorType
46+
): string {
47+
return `Tool '${toolName}' cannot execute in readonly mode for source '${sourceId}'. Only read-only SQL operations are allowed: ${allowedKeywords[connectorType]?.join(", ") || "none"}`;
48+
}
49+
50+
/**
51+
* Track a tool request in the request store
52+
* @param metadata Request metadata (sourceId, toolName, sql)
53+
* @param startTime Request start timestamp
54+
* @param extra MCP extra context for client identification
55+
* @param success Whether the request succeeded
56+
* @param error Optional error message
57+
*/
58+
export function trackToolRequest(
59+
metadata: RequestMetadata,
60+
startTime: number,
61+
extra: any,
62+
success: boolean,
63+
error?: string
64+
): void {
65+
requestStore.add({
66+
id: crypto.randomUUID(),
67+
timestamp: new Date().toISOString(),
68+
sourceId: metadata.sourceId,
69+
toolName: metadata.toolName,
70+
sql: metadata.sql,
71+
durationMs: Date.now() - startTime,
72+
client: getClientIdentifier(extra),
73+
success,
74+
error,
75+
});
76+
}
77+
78+
/**
79+
* Higher-order function to wrap tool handlers with automatic request tracking
80+
* @param handler Core handler logic that performs the actual work
81+
* @param getMetadata Function to extract request metadata from args and result
82+
* @returns Wrapped handler with automatic request tracking
83+
*/
84+
export function withRequestTracking<TArgs = any, TResult = any>(
85+
handler: (args: TArgs, extra: any) => Promise<TResult>,
86+
getMetadata: (args: TArgs, result?: TResult, error?: Error) => RequestMetadata
87+
) {
88+
return async (args: TArgs, extra: any): Promise<TResult> => {
89+
const startTime = Date.now();
90+
let success = true;
91+
let errorMessage: string | undefined;
92+
let result: TResult | undefined;
93+
let error: Error | undefined;
94+
95+
try {
96+
result = await handler(args, extra);
97+
return result;
98+
} catch (err) {
99+
success = false;
100+
error = err as Error;
101+
errorMessage = error.message;
102+
throw err;
103+
} finally {
104+
const metadata = getMetadata(args, result, error);
105+
trackToolRequest(metadata, startTime, extra, success, errorMessage);
106+
}
107+
};
108+
}

0 commit comments

Comments
 (0)