Skip to content

Commit 78cb1b5

Browse files
committed
add graceful shutdown, indexing mutex, structured logging, and error recovery hints
Adds SIGTERM/SIGINT handlers to close SQLite connections cleanly, per-directory indexing mutex to prevent concurrent indexing of the same path, structured JSON logging to stderr, MCP logging capability for client notifications, and descriptive error messages with recovery guidance.
1 parent f0e726f commit 78cb1b5

File tree

6 files changed

+297
-59
lines changed

6 files changed

+297
-59
lines changed

src/logger.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export type LogLevel = 'debug' | 'info' | 'warning' | 'error';
2+
3+
const LEVEL_ORDER: Record<LogLevel, number> = {
4+
debug: 0,
5+
info: 1,
6+
warning: 2,
7+
error: 3,
8+
};
9+
10+
let currentLevel: LogLevel = 'info';
11+
12+
/**
13+
* Set the minimum log level. Messages below this level are discarded.
14+
*/
15+
export function setLogLevel(level: LogLevel): void {
16+
currentLevel = level;
17+
}
18+
19+
/**
20+
* Get the current log level.
21+
*/
22+
export function getLogLevel(): LogLevel {
23+
return currentLevel;
24+
}
25+
26+
/**
27+
* Initialize the log level from the LOG_LEVEL environment variable.
28+
* Invalid values default to 'info' with a warning on stderr.
29+
*/
30+
export function initLogLevel(): void {
31+
const envLevel = process.env.LOG_LEVEL?.toLowerCase();
32+
if (!envLevel) return;
33+
34+
if (envLevel in LEVEL_ORDER) {
35+
currentLevel = envLevel as LogLevel;
36+
} else {
37+
process.stderr.write(
38+
`Warning: invalid LOG_LEVEL "${process.env.LOG_LEVEL}", defaulting to "info"\n`
39+
);
40+
currentLevel = 'info';
41+
}
42+
}
43+
44+
/**
45+
* Write a structured JSON log line to stderr.
46+
* Never writes to stdout (reserved for the MCP protocol channel).
47+
*/
48+
export function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
49+
if (LEVEL_ORDER[level] < LEVEL_ORDER[currentLevel]) return;
50+
51+
const entry: Record<string, unknown> = {
52+
timestamp: new Date().toISOString(),
53+
level,
54+
message,
55+
...data,
56+
};
57+
58+
process.stderr.write(JSON.stringify(entry) + '\n');
59+
}

src/mcp-handlers.ts

Lines changed: 118 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,23 @@ import { searchContent, findSimilarFiles, getFileContent, getChunkContent } from
44
import { getIndexStatus, SQLiteStorage, initializeStorage } from './storage.js';
55
import { validateIndexPrerequisites, validateSearchPrerequisites } from './prerequisites.js';
66
import { validatePathWithinIndexedDirs, resolveIndexedDirectories } from './path-validation.js';
7+
import { log } from './logger.js';
78
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
9+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
10+
11+
// MCP server reference for sending client-visible log notifications
12+
let mcpServer: Server | null = null;
13+
14+
/**
15+
* Set the MCP server reference for logging notifications.
16+
* Called from startMcpServer() after server creation.
17+
*/
18+
export function setMcpServer(server: Server): void {
19+
mcpServer = server;
20+
}
21+
22+
// Workspace-level indexing mutex: keyed by normalized directory path
23+
const indexingMutex = new Map<string, Promise<void>>();
824

925
// Cached set of resolved indexed directory paths for path validation
1026
let indexedDirsCache: Set<string> = new Set();
@@ -94,34 +110,71 @@ export async function handleIndexTool(args: unknown, config: Config): Promise<Ca
94110
await validateIndexPrerequisites(config);
95111

96112
const paths = args.directory_paths.map((p: string) => p.trim());
97-
const result = await indexDirectories(paths, config);
98113

