@esankhan3/code-search-mcp 1.0.3 → 1.0.4

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.
@@ -0,0 +1,45 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { Server } from "@modelcontextprotocol/server";
4
+ import { startHttpTransport } from "../transports/http-transport.js";
5
+ import { metrics } from "../observability/metrics.js";
6
+ const config = {
7
+ port: 0,
8
+ host: "127.0.0.1",
9
+ transport: "streamable-http",
10
+ authEnabled: false,
11
+ authMode: "none",
12
+ authApiKeys: []
13
+ };
14
+ async function scrape(collectMetrics) {
15
+ const httpServer = await startHttpTransport({
16
+ config,
17
+ createMcpServer: async () => ({ server: new Server({ name: "m", version: "0.0.0" }, { capabilities: {} }) }),
18
+ collectMetrics
19
+ });
20
+ try {
21
+ const res = await fetch(`http://127.0.0.1:${httpServer.address().port}/metrics`);
22
+ return { status: res.status, body: await res.text() };
23
+ } finally {
24
+ httpServer.close();
25
+ }
26
+ }
27
+ describe("GET /metrics \u2014 async collector", () => {
28
+ it("renders gauges set by the collector", async () => {
29
+ const { status, body } = await scrape(async () => {
30
+ await new Promise((r) => setTimeout(r, 5));
31
+ metrics.vectorIndexUnindexedRows.set(42, { project: "p" });
32
+ metrics.vectorIndexGrowthRatio.set(0.5, { project: "p" });
33
+ });
34
+ assert.equal(status, 200);
35
+ assert.match(body, /code_search_vector_index_unindexed_rows\{project="p"\} 42/);
36
+ assert.match(body, /code_search_vector_index_growth_ratio\{project="p"\} 0\.5/);
37
+ });
38
+ it("still returns 200 when the collector throws", async () => {
39
+ const { status, body } = await scrape(async () => {
40
+ throw new Error("blob store down");
41
+ });
42
+ assert.equal(status, 200);
43
+ assert.match(body, /code_search_queries_total/);
44
+ });
45
+ });
@@ -124,6 +124,8 @@ const metrics = {
124
124
  queryDuration: registry.register(new Histogram("code_search_query_duration_seconds", "Query latency by mode.")),
125
125
  indexChunks: registry.register(new Gauge("code_search_index_chunks_total", "Indexed chunks by repo.")),
126
126
  indexAge: registry.register(new Gauge("code_search_index_age_seconds", "Seconds since last index by repo.")),
127
+ vectorIndexUnindexedRows: registry.register(new Gauge("code_search_vector_index_unindexed_rows", "Rows not yet folded into the vector index (brute-force scanned) by project.")),
128
+ vectorIndexGrowthRatio: registry.register(new Gauge("code_search_vector_index_growth_ratio", "Rows written since the vector index was last trained / rows it was trained on, by project (retrains at CODE_SEARCH_VECTOR_RETRAIN_RATIO).")),
127
129
  embeddingCalls: registry.register(new Counter("code_search_embeddings_calls_total", "Embedding API calls by provider.")),
128
130
  llmCalls: registry.register(new Counter("code_search_llm_calls_total", "LLM API calls by phase.")),
129
131
  errors: registry.register(new Counter("code_search_errors_total", "Errors by kind.")),
package/dist/server.js CHANGED
@@ -8,11 +8,12 @@ import { registerGraphTools, handleGraphTool } from "./tools/graph.js";
8
8
  import { registerProfileTools, handleProfileTool } from "./tools/profile.js";
9
9
  import { registerIndexTools, handleIndexTool } from "./tools/index-tools.js";
10
10
  import { registerResources, handleResource } from "./resources/resources.js";
11
- import { getBlobStore } from "@esankhan3/anvil-knowledge-core";
11
+ import { getBlobStore, readVectorIndexHealth } from "@esankhan3/anvil-knowledge-core";
12
12
  import { indexFromPath, invalidateRetriever } from "@esankhan3/anvil-knowledge-core";
13
13
  import { loadServerConfig } from "./core/env-config.js";
14
14
  import { toKnowledgeConfig } from "./core/config.js";
15
15
  import { startHttpTransport } from "./transports/http-transport.js";
16
+ import { metrics } from "./observability/metrics.js";
16
17
  function createMcpServerInstance(ctx) {
17
18
  const server = new Server(
18
19
  { name: "code-search-mcp", version: "1.0.0" },
@@ -119,6 +120,16 @@ async function startServer(projectName, directoryPath) {
119
120
  elapsedMs: ctx.indexing.startedAt ? Date.now() - ctx.indexing.startedAt : null
120
121
  }
121
122
  }),
123
+ // From the writer's blob record, not process state: in cluster mode the
124
+ // workers serving HTTP are not the writer.
125
+ collectMetrics: async () => {
126
+ const blobs = getBlobStore(ctx.projectName, toKnowledgeConfig(loadServerConfig().__unified));
127
+ const health = await readVectorIndexHealth(blobs);
128
+ if (!health) return;
129
+ const labels = { project: ctx.projectName };
130
+ metrics.vectorIndexUnindexedRows.set(health.numUnindexedRows ?? 0, labels);
131
+ if (health.trainedRows) metrics.vectorIndexGrowthRatio.set(health.rowsAddedSinceTrain / health.trainedRows, labels);
132
+ },
122
133
  onIndex: (body) => isReadOnlyWorker ? forwardIndexToPrimary(body) : runAdminIndex(ctx, body)
123
134
  });
