Skip to content

Commit 0e52b34

Browse files
Copilotdata-douser
andcommitted
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 e540330 commit 0e52b34

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
@@ -184855,9 +184855,8 @@ import { createHash as createHash2 } from "crypto";
184855184855
// src/lib/query-results-evaluator.ts
184856184856
init_cli_executor();
184857184857
init_logger();
184858-
import { fstatSync, openSync, readFileSync as readFileSync4, writeFileSync } from "fs";
184858+
import { closeSync, fstatSync, mkdirSync as mkdirSync4, openSync, readFileSync as readFileSync4, writeFileSync } from "fs";
184859184859
import { dirname as dirname3, isAbsolute as isAbsolute3 } from "path";
184860-
import { mkdirSync as mkdirSync4 } from "fs";
184861184860
var BUILT_IN_EVALUATORS = {
184862184861
"json-decode": "JSON format decoder for query results",
184863184862
"csv-decode": "CSV format decoder for query results",
@@ -184868,12 +184867,18 @@ var metadataCache = /* @__PURE__ */ new Map();
184868184867
async function extractQueryMetadata(queryPath) {
184869184868
try {
184870184869
const fd = openSync(queryPath, "r");
184871-
const mtime = fstatSync(fd).mtimeMs;
184872-
const cached2 = metadataCache.get(queryPath);
184873-
if (cached2 && cached2.mtime === mtime) {
184874-
return cached2.metadata;
184870+
let queryContent;
184871+
let mtime;
184872+
try {
184873+
mtime = fstatSync(fd).mtimeMs;
184874+
const cached2 = metadataCache.get(queryPath);
184875+
if (cached2 && cached2.mtime === mtime) {
184876+
return cached2.metadata;
184877+
}
184878+
queryContent = readFileSync4(fd, "utf-8");
184879+
} finally {
184880+
closeSync(fd);
184875184881
}
184876-
const queryContent = readFileSync4(fd, "utf-8");
184877184882
const metadata = {};
184878184883
const kindMatch = queryContent.match(/@kind\s+([^\s]+)/);
184879184884
if (kindMatch) metadata.kind = kindMatch[1];
@@ -185134,7 +185139,7 @@ import { randomUUID as randomUUID2 } from "crypto";
185134185139
// src/lib/sqlite-store.ts
185135185140
var import_sql_asm = __toESM(require_sql_asm(), 1);
185136185141
init_logger();
185137-
import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync2 } from "fs";
185142+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
185138185143
import { join as join8 } from "path";
185139185144
var SqliteStore = class _SqliteStore {
185140185145
db = null;
@@ -185202,6 +185207,32 @@ var SqliteStore = class _SqliteStore {
185202185207
CREATE INDEX IF NOT EXISTS idx_annotations_category_entity
185203185208
ON annotations (category, entity_key);
185204185209
`);
185210+
this.exec(`
185211+
CREATE VIRTUAL TABLE IF NOT EXISTS annotations_fts
185212+
USING fts4(tokenize=unicode61, content, label, metadata);
185213+
`);
185214+
this.exec(`
185215+
CREATE TRIGGER IF NOT EXISTS annotations_ai
185216+
AFTER INSERT ON annotations BEGIN
185217+
INSERT INTO annotations_fts(rowid, content, label, metadata)
185218+
VALUES (new.id, new.content, new.label, new.metadata);
185219+
END;
185220+
`);
185221+
this.exec(`
185222+
CREATE TRIGGER IF NOT EXISTS annotations_ad
185223+
AFTER DELETE ON annotations BEGIN
185224+
DELETE FROM annotations_fts WHERE rowid = old.id;
185225+
END;
185226+
`);
185227+
this.exec(`
185228+
CREATE TRIGGER IF NOT EXISTS annotations_au
185229+
AFTER UPDATE ON annotations BEGIN
185230+
DELETE FROM annotations_fts WHERE rowid = old.id;
185231+
INSERT INTO annotations_fts(rowid, content, label, metadata)
185232+
VALUES (new.id, new.content, new.label, new.metadata);
185233+
END;
185234+
`);
185235+
this.backfillAnnotationsFts();
185205185236
this.exec(`
185206185237
CREATE TABLE IF NOT EXISTS query_result_cache (
185207185238
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -185250,6 +185281,24 @@ var SqliteStore = class _SqliteStore {
185250185281
}
185251185282
this.dirty = true;
185252185283
}
185284+
/**
185285+
* Backfill the FTS index for any annotations rows that were inserted before
185286+
* the FTS table existed (schema migration). Compares row counts and rebuilds
185287+
* the entire FTS index when there is a mismatch.
185288+
*/
185289+
backfillAnnotationsFts() {
185290+
const db = this.ensureDb();
185291+
const ftsCountResult = db.exec("SELECT COUNT(*) FROM annotations_fts");
185292+
const annCountResult = db.exec("SELECT COUNT(*) FROM annotations");
185293+
const ftsCount = ftsCountResult[0]?.values[0][0] ?? 0;
185294+
const annCount = annCountResult[0]?.values[0][0] ?? 0;
185295+
if (ftsCount < annCount) {
185296+
db.run("DELETE FROM annotations_fts");
185297+
db.run(
185298+
"INSERT INTO annotations_fts(rowid, content, label, metadata) SELECT id, content, label, metadata FROM annotations"
185299+
);
185300+
}
185301+
}
185253185302
/**
185254185303
* Get the number of rows modified by the last INSERT/UPDATE/DELETE.
185255185304
*/
@@ -185274,7 +185323,15 @@ var SqliteStore = class _SqliteStore {
185274185323
const buffer = Buffer.from(data);
185275185324
const tmpPath = this.dbPath + ".tmp";
185276185325
writeFileSync2(tmpPath, buffer);
185277-
renameSync(tmpPath, this.dbPath);
185326+
try {
185327+
renameSync(tmpPath, this.dbPath);
185328+
} catch {
185329+
writeFileSync2(this.dbPath, buffer);
185330+
try {
185331+
unlinkSync(tmpPath);
185332+
} catch {
185333+
}
185334+
}
185278185335
this.dirty = false;
185279185336
}
185280185337
/**
@@ -185425,8 +185482,8 @@ var SqliteStore = class _SqliteStore {
185425185482
params.$entity_key_prefix = filter.entityKeyPrefix + "%";
185426185483
}
185427185484
if (filter?.search) {
185428-
conditions.push("(content LIKE $search OR metadata LIKE $search OR label LIKE $search)");
185429-
params.$search = "%" + filter.search + "%";
185485+
conditions.push("id IN (SELECT rowid FROM annotations_fts WHERE annotations_fts MATCH $search)");
185486+
params.$search = filter.search;
185430185487
}
185431185488
let sql = "SELECT * FROM annotations";
185432185489
if (conditions.length > 0) {
@@ -192094,7 +192151,7 @@ var codeqlResolveTestsTool = {
192094192151
};
192095192152

192096192153
// src/tools/codeql/search-ql-code.ts
192097-
import { closeSync, createReadStream as createReadStream3, fstatSync as fstatSync2, lstatSync, openSync as openSync2, readdirSync as readdirSync8, readFileSync as readFileSync12, realpathSync } from "fs";
192154+
import { closeSync as closeSync2, createReadStream as createReadStream3, fstatSync as fstatSync2, lstatSync, openSync as openSync2, readdirSync as readdirSync8, readFileSync as readFileSync12, realpathSync } from "fs";
192098192155
import { basename as basename8, extname as extname2, join as join19, resolve as resolve9 } from "path";
192099192156
import { createInterface as createInterface3 } from "readline";
192100192157
init_logger();
@@ -192160,7 +192217,7 @@ async function searchFile(filePath, regex, contextLines, maxCollect) {
192160192217
size = fstatSync2(fd).size;
192161192218
} catch {
192162192219
try {
192163-
closeSync(fd);
192220+
closeSync2(fd);
192164192221
} catch {
192165192222
}
192166192223
return { matches: [], totalCount: 0 };
@@ -192173,13 +192230,13 @@ async function searchFile(filePath, regex, contextLines, maxCollect) {
192173192230
content = readFileSync12(fd, "utf-8");
192174192231
} catch {
192175192232
try {
192176-
closeSync(fd);
192233+
closeSync2(fd);
192177192234
} catch {
192178192235
}
192179192236
return { matches: [], totalCount: 0 };
192180192237
}
192181192238
try {
192182-
closeSync(fd);
192239+
closeSync2(fd);
192183192240
} catch {
192184192241
}
192185192242
const lines = content.replace(/\r\n/g, "\n").split("\n");
@@ -192227,7 +192284,7 @@ async function searchFileStreaming(filePath, regex, contextLines, maxCollect, fd
192227192284
} catch {
192228192285
if (fd !== void 0) {
192229192286
try {
192230-
closeSync(fd);
192287+
closeSync2(fd);
192231192288
} catch {
192232192289
}
192233192290
}
@@ -195606,14 +195663,25 @@ function registerQueryResultsCacheRetrieveTool(server) {
195606195663
if (!subset2) {
195607195664
return { content: [{ type: "text", text: `Cached content not available for key: ${cacheKey2}` }] };
195608195665
}
195666+
let parsedResults;
195667+
try {
195668+
parsedResults = JSON.parse(subset2.content);
195669+
} catch {
195670+
return {
195671+
content: [{
195672+
type: "text",
195673+
text: subset2.content
195674+
}]
195675+
};
195676+
}
195609195677
return {
195610195678
content: [{
195611195679
type: "text",
195612195680
text: JSON.stringify({
195613195681
totalResults: subset2.totalResults,
195614195682
returnedResults: subset2.returnedResults,
195615195683
truncated: subset2.truncated,
195616-
results: JSON.parse(subset2.content)
195684+
results: parsedResults
195617195685
}, null, 2)
195618195686
}]
195619195687
};

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)