99-
// Refresh the indexed directories cache after successful indexing
100-
const { sqlite } = await initializeStorage(config);
114+
log('info', 'Index start', { directories: paths });
115+
mcpServer?.sendLoggingMessage({ level: 'info', data: { event: 'index_start', directories: paths } });
116+
117+
// Per-directory mutex: serialize concurrent calls targeting the same directory
118+
for (const dirPath of paths) {
119+
const existing = indexingMutex.get(dirPath);
120+
if (existing) {
121+
log('info', 'Waiting for ongoing indexing', { directory: dirPath });
122+
await existing;
123+
}
124+
}
125+
126+
// Create a deferred promise for this indexing operation
127+
let resolveIndexing: () => void;
128+
const indexingPromise = new Promise<void>((resolve) => { resolveIndexing = resolve; });
129+
for (const dirPath of paths) {
130+
indexingMutex.set(dirPath, indexingPromise);
131+
}
132+
101133
try {
102-
refreshIndexedDirsCache(sqlite);
134+
const result = await indexDirectories(paths, config);
135+
136+
// Refresh the indexed directories cache after successful indexing
137+
const { sqlite } = await initializeStorage(config);
138+
try {
139+
refreshIndexedDirsCache(sqlite);
140+
} finally {
141+
sqlite.close();
142+
}
143+
144+
log('info', 'Index complete', { result });
145+
mcpServer?.sendLoggingMessage({ level: 'info', data: { event: 'index_complete', result } });
146+
147+
let responseText = `Indexed ${result.indexed} files, skipped ${result.skipped} files, cleaned up ${result.deleted} deleted files, ${result.failed} failed`;
148+
149+
if (result.errors.length > 0) {
150+
responseText += `\nErrors: [\n`;
151+
result.errors.forEach(error => {
152+
responseText += ` '${error}'\n`;
153+
});
154+
responseText += `]`;
155+
}
156+
157+
return {
158+
content: [
159+
{
160+
type: 'text',
161+
text: responseText
162+
}
163+
]
164+
};
165+
} catch (error) {
166+
const errorMessage = error instanceof Error ? error.message : String(error);
167+
log('error', 'Index error', { error: errorMessage, directories: paths });
168+
mcpServer?.sendLoggingMessage({ level: 'error', data: { event: 'index_error', error: errorMessage } });
169+
throw new Error(
170+
`Indexing failed for ${paths.join(', ')}. Verify the directory exists and is readable. Use 'server_info' to check current status.`
171+
);
103172
} finally {
104-
sqlite.close();
105-
}
106-
107-
let responseText = `Indexed ${result.indexed} files, skipped ${result.skipped} files, cleaned up ${result.deleted} deleted files, ${result.failed} failed`;
108-
109-
if (result.errors.length > 0) {
110-
responseText += `\nErrors: [\n`;
111-
result.errors.forEach(error => {
112-
responseText += ` '${error}'\n`;
113-
});
114-
responseText += `]`;
173+
resolveIndexing!();
174+
for (const dirPath of paths) {
175+
indexingMutex.delete(dirPath);
176+
}
115177
}
116-
117-
return {
118-
content: [
119-
{
120-
type: 'text',
121-
text: responseText
122-
}
123-
]
124-
};
125178
}
126179

127180
async function validateWorkspace(workspace?: string): Promise<{ workspace?: string; message?: string }> {
@@ -195,16 +248,26 @@ export async function handleGetContentTool(args: unknown, config?: Config): Prom
195248
await ensureIndexedDirsCache(resolvedConfig);
196249
validatePathWithinIndexedDirs(args.file_path, indexedDirsCache);
197250

198-
const content = await getFileContent(args.file_path, args.chunks);
199-
200-
return {
201-
content: [
202-
{
203-
type: 'text',
204-
text: content
205-
}
206-
]
207-
};
251+
try {
252+
const content = await getFileContent(args.file_path, args.chunks);
253+
254+
return {
255+
content: [
256+
{
257+
type: 'text',
258+
text: content
259+
}
260+
]
261+
};
262+
} catch (error) {
263+
const msg = error instanceof Error ? error.message : String(error);
264+
if (msg.includes('ENOENT') || msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no such file')) {
265+
throw new Error(
266+
`File not found: ${args.file_path}. The file may have been moved or deleted. Use 'search' to find similar content.`
267+
);
268+
}
269+
throw error;
270+
}
208271
}
209272

210273
export async function handleGetChunkTool(args: unknown, config?: Config): Promise<CallToolResult> {
@@ -217,16 +280,26 @@ export async function handleGetChunkTool(args: unknown, config?: Config): Promis
217280
await ensureIndexedDirsCache(resolvedConfig);
218281
validatePathWithinIndexedDirs(args.file_path, indexedDirsCache);
219282

220-
const content = await getChunkContent(args.file_path, args.chunk_id);
221-
222-
return {
223-
content: [
224-
{
225-
type: 'text',
226-
text: content
227-
}
228-
]
229-
};
283+
try {
284+
const content = await getChunkContent(args.file_path, args.chunk_id);
285+
286+
return {
287+
content: [
288+
{
289+
type: 'text',
290+
text: content
291+
}
292+
]
293+
};
294+
} catch (error) {
295+
const msg = error instanceof Error ? error.message : String(error);
296+
if (msg.includes('ENOENT') || msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no such file')) {
297+
throw new Error(
298+
`File not found: ${args.file_path}. The file may have been moved or deleted. Use 'search' to find similar content.`
299+
);
300+
}
301+
throw error;
302+
}
230303
}
231304

