Skip to content

Commit c86c039

Browse files
Copilotdata-douser
andauthored
feat: add MCP prompt argument completions for VS Code UX
Add completable() wrappers to workflow prompt parameters so VS Code Copilot Chat shows auto-complete dropdowns for language, query paths, SARIF files, database paths, and pack roots. - Create prompt-completions.ts with completion providers for each parameter type (language enum, .ql/.qlref files, .sarif files, CodeQL databases, codeql-pack.yml directories) - Update all 14 workflow prompt registrations to use addCompletions() - Add comprehensive unit tests (35 tests covering all completers and the addCompletions utility) Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/e316ece4-06c5-45d7-8020-062e6ec67e39 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com>
1 parent b002962 commit c86c039

File tree

5 files changed

+989
-77
lines changed

5 files changed

+989
-77
lines changed

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

Lines changed: 230 additions & 59 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
/**
2+
* Prompt argument completion providers for VS Code UX.
3+
*
4+
* When workflow prompts are used as slash commands in VS Code Copilot Chat,
5+
* VS Code shows a dialog for each prompt argument. The MCP SDK's
6+
* `completable()` wrapper lets us provide auto-complete suggestions so that
7+
* users can pick values from a filtered dropdown instead of typing paths and
8+
* language names manually.
9+
*
10+
* Each completion callback receives the current user input and returns
11+
* matching suggestions. VS Code filters the list as the user types.
12+
*/
13+
14+
import { readdir } from 'fs/promises';
15+
import { join, relative, sep } from 'path';
16+
import { completable } from '@modelcontextprotocol/sdk/server/completable.js';
17+
import { z } from 'zod';
18+
import { getUserWorkspaceDir } from '../utils/package-paths';
19+
import { logger } from '../utils/logger';
20+
import { SUPPORTED_LANGUAGES } from './workflow-prompts';
21+
import { getDatabaseBaseDirs } from '../lib/discovery-config';
22+
23+
// ────────────────────────────────────────────────────────────────────────────
24+
// Completion callbacks
25+
// ────────────────────────────────────────────────────────────────────────────
26+
27+
/** Maximum number of completions to return for file-based lookups. */
28+
const MAX_FILE_COMPLETIONS = 50;
29+
30+
/** Maximum directory depth when scanning for files. */
31+
const MAX_SCAN_DEPTH = 8;
32+
33+
/**
34+
* Complete a `language` parameter by filtering SUPPORTED_LANGUAGES.
35+
*/
36+
export function completeLanguage(value: string): string[] {
37+
const lower = (value || '').toLowerCase();
38+
return [...SUPPORTED_LANGUAGES].filter(lang => lang.startsWith(lower));
39+
}
40+
41+
/**
42+
* Recursively find files matching given extensions under `dir`, up to
43+
* `maxDepth` levels deep. Returns paths relative to `baseDir`.
44+
*
45+
* Silently skips directories that cannot be read (permission errors, etc.).
46+
*/
47+
async function findFilesByExtension(
48+
dir: string,
49+
baseDir: string,
50+
extensions: string[],
51+
maxDepth: number,
52+
results: string[],
53+
): Promise<void> {
54+
if (maxDepth <= 0 || results.length >= MAX_FILE_COMPLETIONS) return;
55+
56+
let entries;
57+
try {
58+
entries = await readdir(dir, { withFileTypes: true });
59+
} catch {
60+
return; // skip unreadable directories
61+
}
62+
63+
for (const entry of entries) {
64+
if (results.length >= MAX_FILE_COMPLETIONS) break;
65+
66+
const fullPath = join(dir, entry.name);
67+
68+
if (entry.isDirectory()) {
69+
// Skip common non-CodeQL directories
70+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.tmp') {
71+
continue;
72+
}
73+
await findFilesByExtension(fullPath, baseDir, extensions, maxDepth - 1, results);
74+
} else if (entry.isFile()) {
75+
const lower = entry.name.toLowerCase();
76+
if (extensions.some(ext => lower.endsWith(ext))) {
77+
results.push(relative(baseDir, fullPath));
78+
}
79+
}
80+
}
81+
}
82+
83+
/**
84+
* Complete a `queryPath` parameter by finding `.ql` and `.qlref` files
85+
* in the workspace, filtered by the user's current input.
86+
*/
87+
export async function completeQueryPath(value: string): Promise<string[]> {
88+
const workspace = getUserWorkspaceDir();
89+
const results: string[] = [];
90+
91+
try {
92+
await findFilesByExtension(workspace, workspace, ['.ql', '.qlref'], MAX_SCAN_DEPTH, results);
93+
} catch (err) {
94+
logger.debug(`completeQueryPath scan error: ${err}`);
95+
}
96+
97+
const lower = (value || '').toLowerCase();
98+
const filtered = results
99+
.filter(p => p.toLowerCase().includes(lower))
100+
.sort();
101+
102+
return filtered.slice(0, MAX_FILE_COMPLETIONS);
103+
}
104+
105+
/**
106+
* Complete a `sarifPath` parameter by finding `.sarif` and `.sarif.json`
107+
* files in the workspace.
108+
*/
109+
export async function completeSarifPath(value: string): Promise<string[]> {
110+
const workspace = getUserWorkspaceDir();
111+
const results: string[] = [];
112+
113+
try {
114+
await findFilesByExtension(workspace, workspace, ['.sarif', '.sarif.json'], MAX_SCAN_DEPTH, results);
115+
} catch (err) {
116+
logger.debug(`completeSarifPath scan error: ${err}`);
117+
}
118+
119+
const lower = (value || '').toLowerCase();
120+
const filtered = results
121+
.filter(p => p.toLowerCase().includes(lower))
122+
.sort();
123+
124+
return filtered.slice(0, MAX_FILE_COMPLETIONS);
125+
}
126+
127+
/**
128+
* Complete a `database` / `databasePath` parameter by listing CodeQL
129+
* database directories from configured base dirs.
130+
*/
131+
export async function completeDatabasePath(value: string): Promise<string[]> {
132+
const baseDirs = getDatabaseBaseDirs();
133+
const results: string[] = [];
134+
135+
for (const baseDir of baseDirs) {
136+
if (results.length >= MAX_FILE_COMPLETIONS) break;
137+
138+
let entries;
139+
try {
140+
entries = await readdir(baseDir, { withFileTypes: true });
141+
} catch {
142+
continue;
143+
}
144+
145+
for (const entry of entries) {
146+
if (results.length >= MAX_FILE_COMPLETIONS) break;
147+
if (entry.isDirectory()) {
148+
results.push(join(baseDir, entry.name));
149+
}
150+
}
151+
}
152+
153+
// Also check the workspace for databases
154+
const workspace = getUserWorkspaceDir();
155+
try {
156+
const wsEntries = await readdir(workspace, { withFileTypes: true });
157+
for (const entry of wsEntries) {
158+
if (results.length >= MAX_FILE_COMPLETIONS) break;
159+
if (entry.isDirectory() && entry.name.endsWith('-db')) {
160+
results.push(join(workspace, entry.name));
161+
}
162+
}
163+
} catch {
164+
// ignore
165+
}
166+
167+
const lower = (value || '').toLowerCase();
168+
const filtered = results
169+
.filter(p => {
170+
// Match against the full path or just the basename
171+
const lastSeg = p.split(sep).pop() ?? '';
172+
return p.toLowerCase().includes(lower) || lastSeg.toLowerCase().includes(lower);
173+
})
174+
.sort();
175+
176+
return filtered.slice(0, MAX_FILE_COMPLETIONS);
177+
}
178+
179+
/**
180+
* Complete a `workspaceUri` / `packRoot` parameter by finding directories
181+
* that contain a `codeql-pack.yml` file in the workspace.
182+
*/
183+
export async function completePackRoot(value: string): Promise<string[]> {
184+
const workspace = getUserWorkspaceDir();
185+
const results: string[] = [];
186+
187+
async function scan(dir: string, depth: number): Promise<void> {
188+
if (depth <= 0 || results.length >= MAX_FILE_COMPLETIONS) return;
189+
190+
let entries;
191+
try {
192+
entries = await readdir(dir, { withFileTypes: true });
193+
} catch {
194+
return;
195+
}
196+
197+
// Check if this directory has a codeql-pack.yml
198+
const hasPackYml = entries.some(
199+
e => e.isFile() && e.name === 'codeql-pack.yml',
200+
);
201+
if (hasPackYml) {
202+
results.push(relative(workspace, dir) || '.');
203+
}
204+
205+
for (const entry of entries) {
206+
if (results.length >= MAX_FILE_COMPLETIONS) break;
207+
if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '.git' && entry.name !== '.tmp') {
208+
await scan(join(dir, entry.name), depth - 1);
209+
}
210+
}
211+
}
212+
213+
try {
214+
await scan(workspace, MAX_SCAN_DEPTH);
215+
} catch (err) {
216+
logger.debug(`completePackRoot scan error: ${err}`);
217+
}
218+
219+
const lower = (value || '').toLowerCase();
220+
const filtered = results
221+
.filter(p => p.toLowerCase().includes(lower))
222+
.sort();
223+
224+
return filtered.slice(0, MAX_FILE_COMPLETIONS);
225+
}
226+
227+
// ────────────────────────────────────────────────────────────────────────────
228+
// Shape enhancement: apply completable() to known parameter names
229+
// ────────────────────────────────────────────────────────────────────────────
230+
231+
/** Completion callback type matching the MCP SDK's CompleteCallback. */
232+
type CompleteCallback = (
233+
_value: string,
234+
_context?: { arguments?: Record<string, string> },
235+
) => string[] | Promise<string[]>;
236+
237+
/**
238+
* Map of parameter names to their completion callbacks.
239+
*
240+
* When a prompt shape contains one of these keys, the corresponding
241+
* completion callback is attached via `completable()` so VS Code shows
242+
* a filtered dropdown in the slash-command input dialog.
243+
*/
244+
const PARAMETER_COMPLETIONS: Record<string, CompleteCallback> = {
245+
database: completeDatabasePath,
246+
databasePath: completeDatabasePath,
247+
language: completeLanguage,
248+
packRoot: completePackRoot,
249+
queryPath: completeQueryPath,
250+
sarifPath: completeSarifPath,
251+
sarifPathA: completeSarifPath,
252+
sarifPathB: completeSarifPath,
253+
workspaceUri: completePackRoot,
254+
};
255+
256+
/**
257+
* Clone a Zod string (or optional-string) type, preserving its description.
258+
*
259+
* This is necessary because `completable()` mutates the schema in-place,
260+
* and we must not mutate the canonical schema constants (e.g.
261+
* `explainCodeqlQuerySchema.shape.queryPath`).
262+
*/
263+
function cloneStringType(zodType: z.ZodTypeAny): z.ZodTypeAny {
264+
const desc = zodType.description;
265+
266+
if (zodType instanceof z.ZodOptional) {
267+
const fresh = z.string().optional();
268+
return desc ? fresh.describe(desc) : fresh;
269+
}
270+
271+
const fresh = z.string();
272+
return desc ? fresh.describe(desc) : fresh;
273+
}
274+
275+
/**
276+
* Apply `completable()` wrappers to a prompt argument shape.
277+
*
278+
* For each field whose name is in `PARAMETER_COMPLETIONS`, the Zod type
279+
* is cloned (to avoid mutating shared schema objects) and wrapped with
280+
* the corresponding completion callback. Fields without a registered
281+
* completer are passed through unchanged.
282+
*
283+
* Call this **after** `toPermissiveShape()` so the completable metadata
284+
* is attached to the widened types that the MCP SDK sees.
285+
*/
286+
export function addCompletions(
287+
shape: Record<string, z.ZodTypeAny>,
288+
): Record<string, z.ZodTypeAny> {
289+
const enhanced: Record<string, z.ZodTypeAny> = {};
290+
291+
for (const [key, zodType] of Object.entries(shape)) {
292+
const completer = PARAMETER_COMPLETIONS[key];
293+
if (completer) {
294+
const fresh = cloneStringType(zodType);
295+
enhanced[key] = completable(fresh, completer);
296+
} else {
297+
enhanced[key] = zodType;
298+
}
299+
}
300+
301+
return enhanced;
302+
}

0 commit comments

Comments
 (0)