@usewhisper/mcp-server 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +305 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { execSync, spawnSync } from "child_process";
|
|
8
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
9
|
+
import { join, relative, extname } from "path";
|
|
7
10
|
|
|
8
11
|
// ../src/sdk/index.ts
|
|
9
12
|
var WhisperError = class extends Error {
|
|
@@ -543,6 +546,23 @@ var WhisperContext = class _WhisperContext {
|
|
|
543
546
|
});
|
|
544
547
|
return this.request(`/v1/cost/breakdown?${query}`);
|
|
545
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Semantic search over raw documents without pre-indexing.
|
|
551
|
+
* Send file contents/summaries directly — the API embeds them in-memory and ranks by similarity.
|
|
552
|
+
* Perfect for AI agents to semantically explore a codebase on-the-fly.
|
|
553
|
+
*/
|
|
554
|
+
async semanticSearch(params) {
|
|
555
|
+
return this.request("/v1/search/semantic", {
|
|
556
|
+
method: "POST",
|
|
557
|
+
body: JSON.stringify(params)
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
async searchFiles(params) {
|
|
561
|
+
return this.request("/v1/search/files", {
|
|
562
|
+
method: "POST",
|
|
563
|
+
body: JSON.stringify(params)
|
|
564
|
+
});
|
|
565
|
+
}
|
|
546
566
|
async getCostSavings(params = {}) {
|
|
547
567
|
const resolvedProject = params.project ? await this.resolveProjectId(params.project) : void 0;
|
|
548
568
|
const query = new URLSearchParams({
|
|
@@ -1051,6 +1071,291 @@ server.tool(
|
|
|
1051
1071
|
}
|
|
1052
1072
|
}
|
|
1053
1073
|
);
|
|
1074
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".next", "build", "__pycache__", ".turbo", "coverage", ".cache"]);
|
|
1075
|
+
var CODE_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c", "cs", "rb", "php", "swift", "kt", "sql", "prisma", "graphql", "json", "yaml", "yml", "toml", "env"]);
|
|
1076
|
+
function extractSignature(filePath, content) {
|
|
1077
|
+
const lines = content.split("\n");
|
|
1078
|
+
const signature = [`// File: ${filePath}`];
|
|
1079
|
+
const head = lines.slice(0, 60);
|
|
1080
|
+
for (const line of head) {
|
|
1081
|
+
const trimmed = line.trim();
|
|
1082
|
+
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
1083
|
+
if (/^(import|from|require|use |pub use )/.test(trimmed)) {
|
|
1084
|
+
signature.push(trimmed.slice(0, 120));
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (/^(export|async function|function|class|interface|type |const |let |def |pub fn |fn |struct |impl |enum )/.test(trimmed)) {
|
|
1088
|
+
signature.push(trimmed.slice(0, 120));
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (trimmed.startsWith("@") || trimmed.startsWith("#[")) {
|
|
1092
|
+
signature.push(trimmed.slice(0, 80));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
for (const line of lines.slice(60)) {
|
|
1096
|
+
const trimmed = line.trim();
|
|
1097
|
+
if (/^(export (default |async )?function|export (default )?class|export const|export type|export interface|async function|function |class |def |pub fn |fn )/.test(trimmed)) {
|
|
1098
|
+
signature.push(trimmed.slice(0, 120));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return signature.join("\n").slice(0, 2e3);
|
|
1102
|
+
}
|
|
1103
|
+
server.tool(
|
|
1104
|
+
"semantic_search_codebase",
|
|
1105
|
+
"Semantically search a local codebase without pre-indexing. Unlike grep/ripgrep, this understands meaning \u2014 so 'find authentication logic' finds auth code even if it doesn't literally say 'auth'. Uses vector embeddings via the Whisper API. Perfect for exploring unfamiliar codebases.",
|
|
1106
|
+
{
|
|
1107
|
+
query: z.string().describe("Natural language description of what you're looking for. E.g. 'authentication and session management', 'database connection pooling', 'error handling middleware'"),
|
|
1108
|
+
path: z.string().optional().describe("Absolute path to the codebase root. Defaults to current working directory."),
|
|
1109
|
+
file_types: z.array(z.string()).optional().describe("Limit to specific extensions e.g. ['ts', 'py']. Defaults to all common code files."),
|
|
1110
|
+
top_k: z.number().optional().default(10).describe("Number of most relevant files to return"),
|
|
1111
|
+
threshold: z.number().optional().default(0.2).describe("Minimum similarity score 0-1. Lower = more results but less precise."),
|
|
1112
|
+
max_files: z.number().optional().default(300).describe("Max files to scan. For large codebases, increase this or narrow with file_types.")
|
|
1113
|
+
},
|
|
1114
|
+
async ({ query, path: searchPath, file_types, top_k, threshold, max_files }) => {
|
|
1115
|
+
const rootPath = searchPath || process.cwd();
|
|
1116
|
+
const allowedExts = file_types ? new Set(file_types) : CODE_EXTENSIONS;
|
|
1117
|
+
const files = [];
|
|
1118
|
+
function collect(dir) {
|
|
1119
|
+
if (files.length >= (max_files ?? 300)) return;
|
|
1120
|
+
let entries;
|
|
1121
|
+
try {
|
|
1122
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1123
|
+
} catch {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
for (const entry of entries) {
|
|
1127
|
+
if (files.length >= (max_files ?? 300)) break;
|
|
1128
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
1129
|
+
const full = join(dir, entry.name);
|
|
1130
|
+
if (entry.isDirectory()) {
|
|
1131
|
+
collect(full);
|
|
1132
|
+
} else if (entry.isFile()) {
|
|
1133
|
+
const ext = extname(entry.name).replace(".", "");
|
|
1134
|
+
if (allowedExts.has(ext)) files.push(full);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
collect(rootPath);
|
|
1139
|
+
if (files.length === 0) {
|
|
1140
|
+
return { content: [{ type: "text", text: `No code files found in ${rootPath}` }] };
|
|
1141
|
+
}
|
|
1142
|
+
const documents = [];
|
|
1143
|
+
for (const filePath of files) {
|
|
1144
|
+
try {
|
|
1145
|
+
const stat = statSync(filePath);
|
|
1146
|
+
if (stat.size > 500 * 1024) continue;
|
|
1147
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1148
|
+
const relPath = relative(rootPath, filePath);
|
|
1149
|
+
const signature = extractSignature(relPath, content);
|
|
1150
|
+
documents.push({ id: relPath, content: signature });
|
|
1151
|
+
} catch {
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (documents.length === 0) {
|
|
1155
|
+
return { content: [{ type: "text", text: "Could not read any files." }] };
|
|
1156
|
+
}
|
|
1157
|
+
let response;
|
|
1158
|
+
try {
|
|
1159
|
+
response = await whisper.semanticSearch({
|
|
1160
|
+
query,
|
|
1161
|
+
documents,
|
|
1162
|
+
top_k: top_k ?? 10,
|
|
1163
|
+
threshold: threshold ?? 0.2
|
|
1164
|
+
});
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
return { content: [{ type: "text", text: `Semantic search failed: ${error.message}` }] };
|
|
1167
|
+
}
|
|
1168
|
+
if (!response.results || response.results.length === 0) {
|
|
1169
|
+
return { content: [{ type: "text", text: `No semantically relevant files found for: "${query}"
|
|
1170
|
+
|
|
1171
|
+
Searched ${documents.length} files in ${rootPath}.
|
|
1172
|
+
|
|
1173
|
+
Try lowering the threshold or rephrasing your query.` }] };
|
|
1174
|
+
}
|
|
1175
|
+
const lines = [
|
|
1176
|
+
`Semantic search: "${query}"`,
|
|
1177
|
+
`Searched ${documents.length} files \u2192 ${response.results.length} relevant (${response.latency_ms}ms)
|
|
1178
|
+
`
|
|
1179
|
+
];
|
|
1180
|
+
for (const result of response.results) {
|
|
1181
|
+
lines.push(`\u{1F4C4} ${result.id} (score: ${result.score})`);
|
|
1182
|
+
if (result.snippet) {
|
|
1183
|
+
lines.push(` ${result.snippet}`);
|
|
1184
|
+
}
|
|
1185
|
+
if (result.score > 0.5) {
|
|
1186
|
+
try {
|
|
1187
|
+
const fullPath = join(rootPath, result.id);
|
|
1188
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
1189
|
+
const excerpt = content.split("\n").slice(0, 30).join("\n");
|
|
1190
|
+
lines.push(`
|
|
1191
|
+
\`\`\`
|
|
1192
|
+
${excerpt}
|
|
1193
|
+
\`\`\``);
|
|
1194
|
+
} catch {
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
lines.push("");
|
|
1198
|
+
}
|
|
1199
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1200
|
+
}
|
|
1201
|
+
);
|
|
1202
|
+
function* walkDir(dir, fileTypes) {
|
|
1203
|
+
let entries;
|
|
1204
|
+
try {
|
|
1205
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1206
|
+
} catch {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
for (const entry of entries) {
|
|
1210
|
+
if (["node_modules", ".git", "dist", ".next", "build", "__pycache__"].includes(entry.name)) continue;
|
|
1211
|
+
const full = join(dir, entry.name);
|
|
1212
|
+
if (entry.isDirectory()) {
|
|
1213
|
+
yield* walkDir(full, fileTypes);
|
|
1214
|
+
} else if (entry.isFile()) {
|
|
1215
|
+
if (!fileTypes || fileTypes.length === 0) yield full;
|
|
1216
|
+
else if (fileTypes.includes(extname(entry.name).replace(".", ""))) yield full;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
server.tool(
|
|
1221
|
+
"search_files",
|
|
1222
|
+
"Search files and content in a local directory without requiring pre-indexing. Uses ripgrep when available, falls back to Node.js. Great for finding files, functions, patterns, or any text across a codebase instantly.",
|
|
1223
|
+
{
|
|
1224
|
+
query: z.string().describe("What to search for \u2014 natural language keyword, function name, pattern, etc."),
|
|
1225
|
+
path: z.string().optional().describe("Absolute path to search in. Defaults to current working directory."),
|
|
1226
|
+
mode: z.enum(["content", "filename", "both"]).optional().default("both").describe("Search file contents, filenames, or both"),
|
|
1227
|
+
file_types: z.array(z.string()).optional().describe("Limit to these file extensions e.g. ['ts', 'js', 'py', 'go']"),
|
|
1228
|
+
max_results: z.number().optional().default(20).describe("Max number of matching files to return"),
|
|
1229
|
+
context_lines: z.number().optional().default(2).describe("Lines of context around each match"),
|
|
1230
|
+
case_sensitive: z.boolean().optional().default(false)
|
|
1231
|
+
},
|
|
1232
|
+
async ({ query, path: searchPath, mode, file_types, max_results, context_lines, case_sensitive }) => {
|
|
1233
|
+
const rootPath = searchPath || process.cwd();
|
|
1234
|
+
const results = [];
|
|
1235
|
+
const rgAvailable = spawnSync("rg", ["--version"], { stdio: "ignore" }).status === 0;
|
|
1236
|
+
if (rgAvailable && (mode === "content" || mode === "both")) {
|
|
1237
|
+
try {
|
|
1238
|
+
const args = [
|
|
1239
|
+
"--json",
|
|
1240
|
+
case_sensitive ? "" : "-i",
|
|
1241
|
+
`-C`,
|
|
1242
|
+
`${context_lines}`,
|
|
1243
|
+
`-m`,
|
|
1244
|
+
`${max_results}`,
|
|
1245
|
+
"--max-filesize",
|
|
1246
|
+
"1M",
|
|
1247
|
+
"--glob",
|
|
1248
|
+
"!node_modules",
|
|
1249
|
+
"--glob",
|
|
1250
|
+
"!.git",
|
|
1251
|
+
"--glob",
|
|
1252
|
+
"!dist",
|
|
1253
|
+
"--glob",
|
|
1254
|
+
"!.next",
|
|
1255
|
+
"--glob",
|
|
1256
|
+
"!build",
|
|
1257
|
+
...file_types && file_types.length > 0 ? ["--glob", file_types.length === 1 ? `*.${file_types[0]}` : `*.{${file_types.join(",")}}`] : [],
|
|
1258
|
+
query,
|
|
1259
|
+
rootPath
|
|
1260
|
+
].filter(Boolean);
|
|
1261
|
+
const output = execSync(`rg ${args.join(" ")}`, {
|
|
1262
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
1263
|
+
stdio: ["pipe", "pipe", "ignore"]
|
|
1264
|
+
}).toString().trim();
|
|
1265
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
1266
|
+
for (const line of output.split("\n")) {
|
|
1267
|
+
if (!line.trim()) continue;
|
|
1268
|
+
try {
|
|
1269
|
+
const entry = JSON.parse(line);
|
|
1270
|
+
if (entry.type === "match") {
|
|
1271
|
+
const filePath = relative(rootPath, entry.data.path.text);
|
|
1272
|
+
if (!fileMap.has(filePath)) fileMap.set(filePath, []);
|
|
1273
|
+
fileMap.get(filePath).push({
|
|
1274
|
+
line: entry.data.line_number,
|
|
1275
|
+
content: entry.data.lines.text.trimEnd(),
|
|
1276
|
+
context_before: [],
|
|
1277
|
+
context_after: []
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
} catch {
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
for (const [file, matches] of fileMap) {
|
|
1284
|
+
if (results.length >= (max_results ?? 20)) break;
|
|
1285
|
+
results.push({ file, matches });
|
|
1286
|
+
}
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
if (err.status !== 1) {
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (mode === "filename" || mode === "both") {
|
|
1293
|
+
const queryLower = query.toLowerCase();
|
|
1294
|
+
const existingFiles = new Set(results.map((r) => r.file));
|
|
1295
|
+
for (const filePath of walkDir(rootPath, file_types)) {
|
|
1296
|
+
if (results.length >= (max_results ?? 20)) break;
|
|
1297
|
+
const relPath = relative(rootPath, filePath);
|
|
1298
|
+
const check = case_sensitive ? relPath : relPath.toLowerCase();
|
|
1299
|
+
if (check.includes(case_sensitive ? query : queryLower) && !existingFiles.has(relPath)) {
|
|
1300
|
+
results.push({ file: relPath, matches: [] });
|
|
1301
|
+
existingFiles.add(relPath);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (!rgAvailable && (mode === "content" || mode === "both")) {
|
|
1306
|
+
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), case_sensitive ? "g" : "gi");
|
|
1307
|
+
for (const filePath of walkDir(rootPath, file_types)) {
|
|
1308
|
+
if (results.length >= (max_results ?? 20)) break;
|
|
1309
|
+
try {
|
|
1310
|
+
const stat = statSync(filePath);
|
|
1311
|
+
if (stat.size > 512 * 1024) continue;
|
|
1312
|
+
const text = readFileSync(filePath, "utf-8");
|
|
1313
|
+
const lines2 = text.split("\n");
|
|
1314
|
+
const matches = [];
|
|
1315
|
+
lines2.forEach((line, i) => {
|
|
1316
|
+
regex.lastIndex = 0;
|
|
1317
|
+
if (regex.test(line)) {
|
|
1318
|
+
matches.push({
|
|
1319
|
+
line: i + 1,
|
|
1320
|
+
content: line.trimEnd(),
|
|
1321
|
+
context_before: lines2.slice(Math.max(0, i - (context_lines ?? 2)), i).map((l) => l.trimEnd()),
|
|
1322
|
+
context_after: lines2.slice(i + 1, i + 1 + (context_lines ?? 2)).map((l) => l.trimEnd())
|
|
1323
|
+
});
|
|
1324
|
+
regex.lastIndex = 0;
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
if (matches.length > 0) results.push({ file: relative(rootPath, filePath), matches });
|
|
1328
|
+
} catch {
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (results.length === 0) {
|
|
1333
|
+
return { content: [{ type: "text", text: `No results found for "${query}" in ${rootPath}` }] };
|
|
1334
|
+
}
|
|
1335
|
+
const totalMatches = results.reduce((s, r) => s + r.matches.length, 0);
|
|
1336
|
+
const lines = [
|
|
1337
|
+
`Found ${results.length} file(s), ${totalMatches} match(es) for "${query}" in ${rootPath}
|
|
1338
|
+
`
|
|
1339
|
+
];
|
|
1340
|
+
for (const result of results) {
|
|
1341
|
+
lines.push(`\u{1F4C4} ${result.file}`);
|
|
1342
|
+
for (const match of result.matches.slice(0, 5)) {
|
|
1343
|
+
if (match.context_before.length > 0) {
|
|
1344
|
+
lines.push(...match.context_before.map((l) => ` ${l}`));
|
|
1345
|
+
}
|
|
1346
|
+
lines.push(`\u2192 L${match.line}: ${match.content}`);
|
|
1347
|
+
if (match.context_after.length > 0) {
|
|
1348
|
+
lines.push(...match.context_after.map((l) => ` ${l}`));
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
if (result.matches.length > 5) {
|
|
1352
|
+
lines.push(` ... and ${result.matches.length - 5} more matches`);
|
|
1353
|
+
}
|
|
1354
|
+
lines.push("");
|
|
1355
|
+
}
|
|
1356
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1357
|
+
}
|
|
1358
|
+
);
|
|
1054
1359
|
async function main() {
|
|
1055
1360
|
const transport = new StdioServerTransport();
|
|
1056
1361
|
await server.connect(transport);
|