232305
export async function handleServerInfoTool(version: string): Promise<CallToolResult> {
@@ -248,6 +321,7 @@ export async function handleServerInfoTool(version: string): Promise<CallToolRes
248321

249322
export function formatErrorResponse(error: unknown): CallToolResult {
250323
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
324+
log('error', 'Tool error', { error: errorMessage });
251325
return {
252326
content: [
253327
{

src/mcp.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import { readFileSync } from 'fs';
99
import { join, dirname } from 'path';
1010
import { fileURLToPath } from 'url';
1111
import { Config } from './config.js';
12-
import {
13-
handleIndexTool,
14-
handleSearchTool,
15-
handleSimilarFilesTool,
16-
handleGetContentTool,
17-
handleGetChunkTool,
12+
import { closeAllStorage } from './storage.js';
13+
import { initLogLevel, log } from './logger.js';
14+
import {
15+
handleIndexTool,
16+
handleSearchTool,
17+
handleSimilarFilesTool,
18+
handleGetContentTool,
19+
handleGetChunkTool,
1820
handleServerInfoTool,
19-
formatErrorResponse
21+
formatErrorResponse,
22+
setMcpServer
2023
} from './mcp-handlers.js';
2124

2225
// Read version from package.json
@@ -296,18 +299,23 @@ Returns server version, indexing statistics, directory list, workspace informati
296299
];
297300

298301
export async function startMcpServer(config: Config): Promise<void> {
302+
initLogLevel();
303+
299304
const server = new Server(
300305
{
301306
name: 'directory-indexer',
302307
version: VERSION
303308
},
304309
{
305310
capabilities: {
306-
tools: {}
311+
tools: {},
312+
logging: {}
307313
}
308314
}
309315
);
310316

317+
setMcpServer(server);
318+
311319
server.setRequestHandler(ListToolsRequestSchema, async () => {
312320
return {
313321
tools: MCP_TOOLS
@@ -347,8 +355,19 @@ export async function startMcpServer(config: Config): Promise<void> {
347355

348356
const transport = new StdioServerTransport();
349357
await server.connect(transport);
350-
358+
359+
// Graceful shutdown: close all SQLite connections before exiting
360+
const cleanup = () => {
361+
log('info', 'Shutting down MCP server');
362+
closeAllStorage();
363+
process.exit(0);
364+
};
365+
process.on('SIGTERM', cleanup);
366+
process.on('SIGINT', cleanup);
367+
351368
if (config.verbose) {
352369
console.error('MCP server started successfully');
353370
}
371+
372+
log('info', 'MCP server started', { version: VERSION });
354373
}

src/storage.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@ import { Config } from './config.js';
33
import { FileInfo, ChunkInfo, ensureDirectory } from './utils.js';
44
import { dirname } from 'path';
55

6+
// Registry of all open SQLiteStorage instances for graceful shutdown
7+
const openStorageInstances = new Set<SQLiteStorage>();
8+
9+
/**
10+
* Close all open SQLiteStorage instances. Called during graceful shutdown.
11+
*/
12+
export function closeAllStorage(): void {
13+
for (const instance of openStorageInstances) {
14+
try {
15+
instance.close();
16+
} catch {
17+
// Ignore errors during shutdown cleanup
18+
}
19+
}
20+
}
21+
622
export interface DirectoryRecord {
723
id: number;
824
path: string;
@@ -291,6 +307,7 @@ export class SQLiteStorage {
291307

292308
constructor(private config: Config) {
293309
this.db = this.initializeDatabase();
310+
openStorageInstances.add(this);
294311
}
295312

296313
private initializeDatabase(): Database.Database {
@@ -441,6 +458,7 @@ export class SQLiteStorage {
441458
}
442459

443460
close(): void {
461+
openStorageInstances.delete(this);
444462
this.db.close();
445463
}
446464
}

0 commit comments

Comments
 (0)