Skip to content

Commit 5e57b03

Browse files
Copilotdata-douser
andauthored
Address PR #169 review: FTS4 search, FD leak fix, Windows renameSync fallback, JSON.parse fallback
Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/7ff5a5d7-9ac1-4964-8b54-e233d7253301 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com>
1 parent 99be0ca commit 5e57b03

File tree

6 files changed

+195
-34
lines changed

6 files changed

+195
-34
lines changed

server/dist/codeql-development-mcp-server.js

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184843,9 +184843,8 @@ import { createHash as createHash2 } from "crypto";
184843184843
// src/lib/query-results-evaluator.ts
184844184844
init_cli_executor();
184845184845
init_logger();
184846-
import { fstatSync, openSync, readFileSync as readFileSync4, writeFileSync } from "fs";
184846+
import { closeSync, fstatSync, mkdirSync as mkdirSync4, openSync, readFileSync as readFileSync4, writeFileSync } from "fs";
184847184847
import { dirname as dirname3, isAbsolute as isAbsolute3 } from "path";
184848-
import { mkdirSync as mkdirSync4 } from "fs";
184849184848
var BUILT_IN_EVALUATORS = {
184850184849
"json-decode": "JSON format decoder for query results",
184851184850
"csv-decode": "CSV format decoder for query results",
@@ -184856,12 +184855,18 @@ var metadataCache = /* @__PURE__ */ new Map();
184856184855
async function extractQueryMetadata(queryPath) {
184857184856
try {
184858184857
const fd = openSync(queryPath, "r");
184859-
const mtime = fstatSync(fd).mtimeMs;
184860-
const cached2 = metadataCache.get(queryPath);
184861-
if (cached2 && cached2.mtime === mtime) {
184862-
return cached2.metadata;
184858+
let queryContent;
184859+
let mtime;
184860+
try {
184861+
mtime = fstatSync(fd).mtimeMs;
184862+
const cached2 = metadataCache.get(queryPath);
184863+
if (cached2 && cached2.mtime === mtime) {
184864+
return cached2.metadata;
184865+
}
184866+
queryContent = readFileSync4(fd, "utf-8");
184867+
} finally {
184868+
closeSync(fd);
184863184869
}
184864-
const queryContent = readFileSync4(fd, "utf-8");
184865184870
const metadata = {};
184866184871
const kindMatch = queryContent.match(/@kind\s+([^\s]+)/);
184867184872
if (kindMatch) metadata.kind = kindMatch[1];
@@ -185122,7 +185127,7 @@ import { randomUUID as randomUUID2 } from "crypto";
185122185127
// src/lib/sqlite-store.ts
185123185128
var import_sql_asm = __toESM(require_sql_asm(), 1);
185124185129
init_logger();
185125-
import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync2 } from "fs";
185130+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
185126185131
import { join as join8 } from "path";
185127185132
var SqliteStore = class _SqliteStore {
185128185133
db = null;
@@ -185190,6 +185195,32 @@ var SqliteStore = class _SqliteStore {
185190185195
CREATE INDEX IF NOT EXISTS idx_annotations_category_entity
185191185196
ON annotations (category, entity_key);
185192185197
`);
185198+
this.exec(`
185199+
CREATE VIRTUAL TABLE IF NOT EXISTS annotations_fts
185200+
USING fts4(tokenize=unicode61, content, label, metadata);
185201+
`);
185202+
this.exec(`
185203+
CREATE TRIGGER IF NOT EXISTS annotations_ai
185204+
AFTER INSERT ON annotations BEGIN
185205+
INSERT INTO annotations_fts(rowid, content, label, metadata)
185206+
VALUES (new.id, new.content, new.label, new.metadata);
185207+
END;
185208+
`);
185209+
this.exec(`
185210+
CREATE TRIGGER IF NOT EXISTS annotations_ad
185211+
AFTER DELETE ON annotations BEGIN
185212+
DELETE FROM annotations_fts WHERE rowid = old.id;
185213+
END;
185214+
`);
185215+
this.exec(`
185216+
CREATE TRIGGER IF NOT EXISTS annotations_au
185217+
AFTER UPDATE ON annotations BEGIN
185218+
DELETE FROM annotations_fts WHERE rowid = old.id;
185219+
INSERT INTO annotations_fts(rowid, content, label, metadata)
185220+
VALUES (new.id, new.content, new.label, new.metadata);
185221+
END;
185222+
`);
185223+
this.backfillAnnotationsFts();
185193185224
this.exec(`
185194185225
CREATE TABLE IF NOT EXISTS query_result_cache (
185195185226
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -185238,6 +185269,24 @@ var SqliteStore = class _SqliteStore {
185238185269
}
185239185270
this.dirty = true;
185240185271
}
185272+
/**
185273+
* Backfill the FTS index for any annotations rows that were inserted before
185274+
* the FTS table existed (schema migration). Compares row counts and rebuilds
185275+
* the entire FTS index when there is a mismatch.
185276+
*/
185277+
backfillAnnotationsFts() {
185278+
const db = this.ensureDb();
185279+
const ftsCountResult = db.exec("SELECT COUNT(*) FROM annotations_fts");
185280+
const annCountResult = db.exec("SELECT COUNT(*) FROM annotations");
185281+
const ftsCount = ftsCountResult[0]?.values[0][0] ?? 0;
185282+
const annCount = annCountResult[0]?.values[0][0] ?? 0;
185283+
if (ftsCount < annCount) {
185284+
db.run("DELETE FROM annotations_fts");
185285+
db.run(
185286+
"INSERT INTO annotations_fts(rowid, content, label, metadata) SELECT id, content, label, metadata FROM annotations"
185287+
);
185288+
}
185289+
}
185241185290
/**
185242185291
* Get the number of rows modified by the last INSERT/UPDATE/DELETE.
185243185292
*/
@@ -185262,7 +185311,15 @@ var SqliteStore = class _SqliteStore {
185262185311
const buffer = Buffer.from(data);
185263185312
const tmpPath = this.dbPath + ".tmp";
185264185313
writeFileSync2(tmpPath, buffer);
185265-
renameSync(tmpPath, this.dbPath);
185314+
try {
185315+
renameSync(tmpPath, this.dbPath);
185316+
} catch {
185317+
writeFileSync2(this.dbPath, buffer);
185318+
try {
185319+
unlinkSync(tmpPath);
185320+
} catch {
185321+
}
185322+
}
185266185323
this.dirty = false;
185267185324
}
185268185325
/**
@@ -185413,8 +185470,8 @@ var SqliteStore = class _SqliteStore {
185413185470
params.$entity_key_prefix = filter.entityKeyPrefix + "%";
185414185471
}
185415185472
if (filter?.search) {
185416-
conditions.push("(content LIKE $search OR metadata LIKE $search OR label LIKE $search)");
185417-
params.$search = "%" + filter.search + "%";
185473+
conditions.push("id IN (SELECT rowid FROM annotations_fts WHERE annotations_fts MATCH $search)");
185474+
params.$search = filter.search;
185418185475
}
185419185476
let sql = "SELECT * FROM annotations";
185420185477
if (conditions.length > 0) {
@@ -192082,7 +192139,7 @@ var codeqlResolveTestsTool = {
192082192139
};
192083192140

192084192141
// src/tools/codeql/search-ql-code.ts
192085-
import { closeSync, createReadStream as createReadStream3, fstatSync as fstatSync2, lstatSync, openSync as openSync2, readdirSync as readdirSync8, readFileSync as readFileSync12, realpathSync } from "fs";
192142+
import { closeSync as closeSync2, createReadStream as createReadStream3, fstatSync as fstatSync2, lstatSync, openSync as openSync2, readdirSync as readdirSync8, readFileSync as readFileSync12, realpathSync } from "fs";
192086192143
import { basename as basename8, extname as extname2, join as join19, resolve as resolve9 } from "path";
192087192144
import { createInterface as createInterface3 } from "readline";
192088192145
init_logger();
@@ -192148,7 +192205,7 @@ async function searchFile(filePath, regex, contextLines, maxCollect) {
192148192205
size = fstatSync2(fd).size;
192149192206
} catch {
192150192207
try {
192151-
closeSync(fd);
192208+
closeSync2(fd);
192152192209
} catch {
192153192210
}
192154192211
return { matches: [], totalCount: 0 };
@@ -192161,13 +192218,13 @@ async function searchFile(filePath, regex, contextLines, maxCollect) {
192161192218
content = readFileSync12(fd, "utf-8");
192162192219
} catch {
192163192220
try {
192164-
closeSync(fd);
192221+
closeSync2(fd);
192165192222
} catch {
192166192223
}
192167192224
return { matches: [], totalCount: 0 };
192168192225
}
192169192226
try {
192170-
closeSync(fd);
192227+
closeSync2(fd);
192171192228
} catch {
192172192229
}
192173192230
const lines = content.replace(/\r\n/g, "\n").split("\n");
@@ -192215,7 +192272,7 @@ async function searchFileStreaming(filePath, regex, contextLines, maxCollect, fd
192215192272
} catch {
192216192273
if (fd !== void 0) {
192217192274
try {
192218-
closeSync(fd);
192275+
closeSync2(fd);
192219192276
} catch {
192220192277
}
192221192278
}
@@ -195594,14 +195651,25 @@ function registerQueryResultsCacheRetrieveTool(server) {
195594195651
if (!subset2) {
195595195652
return { content: [{ type: "text", text: `Cached content not available for key: ${cacheKey2}` }] };
195596195653
}
195654+
let parsedResults;
195655+
try {
195656+
parsedResults = JSON.parse(subset2.content);
195657+
} catch {
195658+
return {
195659+
content: [{
195660+
type: "text",
195661+
text: subset2.content
195662+
}]
195663+
};
195664+
}
195597195665
return {
195598195666
content: [{
195599195667
type: "text",
195600195668
text: JSON.stringify({
195601195669
totalResults: subset2.totalResults,
195602195670
returnedResults: subset2.returnedResults,
195603195671
truncated: subset2.truncated,
195604-
results: JSON.parse(subset2.content)
195672+
results: parsedResults
195605195673
}, null, 2)
195606195674
}]
195607195675
};

server/dist/codeql-development-mcp-server.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/src/lib/query-results-evaluator.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44

55
import { executeCodeQLCommand } from './cli-executor';
66
import { logger } from '../utils/logger';
7-
import { fstatSync, openSync, readFileSync, writeFileSync } from 'fs';
7+
import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'fs';
88
import { dirname, isAbsolute } from 'path';
9-
import { mkdirSync } from 'fs';
109

1110
export interface QueryEvaluationResult {
1211
success: boolean;
@@ -51,13 +50,18 @@ export async function extractQueryMetadata(queryPath: string): Promise<QueryMeta
5150
try {
5251
// Open once, then fstat + read via the fd to avoid TOCTOU race (CWE-367).
5352
const fd = openSync(queryPath, 'r');
54-
const mtime = fstatSync(fd).mtimeMs;
55-
const cached = metadataCache.get(queryPath);
56-
if (cached && cached.mtime === mtime) {
57-
return cached.metadata;
53+
let queryContent: string;
54+
let mtime: number;
55+
try {
56+
mtime = fstatSync(fd).mtimeMs;
57+
const cached = metadataCache.get(queryPath);
58+
if (cached && cached.mtime === mtime) {
59+
return cached.metadata;
60+
}
61+
queryContent = readFileSync(fd, 'utf-8');
62+
} finally {
63+
closeSync(fd);
5864
}
59-
60-
const queryContent = readFileSync(fd, 'utf-8');
6165
const metadata: QueryMetadata = {};
6266

6367
// Extract metadata from comments using regex patterns

server/src/lib/sqlite-store.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import initSqlJs from 'sql.js/dist/sql-asm.js';
1515
import type { Database as SqlJsDatabase } from 'sql.js';
16-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
16+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
1717
import { join } from 'path';
1818
import { logger } from '../utils/logger';
1919

@@ -129,6 +129,41 @@ export class SqliteStore {
129129
ON annotations (category, entity_key);
130130
`);
131131

132+
// FTS4 virtual table for full-text search over annotation text fields.
133+
// The unicode61 tokenizer provides case-insensitive, accent-insensitive matching.
134+
this.exec(`
135+
CREATE VIRTUAL TABLE IF NOT EXISTS annotations_fts
136+
USING fts4(tokenize=unicode61, content, label, metadata);
137+
`);
138+
139+
// Triggers keep the FTS index in sync with the annotations table.
140+
this.exec(`
141+
CREATE TRIGGER IF NOT EXISTS annotations_ai
142+
AFTER INSERT ON annotations BEGIN
143+
INSERT INTO annotations_fts(rowid, content, label, metadata)
144+
VALUES (new.id, new.content, new.label, new.metadata);
145+
END;
146+
`);
147+
148+
this.exec(`
149+
CREATE TRIGGER IF NOT EXISTS annotations_ad
150+
AFTER DELETE ON annotations BEGIN
151+
DELETE FROM annotations_fts WHERE rowid = old.id;
152+
END;
153+
`);
154+
155+
this.exec(`
156+
CREATE TRIGGER IF NOT EXISTS annotations_au
157+
AFTER UPDATE ON annotations BEGIN
158+
DELETE FROM annotations_fts WHERE rowid = old.id;
159+
INSERT INTO annotations_fts(rowid, content, label, metadata)
160+
VALUES (new.id, new.content, new.label, new.metadata);
161+
END;
162+
`);
163+
164+
// Migration: backfill FTS for any existing rows that pre-date the FTS table.
165+
this.backfillAnnotationsFts();
166+
132167
this.exec(`
133168
CREATE TABLE IF NOT EXISTS query_result_cache (
134169
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -184,6 +219,27 @@ export class SqliteStore {
184219
this.dirty = true;
185220
}
186221

222+
/**
223+
* Backfill the FTS index for any annotations rows that were inserted before
224+
* the FTS table existed (schema migration). Compares row counts and rebuilds
225+
* the entire FTS index when there is a mismatch.
226+
*/
227+
private backfillAnnotationsFts(): void {
228+
const db = this.ensureDb();
229+
const ftsCountResult = db.exec('SELECT COUNT(*) FROM annotations_fts');
230+
const annCountResult = db.exec('SELECT COUNT(*) FROM annotations');
231+
const ftsCount = (ftsCountResult[0]?.values[0][0] as number) ?? 0;
232+
const annCount = (annCountResult[0]?.values[0][0] as number) ?? 0;
233+
234+
if (ftsCount < annCount) {
235+
// Clear and fully rebuild to avoid duplicate entries.
236+
db.run('DELETE FROM annotations_fts');
237+
db.run(
238+
'INSERT INTO annotations_fts(rowid, content, label, metadata) SELECT id, content, label, metadata FROM annotations',
239+
);
240+
}
241+
}
242+
187243
/**
188244
* Get the number of rows modified by the last INSERT/UPDATE/DELETE.
189245
*/
@@ -209,7 +265,14 @@ export class SqliteStore {
209265
const buffer = Buffer.from(data);
210266
const tmpPath = this.dbPath + '.tmp';
211267
writeFileSync(tmpPath, buffer);
212-
renameSync(tmpPath, this.dbPath);
268+
try {
269+
renameSync(tmpPath, this.dbPath);
270+
} catch {
271+
// On some Windows configurations renameSync can fail if the destination
272+
// is locked; fall back to a direct overwrite and clean up the temp file.
273+
writeFileSync(this.dbPath, buffer);
274+
try { unlinkSync(tmpPath); } catch { /* ignore cleanup failure */ }
275+
}
213276
this.dirty = false;
214277
}
215278

@@ -383,8 +446,10 @@ export class SqliteStore {
383446
params.$entity_key_prefix = filter.entityKeyPrefix + '%';
384447
}
385448
if (filter?.search) {
386-
conditions.push('(content LIKE $search OR metadata LIKE $search OR label LIKE $search)');
387-
params.$search = '%' + filter.search + '%';
449+
// Use the FTS4 index for efficient, case-insensitive full-text search
450+
// across content, label, and metadata fields.
451+
conditions.push('id IN (SELECT rowid FROM annotations_fts WHERE annotations_fts MATCH $search)');
452+
params.$search = filter.search;
388453
}
389454

390455
let sql = 'SELECT * FROM annotations';

server/src/tools/cache-tools.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,26 @@ function registerQueryResultsCacheRetrieveTool(server: McpServer): void {
109109
if (!subset) {
110110
return { content: [{ type: 'text' as const, text: `Cached content not available for key: ${cacheKey}` }] };
111111
}
112+
let parsedResults: unknown;
113+
try {
114+
parsedResults = JSON.parse(subset.content);
115+
} catch {
116+
// getCacheSarifSubset fell back to plain-text content; return it as-is.
117+
return {
118+
content: [{
119+
type: 'text' as const,
120+
text: subset.content,
121+
}],
122+
};
123+
}
112124
return {
113125
content: [{
114126
type: 'text' as const,
115127
text: JSON.stringify({
116128
totalResults: subset.totalResults,
117129
returnedResults: subset.returnedResults,
118130
truncated: subset.truncated,
119-
results: JSON.parse(subset.content),
131+
results: parsedResults,
120132
}, null, 2),
121133
}],
122134
};

server/test/src/lib/sqlite-store.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ describe('SqliteStore', () => {
118118
expect(results[0].content).toBe('yes');
119119
});
120120

121-
it('should search annotations by content', () => {
121+
it('should full-text search annotations across content, label, and metadata', () => {
122122
store.createAnnotation('note', 'a', 'Found a vulnerability in auth');
123123
store.createAnnotation('note', 'b', 'No issues here');
124124
store.createAnnotation('note', 'c', null, null, '{"detail":"vulnerability in parser"}');
@@ -127,6 +127,18 @@ describe('SqliteStore', () => {
127127
expect(results).toHaveLength(2);
128128
});
129129

130+
it('should perform case-insensitive full-text search', () => {
131+
store.createAnnotation('note', 'x', 'SQL injection risk');
132+
store.createAnnotation('note', 'y', 'No problems');
133+
134+
const lower = store.listAnnotations({ search: 'sql' });
135+
const upper = store.listAnnotations({ search: 'SQL' });
136+
const mixed = store.listAnnotations({ search: 'Sql' });
137+
expect(lower).toHaveLength(1);
138+
expect(upper).toHaveLength(1);
139+
expect(mixed).toHaveLength(1);
140+
});
141+
130142
it('should support limit and offset', () => {
131143
for (let i = 0; i < 10; i++) {
132144
store.createAnnotation('note', `key-${i}`, `content ${i}`);

0 commit comments

Comments
 (0)