@usewhisper/mcp-server 0.5.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +145 -196
  2. package/dist/server.js +1050 -44
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -94,6 +94,7 @@ var DEFAULT_TIMEOUTS = {
94
94
  sessionMs: 2500
95
95
  };
96
96
  var DEFAULT_RETRYABLE_STATUS = [408, 429, 500, 502, 503, 504];
97
+ var DEFAULT_API_KEY_ONLY_PREFIXES = ["/v1/memory", "/v1/context/query"];
97
98
  var DEFAULT_RETRY_ATTEMPTS = {
98
99
  search: 3,
99
100
  writeAck: 2,
@@ -210,6 +211,16 @@ var RuntimeClient = class {
210
211
  const maybeWindow = globalThis.window;
211
212
  return maybeWindow && typeof maybeWindow === "object" ? "browser" : "node";
212
213
  }
214
+ apiKeyOnlyPrefixes() {
215
+ const raw = process.env.WHISPER_API_KEY_ONLY_PREFIXES;
216
+ if (!raw || !raw.trim()) return DEFAULT_API_KEY_ONLY_PREFIXES;
217
+ return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
218
+ }
219
+ shouldAttachApiKeyHeader(endpoint) {
220
+ if (this.sendApiKeyHeader) return true;
221
+ const prefixes = this.apiKeyOnlyPrefixes();
222
+ return prefixes.some((prefix) => endpoint === prefix || endpoint.startsWith(`${prefix}/`));
223
+ }
213
224
  createRequestFingerprint(options) {
214
225
  const normalizedEndpoint = normalizeEndpoint(options.endpoint);
215
226
  const authFingerprint = stableHash(this.apiKey.replace(/^Bearer\s+/i, ""));
@@ -276,6 +287,7 @@ var RuntimeClient = class {
276
287
  const controller = new AbortController();
277
288
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
278
289
  try {
290
+ const attachApiKeyHeader = this.shouldAttachApiKeyHeader(normalizedEndpoint);
279
291
  const response = await fetch(`${this.baseUrl}${normalizedEndpoint}`, {
280
292
  method,
281
293
  signal: controller.signal,
@@ -283,7 +295,7 @@ var RuntimeClient = class {
283
295
  headers: {
284
296
  "Content-Type": "application/json",
285
297
  Authorization: this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`,
286
- ...this.sendApiKeyHeader ? { "X-API-Key": this.apiKey.replace(/^Bearer\s+/i, "") } : {},
298
+ ...attachApiKeyHeader ? { "X-API-Key": this.apiKey.replace(/^Bearer\s+/i, "") } : {},
287
299
  "x-trace-id": traceId,
288
300
  "x-span-id": spanId,
289
301
  "x-sdk-version": this.sdkVersion,
@@ -720,6 +732,69 @@ var WhisperContext = class _WhisperContext {
720
732
  async syncSource(sourceId) {
721
733
  return this.request(`/v1/sources/${sourceId}/sync`, { method: "POST" });
722
734
  }
735
+ async addSourceByType(projectId, params) {
736
+ const resolvedProjectId = await this.resolveProjectId(projectId);
737
+ return this.request(`/v1/projects/${resolvedProjectId}/add_source`, {
738
+ method: "POST",
739
+ body: JSON.stringify(params)
740
+ });
741
+ }
742
+ async getSourceStatus(sourceId) {
743
+ return this.request(`/v1/sources/${sourceId}/status`, { method: "GET" });
744
+ }
745
+ async createCanonicalSource(project, params) {
746
+ const connector_type = params.type === "github" ? "github" : params.type === "web" ? "website" : params.type === "pdf" ? "pdf" : params.type === "local" ? "local-folder" : "slack";
747
+ const config = {};
748
+ if (params.type === "github") {
749
+ if (!params.owner || !params.repo) throw new WhisperError({ code: "REQUEST_FAILED", message: "github source requires owner and repo" });
750
+ config.owner = params.owner;
751
+ config.repo = params.repo;
752
+ if (params.branch) config.branch = params.branch;
753
+ if (params.paths) config.paths = params.paths;
754
+ } else if (params.type === "web") {
755
+ if (!params.url) throw new WhisperError({ code: "REQUEST_FAILED", message: "web source requires url" });
756
+ config.url = params.url;
757
+ if (params.crawl_depth !== void 0) config.crawl_depth = params.crawl_depth;
758
+ if (params.include_paths) config.include_paths = params.include_paths;
759
+ if (params.exclude_paths) config.exclude_paths = params.exclude_paths;
760
+ } else if (params.type === "pdf") {
761
+ if (!params.url && !params.file_path) throw new WhisperError({ code: "REQUEST_FAILED", message: "pdf source requires url or file_path" });
762
+ if (params.url) config.url = params.url;
763
+ if (params.file_path) config.file_path = params.file_path;
764
+ } else if (params.type === "local") {
765
+ if (!params.path) throw new WhisperError({ code: "REQUEST_FAILED", message: "local source requires path" });
766
+ config.path = params.path;
767
+ if (params.glob) config.glob = params.glob;
768
+ if (params.max_files !== void 0) config.max_files = params.max_files;
769
+ } else {
770
+ config.channel_ids = params.channel_ids || [];
771
+ if (params.since) config.since = params.since;
772
+ if (params.workspace_id) config.workspace_id = params.workspace_id;
773
+ if (params.token) config.token = params.token;
774
+ if (params.auth_ref) config.auth_ref = params.auth_ref;
775
+ }
776
+ if (params.metadata) config.metadata = params.metadata;
777
+ config.auto_index = params.auto_index ?? true;
778
+ const created = await this.addSource(project, {
779
+ name: params.name || `${params.type}-source-${Date.now()}`,
780
+ connector_type,
781
+ config
782
+ });
783
+ let status = "queued";
784
+ let jobId = null;
785
+ if (params.auto_index ?? true) {
786
+ const syncRes = await this.syncSource(created.id);
787
+ status = "indexing";
788
+ jobId = String(syncRes?.id || syncRes?.job_id || "");
789
+ }
790
+ return {
791
+ source_id: created.id,
792
+ status,
793
+ job_id: jobId,
794
+ index_started: params.auto_index ?? true,
795
+ warnings: []
796
+ };
797
+ }
723
798
  async ingest(projectId, documents) {
724
799
  const resolvedProjectId = await this.resolveProjectId(projectId);
725
800
  return this.request(`/v1/projects/${resolvedProjectId}/ingest`, {
@@ -1197,8 +1272,11 @@ var WhisperContext = class _WhisperContext {
1197
1272
  };
1198
1273
  sources = {
1199
1274
  add: (projectId, params) => this.addSource(projectId, params),
1275
+ addSource: (projectId, params) => this.addSourceByType(projectId, params),
1200
1276
  sync: (sourceId) => this.syncSource(sourceId),
1201
- syncSource: (sourceId) => this.syncSource(sourceId)
1277
+ syncSource: (sourceId) => this.syncSource(sourceId),
1278
+ status: (sourceId) => this.getSourceStatus(sourceId),
1279
+ getStatus: (sourceId) => this.getSourceStatus(sourceId)
1202
1280
  };
1203
1281
  memory = {
1204
1282
  add: (params) => this.addMemory(params),
@@ -1247,15 +1325,14 @@ var WhisperContext = class _WhisperContext {
1247
1325
  var API_KEY = process.env.WHISPER_API_KEY || "";
1248
1326
  var DEFAULT_PROJECT = process.env.WHISPER_PROJECT || "";
1249
1327
  var BASE_URL = process.env.WHISPER_BASE_URL;
1250
- if (!API_KEY) {
1251
- console.error("Error: WHISPER_API_KEY environment variable is required");
1252
- process.exit(1);
1253
- }
1254
- var whisper = new WhisperContext({
1328
+ var RUNTIME_MODE = (process.env.WHISPER_MCP_MODE || "remote").toLowerCase();
1329
+ var CLI_ARGS = process.argv.slice(2);
1330
+ var IS_MANAGEMENT_ONLY = CLI_ARGS.includes("--print-tool-map") || CLI_ARGS[0] === "scope";
1331
+ var whisper = !IS_MANAGEMENT_ONLY && API_KEY ? new WhisperContext({
1255
1332
  apiKey: API_KEY,
1256
1333
  project: DEFAULT_PROJECT,
1257
1334
  ...BASE_URL && { baseUrl: BASE_URL }
1258
- });
1335
+ }) : null;
1259
1336
  var server = new McpServer({
1260
1337
  name: "whisper-context",
1261
1338
  version: "0.2.8"
@@ -1263,6 +1340,43 @@ var server = new McpServer({
1263
1340
  var STATE_DIR = join(homedir(), ".whisper-mcp");
1264
1341
  var STATE_PATH = join(STATE_DIR, "state.json");
1265
1342
  var AUDIT_LOG_PATH = join(STATE_DIR, "forget-audit.log");
1343
+ var LOCAL_INGEST_MANIFEST_PATH = join(STATE_DIR, "local-ingest-manifest.json");
1344
+ var TOOL_MIGRATION_MAP = [
1345
+ { old: "list_projects", next: "context.list_projects" },
1346
+ { old: "list_sources", next: "context.list_sources" },
1347
+ { old: "add_source", next: "context.add_source" },
1348
+ { old: "source_status", next: "context.source_status" },
1349
+ { old: "query_context", next: "context.query" },
1350
+ { old: "get_relevant_context", next: "context.get_relevant" },
1351
+ { old: "claim_verifier", next: "context.claim_verify" },
1352
+ { old: "evidence_locked_answer", next: "context.evidence_answer" },
1353
+ { old: "export_context_bundle", next: "context.export_bundle" },
1354
+ { old: "import_context_bundle", next: "context.import_bundle" },
1355
+ { old: "diff_context", next: "context.diff" },
1356
+ { old: "share_context", next: "context.share" },
1357
+ { old: "add_memory", next: "memory.add" },
1358
+ { old: "search_memories", next: "memory.search" },
1359
+ { old: "forget", next: "memory.forget" },
1360
+ { old: "consolidate_memories", next: "memory.consolidate" },
1361
+ { old: "oracle_search", next: "research.oracle" },
1362
+ { old: "repo_index_status", next: "index.workspace_status" },
1363
+ { old: "index_workspace", next: "index.workspace_run" },
1364
+ { old: "autosubscribe_dependencies", next: "index.autosubscribe_deps" },
1365
+ { old: "search_files", next: "code.search_text" },
1366
+ { old: "semantic_search_codebase", next: "code.search_semantic" }
1367
+ ];
1368
+ var ALIAS_TOOL_MAP = [
1369
+ { alias: "search", target: "context.query" },
1370
+ { alias: "search_code", target: "code.search_semantic" },
1371
+ { alias: "grep", target: "code.search_text" },
1372
+ { alias: "read", target: "local.file_read" },
1373
+ { alias: "explore", target: "local.tree" },
1374
+ { alias: "research", target: "research.oracle" },
1375
+ { alias: "index", target: "context.add_source | index.workspace_run" },
1376
+ { alias: "remember", target: "memory.add" },
1377
+ { alias: "recall", target: "memory.search" },
1378
+ { alias: "share_context", target: "context.share" }
1379
+ ];
1266
1380
  function ensureStateDir() {
1267
1381
  if (!existsSync(STATE_DIR)) {
1268
1382
  mkdirSync(STATE_DIR, { recursive: true });
@@ -1286,6 +1400,15 @@ function clamp01(value) {
1286
1400
  return value;
1287
1401
  }
1288
1402
  function renderCitation(ev) {
1403
+ const videoUrl = ev.metadata?.video_url;
1404
+ const tsRaw = ev.metadata?.timestamp_start_ms;
1405
+ const ts = tsRaw ? Number(tsRaw) : NaN;
1406
+ if (videoUrl && Number.isFinite(ts) && ts >= 0) {
1407
+ const totalSeconds = Math.floor(ts / 1e3);
1408
+ const minutes = Math.floor(totalSeconds / 60);
1409
+ const seconds = totalSeconds % 60;
1410
+ return `${videoUrl} @ ${minutes}:${String(seconds).padStart(2, "0")}`;
1411
+ }
1289
1412
  return ev.line_end && ev.line_end !== ev.line_start ? `${ev.path}:${ev.line_start}-${ev.line_end}` : `${ev.path}:${ev.line_start}`;
1290
1413
  }
1291
1414
  function extractLineStart(metadata) {
@@ -1323,7 +1446,11 @@ function toEvidenceRef(source, workspaceId, methodFallback) {
1323
1446
  workspace_id: workspaceId,
1324
1447
  metadata: {
1325
1448
  source: String(source.source || ""),
1326
- document: String(source.document || "")
1449
+ document: String(source.document || ""),
1450
+ video_url: String(metadata.video_url || ""),
1451
+ timestamp_start_ms: String(metadata.timestamp_start_ms ?? ""),
1452
+ timestamp_end_ms: String(metadata.timestamp_end_ms ?? ""),
1453
+ citation: String(metadata.citation || "")
1327
1454
  }
1328
1455
  };
1329
1456
  }
@@ -1383,7 +1510,7 @@ function buildAbstain(args) {
1383
1510
  reason: args.reason,
1384
1511
  message: args.message,
1385
1512
  closest_evidence: args.closest_evidence,
1386
- recommended_next_calls: ["repo_index_status", "index_workspace", "symbol_search", "get_relevant_context"],
1513
+ recommended_next_calls: ["index.workspace_status", "index.workspace_run", "symbol_search", "context.get_relevant"],
1387
1514
  diagnostics: {
1388
1515
  claims_evaluated: args.claims_evaluated,
1389
1516
  evidence_items_found: args.evidence_items_found,
@@ -1437,8 +1564,267 @@ function countCodeFiles(searchPath, maxFiles = 5e3) {
1437
1564
  walk(searchPath);
1438
1565
  return { total, skipped };
1439
1566
  }
1567
+ function toTextResult(payload) {
1568
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
1569
+ }
1570
+ function likelyEmbeddingFailure(error) {
1571
+ const message = String(error?.message || error || "").toLowerCase();
1572
+ return message.includes("embedding") || message.includes("vector") || message.includes("timeout") || message.includes("timed out") || message.includes("temporarily unavailable");
1573
+ }
1574
+ async function queryWithDegradedFallback(params) {
1575
+ try {
1576
+ const response = await whisper.query({
1577
+ project: params.project,
1578
+ query: params.query,
1579
+ top_k: params.top_k,
1580
+ include_memories: params.include_memories,
1581
+ include_graph: params.include_graph,
1582
+ hybrid: true,
1583
+ rerank: true
1584
+ });
1585
+ return { response, degraded_mode: false };
1586
+ } catch (error) {
1587
+ if (!likelyEmbeddingFailure(error)) throw error;
1588
+ const response = await whisper.query({
1589
+ project: params.project,
1590
+ query: params.query,
1591
+ top_k: params.top_k,
1592
+ include_memories: params.include_memories,
1593
+ include_graph: false,
1594
+ hybrid: false,
1595
+ rerank: false,
1596
+ vector_weight: 0,
1597
+ bm25_weight: 1
1598
+ });
1599
+ return {
1600
+ response,
1601
+ degraded_mode: true,
1602
+ degraded_reason: "Embedding/graph path unavailable; lexical fallback used.",
1603
+ recommendation: "Check embedding service health, then re-run for full hybrid quality."
1604
+ };
1605
+ }
1606
+ }
1607
+ function getLocalAllowlistRoots() {
1608
+ const fromEnv = (process.env.WHISPER_LOCAL_ALLOWLIST || "").split(",").map((v) => v.trim()).filter(Boolean);
1609
+ if (fromEnv.length > 0) return fromEnv;
1610
+ return [process.cwd()];
1611
+ }
1612
+ function isPathAllowed(targetPath) {
1613
+ const normalized = targetPath.replace(/\\/g, "/").toLowerCase();
1614
+ const allowlist = getLocalAllowlistRoots();
1615
+ const allowed = allowlist.some((root) => normalized.startsWith(root.replace(/\\/g, "/").toLowerCase()));
1616
+ return { allowed, allowlist };
1617
+ }
1618
+ function shouldSkipSensitivePath(filePath) {
1619
+ const p = filePath.replace(/\\/g, "/").toLowerCase();
1620
+ const denySnippets = [
1621
+ "/node_modules/",
1622
+ "/.git/",
1623
+ "/dist/",
1624
+ "/build/",
1625
+ "/.next/",
1626
+ "/.aws/",
1627
+ "/.ssh/",
1628
+ ".pem",
1629
+ ".key",
1630
+ ".env",
1631
+ "credentials"
1632
+ ];
1633
+ return denySnippets.some((s) => p.includes(s));
1634
+ }
1635
+ function redactLikelySecrets(content) {
1636
+ return content.replace(/(api[_-]?key\s*[=:]\s*)[^\s"'`]+/gi, "$1[REDACTED]").replace(/(token\s*[=:]\s*)[^\s"'`]+/gi, "$1[REDACTED]").replace(/(secret\s*[=:]\s*)[^\s"'`]+/gi, "$1[REDACTED]");
1637
+ }
1638
+ function loadIngestManifest() {
1639
+ ensureStateDir();
1640
+ if (!existsSync(LOCAL_INGEST_MANIFEST_PATH)) return {};
1641
+ try {
1642
+ const parsed = JSON.parse(readFileSync(LOCAL_INGEST_MANIFEST_PATH, "utf-8"));
1643
+ return parsed && typeof parsed === "object" ? parsed : {};
1644
+ } catch {
1645
+ return {};
1646
+ }
1647
+ }
1648
+ function saveIngestManifest(manifest) {
1649
+ ensureStateDir();
1650
+ writeFileSync(LOCAL_INGEST_MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8");
1651
+ }
1652
+ async function ingestLocalPath(params) {
1653
+ if (RUNTIME_MODE === "remote") {
1654
+ throw new Error("Local ingestion is disabled in remote mode. Set WHISPER_MCP_MODE=auto or local.");
1655
+ }
1656
+ const rootPath = params.path || process.cwd();
1657
+ const gate = isPathAllowed(rootPath);
1658
+ if (!gate.allowed) {
1659
+ throw new Error(`Path not allowed by WHISPER_LOCAL_ALLOWLIST. Allowed roots: ${gate.allowlist.join(", ")}`);
1660
+ }
1661
+ const maxFiles = Math.max(1, params.max_files || 200);
1662
+ const maxBytesPerFile = 512 * 1024;
1663
+ const files = [];
1664
+ function collect(dir) {
1665
+ if (files.length >= maxFiles) return;
1666
+ let entries;
1667
+ try {
1668
+ entries = readdirSync(dir, { withFileTypes: true });
1669
+ } catch {
1670
+ return;
1671
+ }
1672
+ for (const entry of entries) {
1673
+ if (files.length >= maxFiles) return;
1674
+ const full = join(dir, entry.name);
1675
+ if (entry.isDirectory()) {
1676
+ if (shouldSkipSensitivePath(full)) continue;
1677
+ collect(full);
1678
+ } else if (entry.isFile()) {
1679
+ if (shouldSkipSensitivePath(full)) continue;
1680
+ files.push(full);
1681
+ }
1682
+ }
1683
+ }
1684
+ collect(rootPath);
1685
+ const manifest = loadIngestManifest();
1686
+ const workspaceId = getWorkspaceIdForPath(rootPath);
1687
+ if (!manifest[workspaceId]) manifest[workspaceId] = { last_run_at: (/* @__PURE__ */ new Date(0)).toISOString(), files: {} };
1688
+ const docs = [];
1689
+ const skipped = [];
1690
+ for (const fullPath of files) {
1691
+ try {
1692
+ const st = statSync(fullPath);
1693
+ if (st.size > maxBytesPerFile) {
1694
+ skipped.push(`${relative(rootPath, fullPath)} (size>${maxBytesPerFile})`);
1695
+ continue;
1696
+ }
1697
+ const mtime = String(st.mtimeMs);
1698
+ const rel = relative(rootPath, fullPath);
1699
+ if (manifest[workspaceId].files[rel] === mtime) continue;
1700
+ const raw = readFileSync(fullPath, "utf-8");
1701
+ const content = redactLikelySecrets(raw).slice(0, params.chunk_chars || 2e4);
1702
+ docs.push({
1703
+ title: rel,
1704
+ content,
1705
+ file_path: rel,
1706
+ metadata: { source_type: "local", path: rel, ingested_at: (/* @__PURE__ */ new Date()).toISOString() }
1707
+ });
1708
+ manifest[workspaceId].files[rel] = mtime;
1709
+ } catch {
1710
+ skipped.push(relative(rootPath, fullPath));
1711
+ }
1712
+ }
1713
+ let ingested = 0;
1714
+ const batchSize = 25;
1715
+ for (let i = 0; i < docs.length; i += batchSize) {
1716
+ const batch = docs.slice(i, i + batchSize);
1717
+ const result = await whisper.ingest(params.project, batch);
1718
+ ingested += Number(result.ingested || batch.length);
1719
+ }
1720
+ manifest[workspaceId].last_run_at = (/* @__PURE__ */ new Date()).toISOString();
1721
+ saveIngestManifest(manifest);
1722
+ appendFileSync(
1723
+ AUDIT_LOG_PATH,
1724
+ `${(/* @__PURE__ */ new Date()).toISOString()} local_ingest workspace=${workspaceId} root_hash=${createHash("sha256").update(rootPath).digest("hex").slice(0, 16)} files=${docs.length}
1725
+ `
1726
+ );
1727
+ return { ingested, scanned: files.length, queued: docs.length, skipped, workspace_id: workspaceId };
1728
+ }
1729
+ async function createSourceByType(params) {
1730
+ const connector_type = params.type === "github" ? "github" : params.type === "web" ? "website" : params.type === "pdf" ? "pdf" : params.type === "local" ? "local-folder" : params.type === "slack" ? "slack" : "video";
1731
+ const config = {};
1732
+ if (params.type === "github") {
1733
+ if (!params.owner || !params.repo) throw new Error("github source requires owner and repo");
1734
+ config.owner = params.owner;
1735
+ config.repo = params.repo;
1736
+ if (params.branch) config.branch = params.branch;
1737
+ if (params.paths) config.paths = params.paths;
1738
+ } else if (params.type === "web") {
1739
+ if (!params.url) throw new Error("web source requires url");
1740
+ config.url = params.url;
1741
+ if (params.crawl_depth !== void 0) config.crawl_depth = params.crawl_depth;
1742
+ if (params.include_paths) config.include_paths = params.include_paths;
1743
+ if (params.exclude_paths) config.exclude_paths = params.exclude_paths;
1744
+ } else if (params.type === "pdf") {
1745
+ if (!params.url && !params.file_path) throw new Error("pdf source requires url or file_path");
1746
+ if (params.url) config.url = params.url;
1747
+ if (params.file_path) config.file_path = params.file_path;
1748
+ } else if (params.type === "local") {
1749
+ if (!params.path) throw new Error("local source requires path");
1750
+ const ingest = await ingestLocalPath({
1751
+ project: params.project,
1752
+ path: params.path,
1753
+ glob: params.glob,
1754
+ max_files: params.max_files
1755
+ });
1756
+ return {
1757
+ source_id: `local_${ingest.workspace_id}`,
1758
+ status: "ready",
1759
+ job_id: `local_ingest_${Date.now()}`,
1760
+ index_started: true,
1761
+ warnings: ingest.skipped.slice(0, 20),
1762
+ details: ingest
1763
+ };
1764
+ } else if (params.type === "slack") {
1765
+ config.channel_ids = params.channel_ids || [];
1766
+ if (params.since) config.since = params.since;
1767
+ if (params.workspace_id) config.workspace_id = params.workspace_id;
1768
+ if (params.token) config.token = params.token;
1769
+ if (params.auth_ref) config.auth_ref = params.auth_ref;
1770
+ } else if (params.type === "video") {
1771
+ if (!params.url) throw new Error("video source requires url");
1772
+ config.url = params.url;
1773
+ if (params.platform) config.platform = params.platform;
1774
+ if (params.language) config.language = params.language;
1775
+ if (params.allow_stt_fallback !== void 0) config.allow_stt_fallback = params.allow_stt_fallback;
1776
+ if (params.max_duration_minutes !== void 0) config.max_duration_minutes = params.max_duration_minutes;
1777
+ if (params.max_chunks !== void 0) config.max_chunks = params.max_chunks;
1778
+ }
1779
+ if (params.metadata) config.metadata = params.metadata;
1780
+ config.auto_index = params.auto_index ?? true;
1781
+ const created = await whisper.addSource(params.project, {
1782
+ name: params.name || `${params.type}-source-${Date.now()}`,
1783
+ connector_type,
1784
+ config
1785
+ });
1786
+ let jobId;
1787
+ let status = "queued";
1788
+ if (params.auto_index ?? true) {
1789
+ const syncRes = await whisper.syncSource(created.id);
1790
+ jobId = String(syncRes?.id || syncRes?.job_id || "");
1791
+ status = "indexing";
1792
+ }
1793
+ return {
1794
+ source_id: created.id,
1795
+ status,
1796
+ job_id: jobId || null,
1797
+ index_started: params.auto_index ?? true,
1798
+ warnings: []
1799
+ };
1800
+ }
1801
+ function scopeConfigJson(project, source, client) {
1802
+ const serverDef = {
1803
+ command: "npx",
1804
+ args: ["-y", "@usewhisper/mcp-server"],
1805
+ env: {
1806
+ WHISPER_API_KEY: "wctx_...",
1807
+ WHISPER_PROJECT: project,
1808
+ WHISPER_SCOPE_SOURCE: source
1809
+ }
1810
+ };
1811
+ if (client === "json") {
1812
+ return JSON.stringify({ mcpServers: { "whisper-context-scoped": serverDef } }, null, 2);
1813
+ }
1814
+ return JSON.stringify({ mcpServers: { "whisper-context-scoped": serverDef } }, null, 2);
1815
+ }
1816
+ function printToolMap() {
1817
+ console.log("Legacy -> canonical MCP tool names:");
1818
+ for (const row of TOOL_MIGRATION_MAP) {
1819
+ console.log(`- ${row.old} => ${row.next}`);
1820
+ }
1821
+ console.log("\nAgent-friendly aliases:");
1822
+ for (const row of ALIAS_TOOL_MAP) {
1823
+ console.log(`- ${row.alias} => ${row.target}`);
1824
+ }
1825
+ }
1440
1826
  server.tool(
1441
- "resolve_workspace",
1827
+ "index.workspace_resolve",
1442
1828
  "Resolve workspace identity from path + API key and map to a project without mandatory dashboard setup.",
1443
1829
  {
1444
1830
  path: z.string().optional().describe("Workspace path. Defaults to current working directory."),
@@ -1472,7 +1858,7 @@ server.tool(
1472
1858
  }
1473
1859
  );
1474
1860
  server.tool(
1475
- "repo_index_status",
1861
+ "index.workspace_status",
1476
1862
  "Check index freshness, coverage, commit, and pending changes before retrieval/edits.",
1477
1863
  {
1478
1864
  workspace_id: z.string().optional(),
@@ -1506,7 +1892,7 @@ server.tool(
1506
1892
  }
1507
1893
  );
1508
1894
  server.tool(
1509
- "index_workspace",
1895
+ "index.workspace_run",
1510
1896
  "Index workspace in full or incremental mode and update index metadata for freshness checks.",
1511
1897
  {
1512
1898
  workspace_id: z.string().optional(),
@@ -1545,7 +1931,43 @@ server.tool(
1545
1931
  }
1546
1932
  );
1547
1933
  server.tool(
1548
- "get_relevant_context",
1934
+ "index.local_scan_ingest",
1935
+ "Scan a local folder safely (allowlist + secret filters), ingest changed files, and persist incremental manifest.",
1936
+ {
1937
+ project: z.string().optional().describe("Project name or slug"),
1938
+ path: z.string().optional().describe("Local path to ingest. Defaults to current working directory."),
1939
+ glob: z.string().optional().describe("Optional include glob"),
1940
+ max_files: z.number().optional().default(200),
1941
+ chunk_chars: z.number().optional().default(2e4)
1942
+ },
1943
+ async ({ project, path, glob, max_files, chunk_chars }) => {
1944
+ try {
1945
+ const resolvedProject = await resolveProjectRef(project);
1946
+ if (!resolvedProject) {
1947
+ return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or provide project." }] };
1948
+ }
1949
+ const result = await ingestLocalPath({
1950
+ project: resolvedProject,
1951
+ path: path || process.cwd(),
1952
+ glob,
1953
+ max_files,
1954
+ chunk_chars
1955
+ });
1956
+ return toTextResult({
1957
+ source_id: `local_${result.workspace_id}`,
1958
+ status: "ready",
1959
+ job_id: `local_ingest_${Date.now()}`,
1960
+ index_started: true,
1961
+ warnings: result.skipped.slice(0, 20),
1962
+ details: result
1963
+ });
1964
+ } catch (error) {
1965
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1966
+ }
1967
+ }
1968
+ );
1969
+ server.tool(
1970
+ "context.get_relevant",
1549
1971
  "Core retrieval. Task goes in, ranked context chunks come out with structured evidence (file:line ready).",
1550
1972
  {
1551
1973
  question: z.string().describe("Task/question to retrieve context for"),
@@ -1574,7 +1996,7 @@ server.tool(
1574
1996
  };
1575
1997
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
1576
1998
  }
1577
- const response = await whisper.query({
1999
+ const queryResult = await queryWithDegradedFallback({
1578
2000
  project: resolvedProject,
1579
2001
  query: question,
1580
2002
  top_k,
@@ -1583,6 +2005,7 @@ server.tool(
1583
2005
  session_id,
1584
2006
  user_id
1585
2007
  });
2008
+ const response = queryResult.response;
1586
2009
  const evidence = (response.results || []).map((r) => toEvidenceRef(r, workspaceId, "semantic"));
1587
2010
  const payload = {
1588
2011
  question,
@@ -1591,7 +2014,10 @@ server.tool(
1591
2014
  context: response.context || "",
1592
2015
  evidence,
1593
2016
  used_context_ids: (response.results || []).map((r) => String(r.id)),
1594
- latency_ms: response.meta?.latency_ms || 0
2017
+ latency_ms: response.meta?.latency_ms || 0,
2018
+ degraded_mode: queryResult.degraded_mode,
2019
+ degraded_reason: queryResult.degraded_reason,
2020
+ recommendation: queryResult.recommendation
1595
2021
  };
1596
2022
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
1597
2023
  } catch (error) {
@@ -1600,7 +2026,7 @@ server.tool(
1600
2026
  }
1601
2027
  );
1602
2028
  server.tool(
1603
- "claim_verifier",
2029
+ "context.claim_verify",
1604
2030
  "Verify whether a claim is supported by retrieved context. Returns supported/partial/unsupported with evidence.",
1605
2031
  {
1606
2032
  claim: z.string().describe("Claim to verify"),
@@ -1653,7 +2079,7 @@ server.tool(
1653
2079
  }
1654
2080
  );
1655
2081
  server.tool(
1656
- "evidence_locked_answer",
2082
+ "context.evidence_answer",
1657
2083
  "Answer a question only when evidence requirements are met. Fails closed with an abstain payload when not verifiable.",
1658
2084
  {
1659
2085
  question: z.string(),
@@ -1767,7 +2193,7 @@ server.tool(
1767
2193
  }
1768
2194
  );
1769
2195
  server.tool(
1770
- "query_context",
2196
+ "context.query",
1771
2197
  "Search your knowledge base for relevant context. Returns packed context ready for LLM consumption. Supports hybrid vector+keyword search, memory inclusion, and knowledge graph traversal.",
1772
2198
  {
1773
2199
  project: z.string().optional().describe("Project name or slug (optional if WHISPER_PROJECT is set)"),
@@ -1782,31 +2208,38 @@ server.tool(
1782
2208
  },
1783
2209
  async ({ project, query, top_k, chunk_types, include_memories, include_graph, user_id, session_id, max_tokens }) => {
1784
2210
  try {
1785
- const response = await whisper.query({
1786
- project,
2211
+ const resolvedProject = await resolveProjectRef(project);
2212
+ if (!resolvedProject) {
2213
+ return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or pass project." }] };
2214
+ }
2215
+ const queryResult = await queryWithDegradedFallback({
2216
+ project: resolvedProject,
1787
2217
  query,
1788
2218
  top_k,
1789
- chunk_types,
1790
2219
  include_memories,
1791
2220
  include_graph,
1792
2221
  user_id,
1793
- session_id,
1794
- max_tokens
2222
+ session_id
1795
2223
  });
2224
+ const response = queryResult.response;
1796
2225
  if (response.results.length === 0) {
1797
2226
  return { content: [{ type: "text", text: "No relevant context found." }] };
1798
2227
  }
1799
2228
  const header = `Found ${response.meta.total} results (${response.meta.latency_ms}ms${response.meta.cache_hit ? ", cached" : ""}):
1800
2229
 
1801
2230
  `;
1802
- return { content: [{ type: "text", text: header + response.context }] };
2231
+ const suffix = queryResult.degraded_mode ? `
2232
+
2233
+ [degraded_mode=true] ${queryResult.degraded_reason}
2234
+ Recommendation: ${queryResult.recommendation}` : "";
2235
+ return { content: [{ type: "text", text: header + response.context + suffix }] };
1803
2236
  } catch (error) {
1804
2237
  return { content: [{ type: "text", text: `Error: ${error.message}` }] };
1805
2238
  }
1806
2239
  }
1807
2240
  );
1808
2241
  server.tool(
1809
- "add_memory",
2242
+ "memory.add",
1810
2243
  "Store a memory (fact, preference, decision) that persists across conversations. Memories can be scoped to a user, session, or agent.",
1811
2244
  {
1812
2245
  project: z.string().optional().describe("Project name or slug"),
@@ -1835,7 +2268,7 @@ server.tool(
1835
2268
  }
1836
2269
  );
1837
2270
  server.tool(
1838
- "search_memories",
2271
+ "memory.search",
1839
2272
  "Search stored memories by semantic similarity. Recall facts, preferences, past decisions from previous interactions.",
1840
2273
  {
1841
2274
  project: z.string().optional().describe("Project name or slug"),
@@ -1867,7 +2300,7 @@ ${r.content}`).join("\n\n");
1867
2300
  }
1868
2301
  );
1869
2302
  server.tool(
1870
- "list_projects",
2303
+ "context.list_projects",
1871
2304
  "List all available context projects.",
1872
2305
  {},
1873
2306
  async () => {
@@ -1881,7 +2314,7 @@ server.tool(
1881
2314
  }
1882
2315
  );
1883
2316
  server.tool(
1884
- "list_sources",
2317
+ "context.list_sources",
1885
2318
  "List all data sources connected to a project.",
1886
2319
  { project: z.string().optional().describe("Project name or slug") },
1887
2320
  async ({ project }) => {
@@ -1896,7 +2329,95 @@ server.tool(
1896
2329
  }
1897
2330
  );
1898
2331
  server.tool(
1899
- "add_context",
2332
+ "context.add_source",
2333
+ "Add a source to a project with normalized source contract and auto-index by default.",
2334
+ {
2335
+ project: z.string().optional().describe("Project name or slug"),
2336
+ type: z.enum(["github", "web", "pdf", "local", "slack", "video"]).default("github"),
2337
+ name: z.string().optional(),
2338
+ auto_index: z.boolean().optional().default(true),
2339
+ metadata: z.record(z.string()).optional(),
2340
+ owner: z.string().optional(),
2341
+ repo: z.string().optional(),
2342
+ branch: z.string().optional(),
2343
+ paths: z.array(z.string()).optional(),
2344
+ url: z.string().url().optional(),
2345
+ crawl_depth: z.number().optional(),
2346
+ include_paths: z.array(z.string()).optional(),
2347
+ exclude_paths: z.array(z.string()).optional(),
2348
+ file_path: z.string().optional(),
2349
+ path: z.string().optional(),
2350
+ glob: z.string().optional(),
2351
+ max_files: z.number().optional(),
2352
+ workspace_id: z.string().optional(),
2353
+ channel_ids: z.array(z.string()).optional(),
2354
+ since: z.string().optional(),
2355
+ token: z.string().optional(),
2356
+ auth_ref: z.string().optional(),
2357
+ platform: z.enum(["youtube", "loom", "generic"]).optional(),
2358
+ language: z.string().optional(),
2359
+ allow_stt_fallback: z.boolean().optional(),
2360
+ max_duration_minutes: z.number().optional(),
2361
+ max_chunks: z.number().optional()
2362
+ },
2363
+ async (input) => {
2364
+ try {
2365
+ const resolvedProject = await resolveProjectRef(input.project);
2366
+ if (!resolvedProject) {
2367
+ return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or provide project." }] };
2368
+ }
2369
+ const result = await createSourceByType({
2370
+ project: resolvedProject,
2371
+ type: input.type,
2372
+ name: input.name,
2373
+ auto_index: input.auto_index,
2374
+ metadata: input.metadata,
2375
+ owner: input.owner,
2376
+ repo: input.repo,
2377
+ branch: input.branch,
2378
+ paths: input.paths,
2379
+ url: input.url,
2380
+ crawl_depth: input.crawl_depth,
2381
+ include_paths: input.include_paths,
2382
+ exclude_paths: input.exclude_paths,
2383
+ file_path: input.file_path,
2384
+ path: input.path,
2385
+ glob: input.glob,
2386
+ max_files: input.max_files,
2387
+ workspace_id: input.workspace_id,
2388
+ channel_ids: input.channel_ids,
2389
+ since: input.since,
2390
+ token: input.token,
2391
+ auth_ref: input.auth_ref,
2392
+ platform: input.platform,
2393
+ language: input.language,
2394
+ allow_stt_fallback: input.allow_stt_fallback,
2395
+ max_duration_minutes: input.max_duration_minutes,
2396
+ max_chunks: input.max_chunks
2397
+ });
2398
+ return toTextResult(result);
2399
+ } catch (error) {
2400
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
2401
+ }
2402
+ }
2403
+ );
2404
+ server.tool(
2405
+ "context.source_status",
2406
+ "Get status and stage/progress details for a source sync job.",
2407
+ {
2408
+ source_id: z.string().describe("Source id")
2409
+ },
2410
+ async ({ source_id }) => {
2411
+ try {
2412
+ const result = await whisper.getSourceStatus(source_id);
2413
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2414
+ } catch (error) {
2415
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
2416
+ }
2417
+ }
2418
+ );
2419
+ server.tool(
2420
+ "context.add_text",
1900
2421
  "Add text content to a project's knowledge base.",
1901
2422
  {
1902
2423
  project: z.string().optional().describe("Project name or slug"),
@@ -1917,7 +2438,56 @@ server.tool(
1917
2438
  }
1918
2439
  );
1919
2440
  server.tool(
1920
- "memory_search_sota",
2441
+ "context.add_document",
2442
+ "Ingest a document into project knowledge. Supports plain text and video URLs.",
2443
+ {
2444
+ project: z.string().optional().describe("Project name or slug"),
2445
+ source_type: z.enum(["text", "video"]).default("text"),
2446
+ title: z.string().optional().describe("Title for text documents"),
2447
+ content: z.string().optional().describe("Text document content"),
2448
+ url: z.string().url().optional().describe("Video URL when source_type=video"),
2449
+ auto_sync: z.boolean().optional().default(true),
2450
+ tags: z.array(z.string()).optional(),
2451
+ platform: z.enum(["youtube", "loom", "generic"]).optional(),
2452
+ language: z.string().optional()
2453
+ },
2454
+ async ({ project, source_type, title, content, url, auto_sync, tags, platform, language }) => {
2455
+ try {
2456
+ const resolvedProject = await resolveProjectRef(project);
2457
+ if (!resolvedProject) {
2458
+ return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or provide project." }] };
2459
+ }
2460
+ if (source_type === "video") {
2461
+ if (!url) {
2462
+ return { content: [{ type: "text", text: "Error: url is required when source_type=video." }] };
2463
+ }
2464
+ const result = await whisper.addSourceByType(resolvedProject, {
2465
+ type: "video",
2466
+ url,
2467
+ auto_sync,
2468
+ tags,
2469
+ platform,
2470
+ language
2471
+ });
2472
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2473
+ }
2474
+ if (!content?.trim()) {
2475
+ return { content: [{ type: "text", text: "Error: content is required when source_type=text." }] };
2476
+ }
2477
+ await whisper.addContext({
2478
+ project: resolvedProject,
2479
+ title: title || "Document",
2480
+ content,
2481
+ metadata: { source: "mcp:add_document", tags: tags || [] }
2482
+ });
2483
+ return { content: [{ type: "text", text: `Indexed "${title || "Document"}" (${content.length} chars).` }] };
2484
+ } catch (error) {
2485
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
2486
+ }
2487
+ }
2488
+ );
2489
+ server.tool(
2490
+ "memory.search_sota",
1921
2491
  "SOTA memory search with temporal reasoning and relation graphs. Searches memories with support for temporal queries ('what did I say yesterday?'), type filtering, and knowledge graph traversal.",
1922
2492
  {
1923
2493
  project: z.string().optional().describe("Project name or slug"),
@@ -1962,7 +2532,7 @@ server.tool(
1962
2532
  }
1963
2533
  );
1964
2534
  server.tool(
1965
- "ingest_conversation",
2535
+ "memory.ingest_conversation",
1966
2536
  "Extract memories from a conversation session. Automatically handles disambiguation, temporal grounding, and relation detection.",
1967
2537
  {
1968
2538
  project: z.string().optional().describe("Project name or slug"),
@@ -1998,7 +2568,7 @@ server.tool(
1998
2568
  }
1999
2569
  );
2000
2570
  server.tool(
2001
- "oracle_search",
2571
+ "research.oracle",
2002
2572
  "Oracle Research Mode - Tree-guided document navigation with multi-step reasoning. More precise than standard search, especially for bleeding-edge features.",
2003
2573
  {
2004
2574
  project: z.string().optional().describe("Project name or slug"),
@@ -2046,7 +2616,7 @@ ${r.content.slice(0, 200)}...`
2046
2616
  }
2047
2617
  );
2048
2618
  server.tool(
2049
- "autosubscribe_dependencies",
2619
+ "index.autosubscribe_deps",
2050
2620
  "Automatically index a project's dependencies (package.json, requirements.txt, etc.). Resolves docs URLs and indexes documentation.",
2051
2621
  {
2052
2622
  project: z.string().optional().describe("Project name or slug"),
@@ -2079,7 +2649,7 @@ server.tool(
2079
2649
  }
2080
2650
  );
2081
2651
  server.tool(
2082
- "share_context",
2652
+ "context.share",
2083
2653
  "Create a shareable snapshot of a conversation with memories. Returns a URL that can be shared or resumed later.",
2084
2654
  {
2085
2655
  project: z.string().optional().describe("Project name or slug"),
@@ -2113,7 +2683,7 @@ Share URL: ${result.share_url}`
2113
2683
  }
2114
2684
  );
2115
2685
  server.tool(
2116
- "consolidate_memories",
2686
+ "memory.consolidate",
2117
2687
  "Find and merge duplicate memories to reduce bloat. Uses vector similarity + LLM merging.",
2118
2688
  {
2119
2689
  project: z.string().optional().describe("Project name or slug"),
@@ -2156,7 +2726,7 @@ Run without dry_run to merge.`
2156
2726
  }
2157
2727
  );
2158
2728
  server.tool(
2159
- "get_cost_summary",
2729
+ "context.cost_summary",
2160
2730
  "Get cost tracking summary showing spending by model and task. Includes savings vs always-Opus.",
2161
2731
  {
2162
2732
  project: z.string().optional().describe("Project name or slug (optional for org-wide)"),
@@ -2199,7 +2769,7 @@ server.tool(
2199
2769
  }
2200
2770
  );
2201
2771
  server.tool(
2202
- "forget",
2772
+ "memory.forget",
2203
2773
  "Delete or invalidate memories with immutable audit logging.",
2204
2774
  {
2205
2775
  workspace_id: z.string().optional(),
@@ -2311,7 +2881,7 @@ server.tool(
2311
2881
  }
2312
2882
  );
2313
2883
  server.tool(
2314
- "export_context_bundle",
2884
+ "context.export_bundle",
2315
2885
  "Export project/workspace memory and context to a portable bundle with checksum.",
2316
2886
  {
2317
2887
  workspace_id: z.string().optional(),
@@ -2363,7 +2933,7 @@ server.tool(
2363
2933
  }
2364
2934
  );
2365
2935
  server.tool(
2366
- "import_context_bundle",
2936
+ "context.import_bundle",
2367
2937
  "Import a portable context bundle with merge/replace modes and checksum verification.",
2368
2938
  {
2369
2939
  workspace_id: z.string().optional(),
@@ -2465,7 +3035,7 @@ server.tool(
2465
3035
  }
2466
3036
  );
2467
3037
  server.tool(
2468
- "diff_context",
3038
+ "context.diff",
2469
3039
  "Return deterministic context changes from an explicit anchor (session_id, timestamp, or commit).",
2470
3040
  {
2471
3041
  workspace_id: z.string().optional(),
@@ -2556,7 +3126,7 @@ function extractSignature(filePath, content) {
2556
3126
  return signature.join("\n").slice(0, 2e3);
2557
3127
  }
2558
3128
  server.tool(
2559
- "semantic_search_codebase",
3129
+ "code.search_semantic",
2560
3130
  "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.",
2561
3131
  {
2562
3132
  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'"),
@@ -2672,8 +3242,41 @@ function* walkDir(dir, fileTypes) {
2672
3242
  }
2673
3243
  }
2674
3244
  }
3245
+ function listTree(rootPath, maxDepth = 3, maxEntries = 200) {
3246
+ const lines = [];
3247
+ let seen = 0;
3248
+ function walk(dir, depth) {
3249
+ if (seen >= maxEntries || depth > maxDepth) return;
3250
+ let entries;
3251
+ try {
3252
+ entries = readdirSync(dir, { withFileTypes: true });
3253
+ } catch {
3254
+ return;
3255
+ }
3256
+ const filtered = entries.filter((entry) => !SKIP_DIRS.has(entry.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
3257
+ for (const entry of filtered) {
3258
+ if (seen >= maxEntries) return;
3259
+ const indent = " ".repeat(depth);
3260
+ lines.push(`${indent}${entry.isDirectory() ? "d" : "f"} ${entry.name}`);
3261
+ seen += 1;
3262
+ if (entry.isDirectory()) {
3263
+ walk(join(dir, entry.name), depth + 1);
3264
+ }
3265
+ }
3266
+ }
3267
+ walk(rootPath, 0);
3268
+ return lines;
3269
+ }
3270
+ function readFileWindow(filePath, startLine = 1, endLine = 200) {
3271
+ const content = readFileSync(filePath, "utf-8");
3272
+ const lines = content.split("\n");
3273
+ const safeStart = Math.max(1, startLine);
3274
+ const safeEnd = Math.max(safeStart, endLine);
3275
+ const excerpt = lines.slice(safeStart - 1, safeEnd).map((line, index) => `${safeStart + index}: ${line}`).join("\n");
3276
+ return excerpt || "(empty file)";
3277
+ }
2675
3278
  server.tool(
2676
- "search_files",
3279
+ "code.search_text",
2677
3280
  "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.",
2678
3281
  {
2679
3282
  query: z.string().describe("What to search for \u2014 natural language keyword, function name, pattern, etc."),
@@ -2812,7 +3415,7 @@ server.tool(
2812
3415
  }
2813
3416
  );
2814
3417
  server.tool(
2815
- "semantic_search",
3418
+ "code.semantic_documents",
2816
3419
  "Semantic vector search over provided documents. Uses embeddings to find semantically similar content. Perfect for AI code search, finding similar functions, or searching by meaning rather than keywords.",
2817
3420
  {
2818
3421
  query: z.string().describe("What to search for semantically (e.g. 'authentication logic', 'database connection')"),
@@ -2855,7 +3458,410 @@ server.tool(
2855
3458
  }
2856
3459
  }
2857
3460
  );
3461
+ server.tool(
3462
+ "search",
3463
+ "Search indexed code, docs, and connected sources with one obvious verb. Use this first for most retrieval tasks.",
3464
+ {
3465
+ project: z.string().optional().describe("Project name or slug"),
3466
+ query: z.string().describe("What you want to find"),
3467
+ top_k: z.number().optional().default(10),
3468
+ include_memories: z.boolean().optional().default(false),
3469
+ include_graph: z.boolean().optional().default(false),
3470
+ user_id: z.string().optional(),
3471
+ session_id: z.string().optional()
3472
+ },
3473
+ async ({ project, query, top_k, include_memories, include_graph, user_id, session_id }) => {
3474
+ try {
3475
+ const resolvedProject = await resolveProjectRef(project);
3476
+ if (!resolvedProject) {
3477
+ return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or pass project." }] };
3478
+ }
3479
+ const queryResult = await queryWithDegradedFallback({
3480
+ project: resolvedProject,
3481
+ query,
3482
+ top_k,
3483
+ include_memories,
3484
+ include_graph,
3485
+ user_id,
3486
+ session_id
3487
+ });
3488
+ const response = queryResult.response;
3489
+ if (!response.results?.length) {
3490
+ return { content: [{ type: "text", text: `No relevant results found for "${query}".` }] };
3491
+ }
3492
+ const suffix = queryResult.degraded_mode ? `
3493
+
3494
+ [degraded_mode=true] ${queryResult.degraded_reason}` : "";
3495
+ return { content: [{ type: "text", text: response.context + suffix }] };
3496
+ } catch (error) {
3497
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3498
+ }
3499
+ }
3500
+ );
3501
+ server.tool(
3502
+ "search_code",
3503
+ "Semantically search a local codebase by meaning. Use this for questions like 'where is auth handled?' or 'find retry logic'.",
3504
+ {
3505
+ query: z.string().describe("Natural-language code search query"),
3506
+ path: z.string().optional().describe("Codebase root. Defaults to current working directory."),
3507
+ file_types: z.array(z.string()).optional(),
3508
+ top_k: z.number().optional().default(10),
3509
+ threshold: z.number().optional().default(0.2),
3510
+ max_files: z.number().optional().default(150)
3511
+ },
3512
+ async ({ query, path, file_types, top_k, threshold, max_files }) => {
3513
+ const rootPath = path || process.cwd();
3514
+ const allowedExts = file_types ? new Set(file_types) : CODE_EXTENSIONS;
3515
+ const files = [];
3516
+ function collect(dir) {
3517
+ if (files.length >= (max_files ?? 150)) return;
3518
+ let entries;
3519
+ try {
3520
+ entries = readdirSync(dir, { withFileTypes: true });
3521
+ } catch {
3522
+ return;
3523
+ }
3524
+ for (const entry of entries) {
3525
+ if (files.length >= (max_files ?? 150)) break;
3526
+ if (SKIP_DIRS.has(entry.name)) continue;
3527
+ const full = join(dir, entry.name);
3528
+ if (entry.isDirectory()) collect(full);
3529
+ else if (entry.isFile()) {
3530
+ const ext = extname(entry.name).replace(".", "");
3531
+ if (allowedExts.has(ext)) files.push(full);
3532
+ }
3533
+ }
3534
+ }
3535
+ collect(rootPath);
3536
+ if (files.length === 0) {
3537
+ return { content: [{ type: "text", text: `No code files found in ${rootPath}` }] };
3538
+ }
3539
+ const documents = [];
3540
+ for (const filePath of files) {
3541
+ try {
3542
+ const stat = statSync(filePath);
3543
+ if (stat.size > 500 * 1024) continue;
3544
+ const content = readFileSync(filePath, "utf-8");
3545
+ const relPath = relative(rootPath, filePath);
3546
+ documents.push({ id: relPath, content: extractSignature(relPath, content) });
3547
+ } catch {
3548
+ }
3549
+ }
3550
+ try {
3551
+ const response = await whisper.semanticSearch({
3552
+ query,
3553
+ documents,
3554
+ top_k: top_k ?? 10,
3555
+ threshold: threshold ?? 0.2
3556
+ });
3557
+ if (!response.results?.length) {
3558
+ return { content: [{ type: "text", text: `No semantically relevant files found for "${query}".` }] };
3559
+ }
3560
+ const lines = response.results.map((result) => `${result.id} (score: ${result.score})${result.snippet ? `
3561
+ ${result.snippet}` : ""}`);
3562
+ return { content: [{ type: "text", text: lines.join("\n\n") }] };
3563
+ } catch (error) {
3564
+ return { content: [{ type: "text", text: `Semantic search failed: ${error.message}` }] };
3565
+ }
3566
+ }
3567
+ );
3568
+ server.tool(
3569
+ "grep",
3570
+ "Regex or text search across a local codebase. Use this when you know the symbol, string, or pattern you want.",
3571
+ {
3572
+ query: z.string().describe("Text or regex-like pattern to search for"),
3573
+ path: z.string().optional().describe("Search root. Defaults to current working directory."),
3574
+ file_types: z.array(z.string()).optional(),
3575
+ max_results: z.number().optional().default(20),
3576
+ case_sensitive: z.boolean().optional().default(false)
3577
+ },
3578
+ async ({ query, path, file_types, max_results, case_sensitive }) => {
3579
+ const rootPath = path || process.cwd();
3580
+ const results = [];
3581
+ const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), case_sensitive ? "g" : "gi");
3582
+ for (const filePath of walkDir(rootPath, file_types)) {
3583
+ if (results.length >= (max_results ?? 20)) break;
3584
+ try {
3585
+ const stat = statSync(filePath);
3586
+ if (stat.size > 512 * 1024) continue;
3587
+ const text = readFileSync(filePath, "utf-8");
3588
+ const lines2 = text.split("\n");
3589
+ const matches = [];
3590
+ lines2.forEach((line, index) => {
3591
+ regex.lastIndex = 0;
3592
+ if (regex.test(line)) {
3593
+ matches.push({ line: index + 1, content: line.trimEnd() });
3594
+ }
3595
+ });
3596
+ if (matches.length > 0) {
3597
+ results.push({ file: relative(rootPath, filePath), matches: matches.slice(0, 10) });
3598
+ }
3599
+ } catch {
3600
+ }
3601
+ }
3602
+ if (!results.length) {
3603
+ return { content: [{ type: "text", text: `No matches found for "${query}" in ${rootPath}` }] };
3604
+ }
3605
+ const lines = results.flatMap((result) => [
3606
+ `FILE ${result.file}`,
3607
+ ...result.matches.map((match) => `L${match.line}: ${match.content}`),
3608
+ ""
3609
+ ]);
3610
+ return { content: [{ type: "text", text: lines.join("\n") }] };
3611
+ }
3612
+ );
3613
+ server.tool(
3614
+ "read",
3615
+ "Read a local file with optional line ranges. Use this after search or grep when you want the actual source.",
3616
+ {
3617
+ path: z.string().describe("Absolute or relative path to the file to read."),
3618
+ start_line: z.number().optional().default(1),
3619
+ end_line: z.number().optional().default(200)
3620
+ },
3621
+ async ({ path, start_line, end_line }) => {
3622
+ try {
3623
+ const fullPath = path.includes(":") || path.startsWith("/") ? path : join(process.cwd(), path);
3624
+ const stats = statSync(fullPath);
3625
+ if (!stats.isFile()) {
3626
+ return { content: [{ type: "text", text: `Error: ${path} is not a file.` }] };
3627
+ }
3628
+ return { content: [{ type: "text", text: readFileWindow(fullPath, start_line, end_line) }] };
3629
+ } catch (error) {
3630
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3631
+ }
3632
+ }
3633
+ );
3634
+ server.tool(
3635
+ "explore",
3636
+ "Browse a repository tree or directory structure. Use this to orient yourself before reading files.",
3637
+ {
3638
+ path: z.string().optional().describe("Root directory to inspect. Defaults to current working directory."),
3639
+ max_depth: z.number().optional().default(3),
3640
+ max_entries: z.number().optional().default(200)
3641
+ },
3642
+ async ({ path, max_depth, max_entries }) => {
3643
+ try {
3644
+ const rootPath = path || process.cwd();
3645
+ const tree = listTree(rootPath, max_depth, max_entries);
3646
+ if (!tree.length) {
3647
+ return { content: [{ type: "text", text: `No visible files found in ${rootPath}` }] };
3648
+ }
3649
+ return { content: [{ type: "text", text: [`TREE ${rootPath}`, ...tree].join("\n") }] };
3650
+ } catch (error) {
3651
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3652
+ }
3653
+ }
3654
+ );
3655
+ server.tool(
3656
+ "research",
3657
+ "Run deeper research over indexed sources. Use this when search is not enough and you want synthesis or multi-step investigation.",
3658
+ {
3659
+ project: z.string().optional(),
3660
+ query: z.string().describe("Research question"),
3661
+ mode: z.enum(["search", "research"]).optional().default("research"),
3662
+ max_results: z.number().optional().default(5),
3663
+ max_steps: z.number().optional().default(5)
3664
+ },
3665
+ async ({ project, query, mode, max_results, max_steps }) => {
3666
+ try {
3667
+ const results = await whisper.oracleSearch({ project, query, mode, max_results, max_steps });
3668
+ if (mode === "research" && results.answer) {
3669
+ return { content: [{ type: "text", text: results.answer }] };
3670
+ }
3671
+ if (!results.results?.length) {
3672
+ return { content: [{ type: "text", text: "No research results found." }] };
3673
+ }
3674
+ const text = results.results.map((r, i) => `${i + 1}. ${r.path || r.source}
3675
+ ${String(r.content || "").slice(0, 200)}...`).join("\n\n");
3676
+ return { content: [{ type: "text", text }] };
3677
+ } catch (error) {
3678
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3679
+ }
3680
+ }
3681
+ );
3682
+ server.tool(
3683
+ "index",
3684
+ "Index a new source or refresh a workspace. Use action='source' to add GitHub/web/pdf/local/slack/video. Use action='workspace' to refresh local workspace metadata.",
3685
+ {
3686
+ action: z.enum(["source", "workspace"]).default("source"),
3687
+ project: z.string().optional(),
3688
+ type: z.enum(["github", "web", "pdf", "local", "slack", "video"]).optional(),
3689
+ name: z.string().optional(),
3690
+ owner: z.string().optional(),
3691
+ repo: z.string().optional(),
3692
+ branch: z.string().optional(),
3693
+ url: z.string().optional(),
3694
+ file_path: z.string().optional(),
3695
+ path: z.string().optional(),
3696
+ glob: z.string().optional(),
3697
+ max_files: z.number().optional(),
3698
+ channel_ids: z.array(z.string()).optional(),
3699
+ token: z.string().optional(),
3700
+ workspace_id: z.string().optional(),
3701
+ mode: z.enum(["full", "incremental"]).optional().default("incremental"),
3702
+ platform: z.enum(["youtube", "loom", "generic"]).optional(),
3703
+ language: z.string().optional(),
3704
+ allow_stt_fallback: z.boolean().optional(),
3705
+ max_duration_minutes: z.number().optional(),
3706
+ max_chunks: z.number().optional()
3707
+ },
3708
+ async (input) => {
3709
+ try {
3710
+ if (input.action === "workspace") {
3711
+ const rootPath = input.path || process.cwd();
3712
+ const workspaceId = getWorkspaceIdForPath(rootPath, input.workspace_id);
3713
+ const state = loadState();
3714
+ const workspace = getWorkspaceState(state, workspaceId);
3715
+ const fileStats = countCodeFiles(rootPath, input.max_files || 1500);
3716
+ workspace.index_metadata = {
3717
+ last_indexed_at: (/* @__PURE__ */ new Date()).toISOString(),
3718
+ last_indexed_commit: getGitHead(rootPath),
3719
+ coverage: input.mode === "full" ? 1 : Math.max(0, Math.min(1, fileStats.total / Math.max(1, input.max_files || 1500)))
3720
+ };
3721
+ saveState(state);
3722
+ return toTextResult({ workspace_id: workspaceId, mode: input.mode, indexed_files: fileStats.total, skipped_files: fileStats.skipped, index_metadata: workspace.index_metadata });
3723
+ }
3724
+ if (!input.type) {
3725
+ return { content: [{ type: "text", text: "Error: type is required when action='source'." }] };
3726
+ }
3727
+ const resolvedProject = await resolveProjectRef(input.project);
3728
+ if (!resolvedProject) {
3729
+ return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or provide project." }] };
3730
+ }
3731
+ const result = await createSourceByType({
3732
+ project: resolvedProject,
3733
+ type: input.type,
3734
+ name: input.name,
3735
+ owner: input.owner,
3736
+ repo: input.repo,
3737
+ branch: input.branch,
3738
+ url: input.url,
3739
+ file_path: input.file_path,
3740
+ path: input.path,
3741
+ glob: input.glob,
3742
+ max_files: input.max_files,
3743
+ channel_ids: input.channel_ids,
3744
+ token: input.token,
3745
+ workspace_id: input.workspace_id,
3746
+ platform: input.platform,
3747
+ language: input.language,
3748
+ allow_stt_fallback: input.allow_stt_fallback,
3749
+ max_duration_minutes: input.max_duration_minutes,
3750
+ max_chunks: input.max_chunks,
3751
+ auto_index: true
3752
+ });
3753
+ return toTextResult(result);
3754
+ } catch (error) {
3755
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3756
+ }
3757
+ }
3758
+ );
3759
+ server.tool(
3760
+ "remember",
3761
+ "Store something the agent should keep across sessions: a fact, decision, preference, or instruction.",
3762
+ {
3763
+ project: z.string().optional(),
3764
+ content: z.string().describe("Memory content"),
3765
+ memory_type: z.enum(["factual", "preference", "event", "relationship", "opinion", "goal", "instruction"]).optional().default("factual"),
3766
+ user_id: z.string().optional(),
3767
+ session_id: z.string().optional(),
3768
+ agent_id: z.string().optional(),
3769
+ importance: z.number().optional().default(0.5)
3770
+ },
3771
+ async ({ project, content, memory_type, user_id, session_id, agent_id, importance }) => {
3772
+ try {
3773
+ const result = await whisper.addMemory({ project, content, memory_type, user_id, session_id, agent_id, importance });
3774
+ return { content: [{ type: "text", text: `Memory stored (id: ${result.id}, type: ${memory_type}).` }] };
3775
+ } catch (error) {
3776
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3777
+ }
3778
+ }
3779
+ );
3780
+ server.tool(
3781
+ "recall",
3782
+ "Recall facts, decisions, and preferences from previous sessions or prior work.",
3783
+ {
3784
+ project: z.string().optional(),
3785
+ query: z.string().describe("What to recall"),
3786
+ user_id: z.string().optional(),
3787
+ session_id: z.string().optional(),
3788
+ top_k: z.number().optional().default(10),
3789
+ memory_types: z.array(z.enum(["factual", "preference", "event", "relationship", "opinion", "goal", "instruction"])).optional()
3790
+ },
3791
+ async ({ project, query, user_id, session_id, top_k, memory_types }) => {
3792
+ try {
3793
+ const results = await whisper.searchMemoriesSOTA({ project, query, user_id, session_id, top_k, memory_types });
3794
+ if (!results.memories?.length) {
3795
+ return { content: [{ type: "text", text: "No memories found." }] };
3796
+ }
3797
+ const text = results.memories.map((r, i) => `${i + 1}. [${r.memory_type}, score: ${r.similarity?.toFixed(3) || "N/A"}]
3798
+ ${r.content}`).join("\n\n");
3799
+ return { content: [{ type: "text", text }] };
3800
+ } catch (error) {
3801
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3802
+ }
3803
+ }
3804
+ );
3805
+ server.tool(
3806
+ "share_context",
3807
+ "Create a shareable snapshot of a session so another agent or teammate can continue with the same context.",
3808
+ {
3809
+ project: z.string().optional(),
3810
+ session_id: z.string().describe("Session to share"),
3811
+ title: z.string().optional(),
3812
+ expiry_days: z.number().optional().default(30)
3813
+ },
3814
+ async ({ project, session_id, title, expiry_days }) => {
3815
+ try {
3816
+ const result = await whisper.createSharedContext({ project, session_id, title, expiry_days });
3817
+ return {
3818
+ content: [{
3819
+ type: "text",
3820
+ text: `Shared context created:
3821
+ - Share ID: ${result.share_id}
3822
+ - Expires: ${result.expires_at || "Never"}
3823
+
3824
+ Share URL: ${result.share_url}`
3825
+ }]
3826
+ };
3827
+ } catch (error) {
3828
+ return { content: [{ type: "text", text: `Error: ${error.message}` }] };
3829
+ }
3830
+ }
3831
+ );
2858
3832
  async function main() {
3833
+ const args = process.argv.slice(2);
3834
+ if (args.includes("--print-tool-map")) {
3835
+ printToolMap();
3836
+ return;
3837
+ }
3838
+ if (args[0] === "scope") {
3839
+ const readArg = (name) => {
3840
+ const idx = args.indexOf(name);
3841
+ if (idx === -1) return void 0;
3842
+ return args[idx + 1];
3843
+ };
3844
+ const project = readArg("--project") || DEFAULT_PROJECT || "my-project";
3845
+ const source = readArg("--source") || "source-or-type";
3846
+ const client = readArg("--client") || "json";
3847
+ const outPath = readArg("--write");
3848
+ const rendered = scopeConfigJson(project, source, client);
3849
+ if (outPath) {
3850
+ const backup = existsSync(outPath) ? `${outPath}.bak-${Date.now()}` : void 0;
3851
+ if (backup) writeFileSync(backup, readFileSync(outPath, "utf-8"), "utf-8");
3852
+ writeFileSync(outPath, `${rendered}
3853
+ `, "utf-8");
3854
+ console.log(JSON.stringify({ ok: true, path: outPath, backup: backup || null, client }, null, 2));
3855
+ return;
3856
+ }
3857
+ console.log(rendered);
3858
+ return;
3859
+ }
3860
+ if (!API_KEY && !IS_MANAGEMENT_ONLY) {
3861
+ console.error("Error: WHISPER_API_KEY environment variable is required");
3862
+ process.exit(1);
3863
+ }
3864
+ console.error("[whisper-context-mcp] Breaking change: canonical namespaced tool names are active. Run with --print-tool-map for migration mapping.");
2859
3865
  const transport = new StdioServerTransport();
2860
3866
  await server.connect(transport);
2861
3867
  console.error("Whisper Context MCP server running on stdio");