@esankhan3/code-search-mcp 1.0.6 → 1.1.1

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 (42) hide show
  1. package/README.md +114 -54
  2. package/dist/__tests__/_harness.js +194 -0
  3. package/dist/__tests__/config-cli.test.js +110 -0
  4. package/dist/__tests__/config-resolver.test.js +1 -1
  5. package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
  6. package/dist/__tests__/daemon-runtime.test.js +145 -0
  7. package/dist/__tests__/graph-tools-store.test.js +314 -0
  8. package/dist/__tests__/graph-tools.test.js +11 -4
  9. package/dist/__tests__/graph-traversal.test.js +262 -0
  10. package/dist/__tests__/http-auth.test.js +180 -0
  11. package/dist/__tests__/http-index.test.js +125 -0
  12. package/dist/__tests__/metrics-collect.test.js +48 -0
  13. package/dist/__tests__/observability.test.js +3 -4
  14. package/dist/__tests__/remote-proxy.test.js +105 -0
  15. package/dist/__tests__/server-cli-args.test.js +146 -0
  16. package/dist/__tests__/server-lifecycle.test.js +108 -0
  17. package/dist/__tests__/tool-inputs.test.js +178 -0
  18. package/dist/backends/in-process.js +5 -2
  19. package/dist/backends/index.js +2 -2
  20. package/dist/cli/index.js +143 -76
  21. package/dist/core/config.js +106 -35
  22. package/dist/core/env-config.js +3 -5
  23. package/dist/core/project-name.js +12 -0
  24. package/dist/core/version.js +5 -0
  25. package/dist/daemon/index.js +106 -65
  26. package/dist/daemon/rpc-server.js +38 -18
  27. package/dist/index.js +113 -89
  28. package/dist/middleware/auth.js +34 -66
  29. package/dist/middleware/rate-limit.js +37 -0
  30. package/dist/observability/logger.js +10 -2
  31. package/dist/observability/metrics.js +7 -6
  32. package/dist/projects/registry.js +13 -15
  33. package/dist/resources/resources.js +17 -0
  34. package/dist/server.js +216 -129
  35. package/dist/tools/graph.js +448 -165
  36. package/dist/tools/index-tools.js +6 -2
  37. package/dist/tools/profile.js +14 -8
  38. package/dist/tools/search.js +61 -51
  39. package/dist/tools/validate.js +48 -0
  40. package/dist/transports/http-transport.js +94 -55
  41. package/dist/transports/remote-proxy.js +58 -97
  42. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { resolvedKnowledgeConfig } from "../core/env-config.js";
2
2
  import { KnowledgeIndexer } from "@esankhan3/anvil-knowledge-core";
3
+ import { toolError } from "./validate.js";
3
4
  const TOOL_NAMES = ["index_status"];