124
135
  }
@@ -1,6 +1,17 @@
1
1
  import { resolvedKnowledgeConfig } from "../core/env-config.js";
2
2
  import { KnowledgeIndexer } from "@esankhan3/anvil-knowledge-core";
3
3
  const TOOL_NAMES = ["index_status"];
4
+ function describeVectorIndex(v) {
5
+ if (!v) return "none (flat scan \u2014 table below the index threshold or not yet indexed)";
6
+ const parts = [`${v.indexType ?? "unknown"}, ${v.numPartitions ?? "?"} partitions`];
7
+ parts.push(
8
+ v.trainedRows ? `trained on ${v.trainedRows.toLocaleString()} rows at ${v.trainedAt}, +${v.rowsAddedSinceTrain.toLocaleString()} since (${Math.round(v.rowsAddedSinceTrain / v.trainedRows * 100)}%)` : "training untracked (retrains on the next writer run)"
9
+ );
10
+ parts.push(`${(v.numUnindexedRows ?? 0).toLocaleString()} unindexed rows`);
11
+ if (v.retrainStartedAt) parts.push(`retrain started ${v.retrainStartedAt} has not completed`);
12
+ if (v.lastError) parts.push(`last error: ${v.lastError}`);
13
+ return parts.join("; ");
14
+ }
4
15
  function registerIndexTools() {
5
16
  return [
6
17
  {
@@ -26,6 +37,7 @@ async function handleIndexTool(name, args, ctx) {
26
37
  `- **Embedding provider:** ${stats.embeddingProvider}`,
27
38
  `- **Last indexed:** ${stats.lastIndexed || "never"}`,
28
39
  `- **Repos:** ${stats.repos.length}`,
40
+ `- **Vector index:** ${describeVectorIndex(stats.vectorIndex)}`,
29
41
  "",
30
42
  "## Repos",
31
43
  ...stats.repos.map((r) => `- ${r.name}: ${r.chunkCount} chunks`)
@@ -53,6 +53,10 @@ async function startHttpTransport(opts) {
53
53
  const status = opts.getStatus?.() ?? {};
54
54
  const indexing = status.indexing;
55
55
  metrics.indexAge.set(0, { phase: indexing?.phase ?? "idle" });
56
+ try {
57
+ await opts.collectMetrics?.();
58
+ } catch {
59
+ }
56
60
  res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
57
61
  res.end(registry.render());
58
62
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esankhan3/code-search-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "MCP server for multi-repo code search \u2014 GitHub org or local path, zero config",
5
5
  "type": "module",
6
6
  "license": "MIT",