4
5
  function describeVectorIndex(v) {
5
6
  if (!v) return "none (flat scan \u2014 table below the index threshold or not yet indexed)";
@@ -8,6 +9,9 @@ function describeVectorIndex(v) {
8
9
  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
  );
10
11
  parts.push(`${(v.numUnindexedRows ?? 0).toLocaleString()} unindexed rows`);
12
+ if (v.nprobes) {
13
+ parts.push(`${v.nprobes} probes per query (recall@10 ${v.calibratedRecall?.toFixed(3)} on ${v.calibrationQueries} query-like probes)`);
14
+ }
11
15
  if (v.retrainStartedAt) parts.push(`retrain started ${v.retrainStartedAt} has not completed`);
12
16
  if (v.lastError) parts.push(`last error: ${v.lastError}`);
13
17
  return parts.join("; ");
@@ -16,7 +20,7 @@ function registerIndexTools() {
16
20
  return [
17
21
  {
18
22
  name: "index_status",
19
- description: "Get current index stats \u2014 chunk count, embedding provider, repos indexed, last indexed time.",
23
+ description: "Get current index stats \u2014 chunk count, embedding provider, last indexed time, vector-index health, and chunks per repo.",
20
24
  inputSchema: {
21
25
  type: "object",
22
26
  properties: {}
@@ -47,7 +51,7 @@ async function handleIndexTool(name, args, ctx) {
47
51
  return null;
48
52
  } catch (err) {
49
53
  const msg = err instanceof Error ? err.message : String(err);
50
- return { content: [{ type: "text", text: `Index tool error: ${msg}` }] };
54
+ return toolError(`Index tool error: ${msg}`);
51
55
  }
52
56
  }
53
57
  export {
@@ -1,6 +1,10 @@
1
- import { loadAllProfiles, loadProfile } from "@esankhan3/anvil-knowledge-core";
1
+ import { loadAllProfiles, loadProfile, isLlmAvailable } from "@esankhan3/anvil-knowledge-core";
2
2
  import { resolvedKnowledgeConfig } from "../core/env-config.js";
3
3
  import { discoverRepos } from "@esankhan3/anvil-knowledge-core";
4
+ import { toolError, toolText, validateArgs } from "./validate.js";
5
+ function missingProfileText(repo) {
6
+ return isLlmAvailable() ? `No profile for "${repo}" yet. Profiles are generated during indexing, so it appears after the next index run (or check the name with list_repos).` : `No profile for "${repo}": repo profiles are generated during indexing only when an LLM is configured, and LLM mode is "none".`;
7
+ }
4
8
  function registerProfileTools() {
5
9
  return [
6
10
  {
@@ -17,7 +21,7 @@ function registerProfileTools() {
17
21
  inputSchema: {
18
22
  type: "object",
19
23
  properties: {
20
- repo: { type: "string", description: "Repository name" }
24
+ repo: { type: "string", minLength: 1, description: "Repository name" }
21
25
  },
22
26
  required: ["repo"]
23
27
  }
@@ -25,7 +29,10 @@ function registerProfileTools() {
25
29
  ];
26
30
  }
27
31
  async function handleProfileTool(name, args, ctx) {
28
- if (!["list_repos", "get_repo_profile"].includes(name)) return null;
32
+ const schema = registerProfileTools().find((t) => t.name === name)?.inputSchema;
33
+ if (!schema) return null;
34
+ const invalid = validateArgs(schema, args);
35
+ if (invalid) return toolError(`${name}: ${invalid}`);
29
36
  try {
30
37
  if (name === "list_repos") {
31
38
  const profiles = await loadAllProfiles(ctx.projectName, resolvedKnowledgeConfig());
@@ -37,7 +44,7 @@ async function handleProfileTool(name, args, ctx) {
37
44
 
38
45
  ${text2}` }] };
39
46
  }
40
- return { content: [{ type: "text", text: "No repos found. Run reindex first." }] };
47
+ return toolText("No repos indexed yet.");
41
48
  }
42
49
  const text = profiles.map(
43
50
  (p) => `- **${p.name}** \u2014 ${p.role} | ${p.domain} | ${p.description}`
@@ -49,9 +56,7 @@ ${text}` }] };
49
56
  if (name === "get_repo_profile") {
50
57
  const repo = args.repo;
51
58
  const profile = await loadProfile(ctx.projectName, repo, resolvedKnowledgeConfig());
52
- if (!profile) {
53
- return { content: [{ type: "text", text: `No profile found for "${repo}". Run reindex to generate profiles.` }] };
54
- }
59
+ if (!profile) return toolText(missingProfileText(repo));
55
60
  const lines = [
56
61
  `# ${profile.name}`,
57
62
  "",
@@ -80,10 +85,11 @@ ${text}` }] };
80
85
  return null;
81
86
  } catch (err) {
82
87
  const msg = err instanceof Error ? err.message : String(err);
83
- return { content: [{ type: "text", text: `Profile tool error: ${msg}` }] };
88
+ return toolError(`Profile tool error: ${msg}`);
84
89
  }
85
90
  }
86
91
  export {
87
92
  handleProfileTool,
93
+ missingProfileText,
88
94
  registerProfileTools
89
95
  };
@@ -1,43 +1,36 @@
1
1
  import { getRetriever, getBlobStore, findChunksInProject } from "@esankhan3/anvil-knowledge-core";
2
2
  import { resolvedKnowledgeConfig } from "../core/env-config.js";
3
+ import { metrics } from "../observability/metrics.js";
4
+ import { toolError, toolText, validateArgs } from "./validate.js";
5
+ const queryProps = {
6
+ query: { type: "string", minLength: 1, description: "Search query (natural language or code identifier)" },
7
+ maxResults: { type: "integer", minimum: 1, description: "Max results to return (default: 10)" },
8
+ // Agents send repos as an array, one name, or a comma-joined string — all accepted.
9
+ repos: {
10
+ anyOf: [
11
+ { type: "array", items: { type: "string", minLength: 1 }, minItems: 1 },
12
+ { type: "string", minLength: 1 }
13
+ ],
14
+ description: "Only search these repos (optional): an array of names, one name, or a comma-separated list"
15
+ }
16
+ };
17
+ const repoList = (v) => (Array.isArray(v) ? v.map(String) : String(v).split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
3
18
  function registerSearchTools() {
4
19
  return [
5
20
  {
6
21
  name: "search_code",
7
- description: "Hybrid search across all repos using vector + BM25 + graph expansion + reranking. Best for general queries.",
8
- inputSchema: {
9
- type: "object",
10
- properties: {
11
- query: { type: "string", description: "Search query (natural language or code identifier)" },
12
- maxResults: { type: "number", description: "Max results to return (default: 10)" },
13
- repos: { type: "array", items: { type: "string" }, description: "Filter to specific repos (optional)" }
14
- },
15
- required: ["query"]
16
- }
22
+ description: "Hybrid search across all repos using vector + BM25 + graph expansion + reranking. Best for general queries. Needs the embedder.",
23
+ inputSchema: { type: "object", properties: queryProps, required: ["query"] }
17
24
  },
18
25
  {
19
26
  name: "search_semantic",
20
- description: 'Vector-only semantic search. Best for conceptual questions like "how does X work?"',
21
- inputSchema: {
22
- type: "object",
23
- properties: {
24
- query: { type: "string", description: "Search query" },
25
- maxResults: { type: "number", description: "Max results (default: 10)" }
26
- },
27
- required: ["query"]
28
- }
27
+ description: 'Vector-only semantic search. Best for conceptual questions like "how does X work?". Needs the embedder.',
28
+ inputSchema: { type: "object", properties: queryProps, required: ["query"] }
29
29
  },
30
30
  {
31
31
  name: "search_exact",
32
- description: "BM25 keyword search. Best for exact function names, error codes, file paths.",
33
- inputSchema: {
34
- type: "object",
35
- properties: {
36
- query: { type: "string", description: "Exact search query" },
37
- maxResults: { type: "number", description: "Max results (default: 10)" }
38
- },
39
- required: ["query"]
40
- }
32
+ description: "BM25 keyword search, led by exact symbol-name and exact-phrase hits. Best for exact function names, error codes, file paths. Works without the embedder.",
33
+ inputSchema: { type: "object", properties: queryProps, required: ["query"] }
41
34
  },
42
35
  {
43
36
  name: "get_code_snippet",
@@ -45,31 +38,39 @@ function registerSearchTools() {
45
38
  inputSchema: {
46
39
  type: "object",
47
40
  properties: {
48
- id: { type: "string", description: 'Qualified id "repo::filePath::entity" (or "repo::filePath" for a whole file)' },
49
- repo: { type: "string", description: "Repository name (alternative to id)" },
50
- file: { type: "string", description: "File path relative to repo root (alternative to id)" },
51
- entity: { type: "string", description: "Entity name (optional; with repo/file)" }
41
+ id: { type: "string", minLength: 1, description: 'Qualified id "repo::filePath::entity" (or "repo::filePath" for a whole file)' },
42
+ repo: { type: "string", minLength: 1, description: "Repository name (alternative to id)" },
43
+ file: { type: "string", minLength: 1, description: "File path relative to repo root (alternative to id)" },
44
+ entity: { type: "string", minLength: 1, description: "Entity name (optional; with repo/file)" }
52
45
  }
53
46
  }
54
47
  }
55
48
  ];
56
49
  }
50
+ const SCHEMAS = new Map(registerSearchTools().map((t) => [t.name, t.inputSchema]));
51
+ const METRIC_MODE = { search_code: "hybrid", search_semantic: "vector", search_exact: "bm25" };
57
52
  async function handleSearchTool(name, args, ctx) {
53
+ const schema = SCHEMAS.get(name);
54
+ if (!schema) return null;
55
+ const invalid = validateArgs(schema, args);
56
+ if (invalid) return toolError(`${name}: ${invalid}`);
58
57
  if (name === "get_code_snippet") return await handleGetCodeSnippet(args, ctx);
59
- if (!["search_code", "search_semantic", "search_exact"].includes(name)) return null;
60
- if (!ctx.indexReady) {
61
- return { content: [{ type: "text", text: "Index not ready. Run reindex tool or wait for auto-indexing to complete." }] };
58
+ if (!ctx.indexReady && name !== "search_exact") {
59
+ return toolText("Index not ready \u2014 indexing is still running or has not completed. Check index_status for progress.");
62
60
  }
61
+ const query = args.query;
62
+ const maxResults = args.maxResults ?? 10;
63
+ const repos = args.repos === void 0 || args.repos === null ? void 0 : repoList(args.repos);
64
+ if (repos?.length === 0) return toolError(`${name}: "repos" must name at least one repo.`);
65
+ const modeMap = {
66
+ search_code: "vector+bm25+graph",
67
+ search_semantic: "vector",
68
+ search_exact: "bm25"
69
+ };
70
+ const started = Date.now();
71
+ let outcome = "ok";
63
72
  try {
64
73
  const retriever = await getRetriever(ctx.projectName, resolvedKnowledgeConfig());
65
- const query = args.query;
66
- const maxResults = args.maxResults || 10;
67
- const repos = args.repos;
68
- const modeMap = {
69
- search_code: "vector+bm25+graph",
70
- search_semantic: "vector",
71
- search_exact: "bm25"
72
- };
73
74
  const result = await retriever.retrieve(query, {
74
75
  maxChunks: maxResults,
75
76
  // A search tool returns a fixed COUNT of results; the config token budget
@@ -81,7 +82,7 @@ async function handleSearchTool(name, args, ctx) {
81
82
  mode: modeMap[name]
82
83
  });
83
84
  if (result.chunks.length === 0) {
84
- return { content: [{ type: "text", text: `No results found for "${query}"` }] };
85
+ return toolText(`No results found for "${query}"${ctx.indexReady ? "" : " (indexing has not completed \u2014 check index_status)"}`);
85
86
  }
86
87
  const text = result.chunks.map((sc, i) => {
87
88
  const c = sc.chunk;
@@ -90,14 +91,23 @@ async function handleSearchTool(name, args, ctx) {
90
91
  ${c.content}
91
92
  \`\`\``;
92
93
  }).join("\n\n");
93
- return { content: [{ type: "text", text: `Found ${result.chunks.length} results for "${query}" (${result.totalTokens} tokens):
94
+ return toolText(`Found ${result.chunks.length} results for "${query}" (${result.totalTokens} tokens):
94
95
 
95
- ${text}` }] };
96
+ ${text}`);
96
97
  } catch (err) {
98
+ outcome = "error";
99
+ metrics.errors.inc({ kind: "search" });
97
100
  const msg = err instanceof Error ? err.message : String(err);
98
- return { content: [{ type: "text", text: `Search failed: ${msg}` }] };
101
+ return toolError(`Search failed: ${msg}`);
102
+ } finally {
103
+ const mode = METRIC_MODE[name];
104
+ metrics.queriesTotal.inc({ mode, outcome });
105
+ metrics.queryDuration.observe((Date.now() - started) / 1e3, { mode });
99
106
  }
100
107
  }
108
+ function namesEntity(c, entity) {
109
+ return c.entityName === entity || !!c.parentEntity && `${c.parentEntity}.${c.entityName}` === entity;
110
+ }
101
111
  async function handleGetCodeSnippet(args, ctx) {
102
112
  let repo = args.repo;
103
113
  let file = args.file;
@@ -113,7 +123,7 @@ async function handleGetCodeSnippet(args, ctx) {
113
123
  }
114
124
  }
115
125
  if (!repo || !file) {
116
- return { content: [{ type: "text", text: 'Provide id="repo::file::entity" or repo + file (entity optional).' }] };
126
+ return toolError('get_code_snippet: provide id="repo::file::entity" or repo + file (entity optional).');
117
127
  }
118
128
  try {
119
129
  const esc = (v) => v.replace(/'/g, "''");
@@ -132,12 +142,12 @@ async function handleGetCodeSnippet(args, ctx) {
132
142
  if (matches.length === 0 && await getBlobStore(ctx.projectName, resolvedKnowledgeConfig()).exists("chunks.json")) {
133
143
  matches = await findChunksInProject(
134
144
  ctx.projectName,
135
- (c) => c.repoName === repo && c.filePath === file && (!entity || c.entityName === entity),
145
+ (c) => c.repoName === repo && c.filePath === file && (!entity || namesEntity(c, entity)),
136
146
  5,
137
147
  resolvedKnowledgeConfig()
138
148
  );
139
149
  if (matches.length === 0 && entity) {
140
- matches = await findChunksInProject(ctx.projectName, (c) => c.repoName === repo && c.entityName === entity, 5, resolvedKnowledgeConfig());
150
+ matches = await findChunksInProject(ctx.projectName, (c) => c.repoName === repo && namesEntity(c, entity), 5, resolvedKnowledgeConfig());
141
151
  }
142
152
  }
143
153
  if (matches.length === 0) {
@@ -153,7 +163,7 @@ ${c.content}
153
163
  return { content: [{ type: "text", text }] };
154
164
  } catch (err) {
155
165
  const msg = err instanceof Error ? err.message : String(err);
156
- return { content: [{ type: "text", text: `Failed to read chunks: ${msg}` }] };
166
+ return toolError(`Failed to read chunks: ${msg}`);
157
167
  }
158
168
  }
159
169
  export {
@@ -0,0 +1,48 @@
1
+ const toolText = (text) => ({ content: [{ type: "text", text }] });
2
+ const toolError = (text) => ({ content: [{ type: "text", text }], isError: true });
3
+ const describe = (p) => p.type === "array" ? `${p.minItems ? "a non-empty " : "an "}array of ${p.items?.type ?? "value"}s` : `${p.minLength ? "a non-empty " : "a "}${p.type}`;
4
+ function checkValue(name, v, p) {
5
+ if (p.anyOf) {
6
+ return p.anyOf.some((branch) => checkValue(name, v, branch) === null) ? null : `"${name}" must be ${p.anyOf.map(describe).join(" or ")}.`;
7
+ }
8
+ switch (p.type) {
9
+ case "string":
10
+ if (typeof v !== "string") return `"${name}" must be a string.`;
11
+ if (p.minLength !== void 0 && v.trim().length < p.minLength) return `"${name}" must not be empty.`;
12
+ break;
13
+ case "boolean":
14
+ if (typeof v !== "boolean") return `"${name}" must be a boolean.`;
15
+ break;
16
+ case "integer":
17
+ if (typeof v !== "number" || !Number.isInteger(v)) return `"${name}" must be an integer.`;
18
+ if (p.minimum !== void 0 && v < p.minimum) return `"${name}" must be at least ${p.minimum}.`;
19
+ if (p.maximum !== void 0 && v > p.maximum) return `"${name}" must be at most ${p.maximum}.`;
20
+ break;
21
+ case "array":
22
+ if (!Array.isArray(v)) return `"${name}" must be an array.`;
23
+ if (p.minItems !== void 0 && v.length < p.minItems) return `"${name}" must not be empty.`;
24
+ for (const item of v) {
25
+ const err = p.items ? checkValue(`${name}[]`, item, p.items) : null;
26
+ if (err) return err;
27
+ }
28
+ break;
29
+ }
30
+ if (p.enum && !p.enum.includes(v)) return `"${name}" must be one of: ${p.enum.join(", ")} (got ${JSON.stringify(v)}).`;
31
+ return null;
32
+ }
33
+ function validateArgs(schema, args) {
34
+ for (const k of schema.required ?? []) {
35
+ if (args[k] === void 0 || args[k] === null) return `Missing required argument "${k}".`;
36
+ }
37
+ for (const [k, p] of Object.entries(schema.properties)) {
38
+ if (args[k] === void 0 || args[k] === null) continue;
39
+ const err = checkValue(k, args[k], p);
40
+ if (err) return err;
41
+ }
42
+ return null;
43
+ }
44
+ export {
45
+ toolError,
46
+ toolText,
47
+ validateArgs
48
+ };
@@ -2,14 +2,56 @@ import { createServer } from "node:http";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { createMcpHandler } from "@modelcontextprotocol/server";
4
4
  import { toNodeHandler } from "@modelcontextprotocol/node";
5
+ import { PACKAGE_VERSION } from "../core/version.js";
5
6
  import { createAuthMiddleware } from "../middleware/auth.js";
6
- import { registry, metrics } from "../observability/metrics.js";
7
+ import { registry } from "../observability/metrics.js";
8
+ import { getComponentLogger } from "../observability/logger.js";
9
+ const log = getComponentLogger("code-search-mcp");
10
+ class HttpError extends Error {
11
+ constructor(status, message) {
12
+ super(message);
13
+ this.status = status;
14
+ }
15
+ }
16
+ const MAX_INDEX_BODY_BYTES = 64 * 1024;
17
+ function sendJson(res, status, body, headers = {}) {
18
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
19
+ res.end(JSON.stringify(body));
20
+ }
21
+ function readBody(req, res, limit) {
22
+ return new Promise((resolve, reject) => {
23
+ if (Number(req.headers["content-length"] ?? 0) > limit) {
24
+ sendJson(res, 413, { error: `Request body exceeds ${limit} bytes` }, { Connection: "close" });
25
+ req.resume();
26
+ resolve(null);
27
+ return;
28
+ }
29
+ const chunks = [];
30
+ let size = 0;
31
+ let tooLarge = false;
32
+ req.on("data", (chunk) => {
33
+ if (tooLarge) return;
34
+ size += chunk.length;
35
+ if (size > limit) {
36
+ tooLarge = true;
37
+ sendJson(res, 413, { error: `Request body exceeds ${limit} bytes` }, { Connection: "close" });
38
+ resolve(null);
39
+ return;
40
+ }
41
+ chunks.push(chunk);
42
+ });
43
+ req.on("end", () => {
44
+ if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf-8"));
45
+ });
46
+ req.on("error", reject);
47
+ });
48
+ }
7
49
  async function startHttpTransport(opts) {
8
50
  const { config, createMcpServer, onReady, getHealth } = opts;
9
- const authenticate = createAuthMiddleware(config);
51
+ const authenticate = createAuthMiddleware(config, { rateLimiter: opts.rateLimiter });
10
52
  const startedAt = Date.now();
11
53
  const mcpHandler = createMcpHandler(async () => (await createMcpServer()).server, {
12
- onerror: (err) => console.error(`[code-search-mcp] MCP handler error: ${err.message}`)
54
+ onerror: (err) => log.error(`MCP handler error: ${err.message}`)
13
55
  });
14
56
  const handleMcp = toNodeHandler(mcpHandler);
15
57
  const httpServer = createServer(async (req, res) => {
@@ -19,11 +61,7 @@ async function startHttpTransport(opts) {
19
61
  res.setHeader("X-Request-ID", requestId);
20
62
  if (path === "/health" && req.method === "GET") {
21
63
  const health = getHealth?.() ?? {};
22
- res.writeHead(200, { "Content-Type": "application/json" });
23
- res.end(JSON.stringify({
24
- status: "ok",
25
- ...health
26
- }));
64
+ sendJson(res, 200, { status: "ok", ...health });
27
65
  return;
28
66
  }
29
67
  if (path === "/status" && req.method === "GET") {
@@ -33,26 +71,21 @@ async function startHttpTransport(opts) {
33
71
  return;
34
72
  }
35
73
  if (path === "/version" && req.method === "GET") {
36
- res.writeHead(200, { "Content-Type": "application/json" });
37
- res.end(JSON.stringify({
74
+ sendJson(res, 200, {
38
75
  name: "code-search-mcp",
39
- version: "1.0.0",
76
+ version: PACKAGE_VERSION,
40
77
  node: process.version,
41
78
  platform: process.platform
42
- }));
79
+ });
43
80
  return;
44
81
  }
45
82
  if (path === "/ready" && req.method === "GET") {
46
83
  const health = getHealth?.() ?? {};
47
84
  const ready = health.indexReady === true;
48
- res.writeHead(ready ? 200 : 503, { "Content-Type": "application/json" });
49
- res.end(JSON.stringify({ ready, ...health }));
85
+ sendJson(res, ready ? 200 : 503, { ready, ...health });
50
86
  return;
51
87
  }
52
88
  if (path === "/metrics" && req.method === "GET") {
53
- const status = opts.getStatus?.() ?? {};
54
- const indexing = status.indexing;
55
- metrics.indexAge.set(0, { phase: indexing?.phase ?? "idle" });
56
89
  try {
57
90
  await opts.collectMetrics?.();
58
91
  } catch {
@@ -62,63 +95,68 @@ async function startHttpTransport(opts) {
62
95
  return;
63
96
  }
64
97
  if (path === "/admin/api/status" && req.method === "GET") {
98
+ if (config.authEnabled && !await authenticate(req, res)) return;
65
99
  const status = opts.getStatus?.() ?? {};
66
100
  res.writeHead(200, { "Content-Type": "application/json" });
67
101
  res.end(JSON.stringify({
68
- version: "1.0.0",
102
+ version: PACKAGE_VERSION,
69
103
  uptime: Math.floor((Date.now() - startedAt) / 1e3),
70
104
  ...status
71
105
  }, null, 2));
72
106
  return;
73
107
  }
74
108
  if (path === "/index" && req.method === "POST") {
75
- if (config.authEnabled) {
76
- const identity = authenticate(req, res);
77
- if (!identity) return;
109
+ if (config.authEnabled && !await authenticate(req, res)) return;
110
+ const raw = await readBody(req, res, MAX_INDEX_BODY_BYTES);
111
+ if (raw === null) return;
112
+ let body;
113
+ try {
114
+ body = JSON.parse(raw);
115
+ } catch {
116
+ sendJson(res, 400, { error: 'Body must be a JSON object: {"path": "...", "project"?: "...", "force"?: true}' });
117
+ return;
118
+ }
119
+ if (typeof body !== "object" || body === null || typeof body.path !== "string" || !body.path) {
120
+ sendJson(res, 400, { error: "`path` is required" });
121
+ return;
122
+ }
123
+ if (!opts.onIndex) {
124
+ sendJson(res, 501, { error: "Indexing handler not configured" });
125
+ return;
126
+ }
127
+ try {
128
+ sendJson(res, 200, await opts.onIndex(body));
129
+ } catch (err) {
130
+ const msg = err instanceof Error ? err.message : String(err);
131
+ const status = err instanceof HttpError ? err.status : 500;
132
+ if (!res.headersSent) sendJson(res, status, { error: msg });
78
133
  }
79
- let body = "";
80
- req.on("data", (chunk) => {
81
- body += chunk.toString();
82
- });
83
- req.on("end", async () => {
84
- try {
85
- const parsed = JSON.parse(body);
86
- if (!parsed.path) {
87
- res.writeHead(400, { "Content-Type": "application/json" });
88
- res.end(JSON.stringify({ error: "`path` is required" }));
89
- return;
90
- }
91
- if (!opts.onIndex) {
92
- res.writeHead(501, { "Content-Type": "application/json" });
93
- res.end(JSON.stringify({ error: "Indexing handler not configured" }));
94
- return;
95
- }
96
- const result = await opts.onIndex(parsed);
97
- res.writeHead(200, { "Content-Type": "application/json" });
98
- res.end(JSON.stringify(result));
99
- } catch (err) {
100
- const msg = err instanceof Error ? err.message : String(err);
101
- if (!res.headersSent) {
102
- res.writeHead(500, { "Content-Type": "application/json" });
103
- res.end(JSON.stringify({ error: msg }));
104
- }
105
- }
106
- });
107
134
  return;
108
135
  }
109
136
  if (path === "/mcp") {
110
- if (config.authEnabled) {
111
- const identity = authenticate(req, res);
112
- if (!identity) return;
137
+ const identity = config.authEnabled ? await authenticate(req, res) : { mode: "anonymous", subject: "anonymous", scopes: ["*"] };
138
+ if (!identity) return;
139
+ if (opts.authorize) {
140
+ const decision = await opts.authorize(identity);
141
+ if (!decision.ok) {
142
+ sendJson(
143
+ res,
144
+ decision.status ?? 403,
145
+ { error: decision.error },
146
+ decision.retryAfterSec ? { "Retry-After": String(decision.retryAfterSec) } : {}
147
+ );
148
+ return;
149
+ }
113
150
  }
114
151
  await handleMcp(req, res);
115
152
  return;
116
153
  }
117
- res.writeHead(404, { "Content-Type": "application/json" });
118
- res.end(JSON.stringify({ error: "Not found" }));
154
+ sendJson(res, 404, { error: "Not found" });
119
155
  });
120
- return new Promise((resolve) => {
156
+ return new Promise((resolve, reject) => {
157
+ httpServer.once("error", reject);
121
158
  httpServer.listen(config.port, config.host, () => {
159
+ httpServer.off("error", reject);
122
160
  const url = `http://${config.host === "0.0.0.0" ? "localhost" : config.host}:${config.port}`;
123
161
  onReady?.(url);
124
162
  resolve(httpServer);
@@ -126,5 +164,6 @@ async function startHttpTransport(opts) {
126
164
  });
127
165
  }
128
166
  export {
167
+ HttpError,
129
168
  startHttpTransport
130
169
  };