@esankhan3/code-search-mcp 0.0.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.
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/dist/core/ast-graph-builder.js +800 -0
- package/dist/core/chunker.js +368 -0
- package/dist/core/claude-runner.js +173 -0
- package/dist/core/config.js +59 -0
- package/dist/core/cross-repo-detector.js +887 -0
- package/dist/core/embedder.js +334 -0
- package/dist/core/env-config.js +86 -0
- package/dist/core/file-walker.js +186 -0
- package/dist/core/git-diff.js +160 -0
- package/dist/core/graph-metrics.js +233 -0
- package/dist/core/indexer.js +552 -0
- package/dist/core/project-graph-builder.js +202 -0
- package/dist/core/query-classifier.js +144 -0
- package/dist/core/query-router.js +210 -0
- package/dist/core/rag-evaluator.js +213 -0
- package/dist/core/repo-profiler.js +383 -0
- package/dist/core/reranker.js +218 -0
- package/dist/core/retriever.js +242 -0
- package/dist/core/semantic-edge-detector.js +147 -0
- package/dist/core/service-mesh-inferrer.js +241 -0
- package/dist/core/structural-hasher.js +316 -0
- package/dist/core/tree-sitter-parser.js +539 -0
- package/dist/core/types.js +0 -0
- package/dist/core/vector-store.js +259 -0
- package/dist/core/workspace-detector.js +481 -0
- package/dist/index.js +177 -0
- package/dist/middleware/auth.js +109 -0
- package/dist/resources/resources.js +79 -0
- package/dist/server.js +242 -0
- package/dist/sources/github-org.js +109 -0
- package/dist/sources/local-path.js +44 -0
- package/dist/tools/graph.js +193 -0
- package/dist/tools/index-tools.js +43 -0
- package/dist/tools/profile.js +88 -0
- package/dist/tools/search.js +84 -0
- package/dist/transports/http-transport.js +162 -0
- package/dist/transports/remote-proxy.js +152 -0
- package/package.json +33 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const rateBuckets = /* @__PURE__ */ new Map();
|
|
3
|
+
function checkRateLimit(identity, maxPerMinute) {
|
|
4
|
+
const now = Date.now();
|
|
5
|
+
const bucket = rateBuckets.get(identity);
|
|
6
|
+
if (!bucket || now > bucket.resetAt) {
|
|
7
|
+
rateBuckets.set(identity, { count: 1, resetAt: now + 6e4 });
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
if (bucket.count >= maxPerMinute) return false;
|
|
11
|
+
bucket.count++;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
setInterval(() => {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const [key, bucket] of rateBuckets) {
|
|
17
|
+
if (now > bucket.resetAt) rateBuckets.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}, 3e5).unref();
|
|
20
|
+
function verifyJwt(token, secret, issuer) {
|
|
21
|
+
try {
|
|
22
|
+
const parts = token.split(".");
|
|
23
|
+
if (parts.length !== 3) return null;
|
|
24
|
+
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString());
|
|
25
|
+
if (header.alg !== "HS256") return null;
|
|
26
|
+
const data = `${parts[0]}.${parts[1]}`;
|
|
27
|
+
const expected = createHmac("sha256", secret).update(data).digest("base64url");
|
|
28
|
+
const sigBuf = Buffer.from(parts[2], "base64url");
|
|
29
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
30
|
+
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
34
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) return null;
|
|
35
|
+
if (issuer && payload.iss !== issuer) return null;
|
|
36
|
+
return { sub: payload.sub ?? "unknown", scope: payload.scope };
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function safeCompare(a, b) {
|
|
42
|
+
const bufA = Buffer.from(a);
|
|
43
|
+
const bufB = Buffer.from(b);
|
|
44
|
+
if (bufA.length !== bufB.length) return false;
|
|
45
|
+
return timingSafeEqual(bufA, bufB);
|
|
46
|
+
}
|
|
47
|
+
function createAuthMiddleware(config) {
|
|
48
|
+
return function authenticate(req, res) {
|
|
49
|
+
if (config.authMode === "none") {
|
|
50
|
+
return { mode: "anonymous", subject: "anonymous", scopes: ["*"] };
|
|
51
|
+
}
|
|
52
|
+
const authHeader = req.headers.authorization;
|
|
53
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
54
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
55
|
+
res.end(JSON.stringify({ error: "Missing Authorization header" }));
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const token = authHeader.slice(7);
|
|
59
|
+
if (config.authMode === "api-key") {
|
|
60
|
+
const matched = config.authApiKeys.some((key) => safeCompare(token, key));
|
|
61
|
+
if (!matched) {
|
|
62
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
63
|
+
res.end(JSON.stringify({ error: "Invalid API key" }));
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const identity = {
|
|
67
|
+
mode: "api-key",
|
|
68
|
+
subject: `key:${token.slice(0, 8)}...`,
|
|
69
|
+
scopes: ["*"]
|
|
70
|
+
};
|
|
71
|
+
if (!checkRateLimit(identity.subject, config.rateLimitPerMinute)) {
|
|
72
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
73
|
+
res.end(JSON.stringify({ error: "Rate limit exceeded" }));
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return identity;
|
|
77
|
+
}
|
|
78
|
+
if (config.authMode === "jwt") {
|
|
79
|
+
if (!config.authJwtSecret) {
|
|
80
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
81
|
+
res.end(JSON.stringify({ error: "JWT secret not configured" }));
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const claims = verifyJwt(token, config.authJwtSecret, config.authJwtIssuer);
|
|
85
|
+
if (!claims) {
|
|
86
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
87
|
+
res.end(JSON.stringify({ error: "Invalid or expired JWT" }));
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const identity = {
|
|
91
|
+
mode: "jwt",
|
|
92
|
+
subject: claims.sub,
|
|
93
|
+
scopes: claims.scope ? claims.scope.split(" ") : ["*"]
|
|
94
|
+
};
|
|
95
|
+
if (!checkRateLimit(identity.subject, config.rateLimitPerMinute)) {
|
|
96
|
+
res.writeHead(429, { "Content-Type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ error: "Rate limit exceeded" }));
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return identity;
|
|
101
|
+
}
|
|
102
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
103
|
+
res.end(JSON.stringify({ error: `Unknown auth mode: ${config.authMode}` }));
|
|
104
|
+
return null;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export {
|
|
108
|
+
createAuthMiddleware
|
|
109
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getKnowledgeBasePath } from "../core/config.js";
|
|
4
|
+
import { loadAllProfiles } from "../core/repo-profiler.js";
|
|
5
|
+
import { loadProfile } from "../core/repo-profiler.js";
|
|
6
|
+
function registerResources(ctx) {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
uri: `code-search://repos`,
|
|
10
|
+
name: "All Repos",
|
|
11
|
+
description: `List of all repos in project "${ctx.projectName}"`,
|
|
12
|
+
mimeType: "application/json"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
uri: `code-search://system-graph`,
|
|
16
|
+
name: "System Graph",
|
|
17
|
+
description: "Unified knowledge graph with cross-repo edges",
|
|
18
|
+
mimeType: "application/json"
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
async function handleResource(uri, ctx) {
|
|
23
|
+
try {
|
|
24
|
+
const kbPath = getKnowledgeBasePath(ctx.projectName);
|
|
25
|
+
if (uri === "code-search://repos") {
|
|
26
|
+
const profiles = loadAllProfiles(ctx.projectName);
|
|
27
|
+
return {
|
|
28
|
+
contents: [{
|
|
29
|
+
uri,
|
|
30
|
+
mimeType: "application/json",
|
|
31
|
+
text: JSON.stringify(profiles, null, 2)
|
|
32
|
+
}]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (uri === "code-search://system-graph") {
|
|
36
|
+
const graphPath = join(kbPath, "system_graph_v2.json");
|
|
37
|
+
if (!existsSync(graphPath)) {
|
|
38
|
+
return { contents: [{ uri, mimeType: "application/json", text: '{"nodes":[],"edges":[]}' }] };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
contents: [{
|
|
42
|
+
uri,
|
|
43
|
+
mimeType: "application/json",
|
|
44
|
+
text: readFileSync(graphPath, "utf-8")
|
|
45
|
+
}]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const profileMatch = uri.match(/^code-search:\/\/repo\/([^/]+)\/profile$/);
|
|
49
|
+
if (profileMatch) {
|
|
50
|
+
const profile = loadProfile(ctx.projectName, profileMatch[1]);
|
|
51
|
+
return {
|
|
52
|
+
contents: [{
|
|
53
|
+
uri,
|
|
54
|
+
mimeType: "application/json",
|
|
55
|
+
text: profile ? JSON.stringify(profile, null, 2) : "{}"
|
|
56
|
+
}]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const graphMatch = uri.match(/^code-search:\/\/repo\/([^/]+)\/graph$/);
|
|
60
|
+
if (graphMatch) {
|
|
61
|
+
const graphPath = join(kbPath, graphMatch[1], "graph.json");
|
|
62
|
+
return {
|
|
63
|
+
contents: [{
|
|
64
|
+
uri,
|
|
65
|
+
mimeType: "application/json",
|
|
66
|
+
text: existsSync(graphPath) ? readFileSync(graphPath, "utf-8") : '{"nodes":[],"links":[]}'
|
|
67
|
+
}]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { contents: [{ uri, mimeType: "text/plain", text: `Unknown resource: ${uri}` }] };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
+
return { contents: [{ uri, mimeType: "text/plain", text: `Error: ${msg}` }] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export {
|
|
77
|
+
handleResource,
|
|
78
|
+
registerResources
|
|
79
|
+
};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
ListResourcesRequestSchema,
|
|
7
|
+
ReadResourceRequestSchema
|
|
8
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { registerSearchTools, handleSearchTool } from "./tools/search.js";
|
|
12
|
+
import { registerGraphTools, handleGraphTool } from "./tools/graph.js";
|
|
13
|
+
import { registerProfileTools, handleProfileTool } from "./tools/profile.js";
|
|
14
|
+
import { registerIndexTools, handleIndexTool } from "./tools/index-tools.js";
|
|
15
|
+
import { registerResources, handleResource } from "./resources/resources.js";
|
|
16
|
+
import { getKnowledgeBasePath } from "./core/config.js";
|
|
17
|
+
import { indexFromPath } from "./core/indexer.js";
|
|
18
|
+
import { loadServerConfig } from "./core/env-config.js";
|
|
19
|
+
import { startHttpTransport } from "./transports/http-transport.js";
|
|
20
|
+
function createMcpServerInstance(ctx) {
|
|
21
|
+
const server = new Server(
|
|
22
|
+
{ name: "code-search-mcp", version: "0.1.0" },
|
|
23
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
24
|
+
);
|
|
25
|
+
const allTools = [
|
|
26
|
+
...registerSearchTools(),
|
|
27
|
+
...registerGraphTools(),
|
|
28
|
+
...registerProfileTools(),
|
|
29
|
+
...registerIndexTools()
|
|
30
|
+
];
|
|
31
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
32
|
+
tools: allTools
|
|
33
|
+
}));
|
|
34
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
35
|
+
const name = request.params.name;
|
|
36
|
+
const args = request.params.arguments ?? {};
|
|
37
|
+
const searchResult = await handleSearchTool(name, args, ctx);
|
|
38
|
+
if (searchResult) return searchResult;
|
|
39
|
+
const graphResult = await handleGraphTool(name, args, ctx);
|
|
40
|
+
if (graphResult) return graphResult;
|
|
41
|
+
const profileResult = await handleProfileTool(name, args, ctx);
|
|
42
|
+
if (profileResult) return profileResult;
|
|
43
|
+
const indexResult = await handleIndexTool(name, args, ctx);
|
|
44
|
+
if (indexResult) return indexResult;
|
|
45
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
46
|
+
});
|
|
47
|
+
const allResources = registerResources(ctx);
|
|
48
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
49
|
+
resources: allResources
|
|
50
|
+
}));
|
|
51
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
52
|
+
return handleResource(request.params.uri, ctx);
|
|
53
|
+
});
|
|
54
|
+
return server;
|
|
55
|
+
}
|
|
56
|
+
async function startServer(projectName, directoryPath) {
|
|
57
|
+
const config = loadServerConfig();
|
|
58
|
+
const ctx = {
|
|
59
|
+
projectName,
|
|
60
|
+
directoryPath,
|
|
61
|
+
indexReady: false,
|
|
62
|
+
startedAt: Date.now(),
|
|
63
|
+
indexing: {
|
|
64
|
+
status: "idle",
|
|
65
|
+
phase: null,
|
|
66
|
+
message: null,
|
|
67
|
+
percent: 0,
|
|
68
|
+
startedAt: null,
|
|
69
|
+
error: null,
|
|
70
|
+
lastSuccess: null,
|
|
71
|
+
lastDurationMs: 0,
|
|
72
|
+
history: []
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const llmInfo = config.llmMode === "none" ? "disabled (profiling + service mesh skipped)" : config.llmMode === "api" ? `api \u2192 ${config.llmProvider}/${config.llmModel}${config.llmApiKey ? "" : " (WARNING: no API key!)"}` : `cli \u2192 ${config.claudeBin}`;
|
|
76
|
+
console.error(`[code-search-mcp] LLM: ${llmInfo}`);
|
|
77
|
+
await autoIndex(ctx);
|
|
78
|
+
if (config.transport === "stdio") {
|
|
79
|
+
const server = createMcpServerInstance(ctx);
|
|
80
|
+
const transport = new StdioServerTransport();
|
|
81
|
+
await server.connect(transport);
|
|
82
|
+
console.error(`[code-search-mcp] Server running for "${projectName}" (stdio)`);
|
|
83
|
+
} else {
|
|
84
|
+
if (config.authMode === "none" && config.host !== "127.0.0.1" && config.host !== "localhost") {
|
|
85
|
+
console.error(`[code-search-mcp] WARNING: Auth is disabled but server binds to ${config.host}. Any machine on the network can access your code search API.`);
|
|
86
|
+
console.error(`[code-search-mcp] Set CODE_SEARCH_AUTH_MODE=api-key or CODE_SEARCH_HOST=127.0.0.1 for security.`);
|
|
87
|
+
}
|
|
88
|
+
await startHttpTransport({
|
|
89
|
+
config,
|
|
90
|
+
createMcpServer: async () => ({
|
|
91
|
+
server: createMcpServerInstance(ctx)
|
|
92
|
+
}),
|
|
93
|
+
onReady: (url) => {
|
|
94
|
+
console.error(`[code-search-mcp] Server running for "${projectName}" at ${url}/mcp`);
|
|
95
|
+
console.error(`[code-search-mcp] Health: GET ${url}/health`);
|
|
96
|
+
console.error(`[code-search-mcp] Status: GET ${url}/status`);
|
|
97
|
+
console.error(`[code-search-mcp] Index: POST ${url}/index`);
|
|
98
|
+
console.error(`[code-search-mcp] Auth: ${config.authMode}`);
|
|
99
|
+
},
|
|
100
|
+
getHealth: () => ({
|
|
101
|
+
project: ctx.projectName,
|
|
102
|
+
indexReady: ctx.indexReady,
|
|
103
|
+
indexing: ctx.indexing.status,
|
|
104
|
+
uptime: Math.floor((Date.now() - ctx.startedAt) / 1e3),
|
|
105
|
+
transport: config.transport,
|
|
106
|
+
authMode: config.authMode
|
|
107
|
+
}),
|
|
108
|
+
getStatus: () => ({
|
|
109
|
+
project: ctx.projectName,
|
|
110
|
+
directoryPath: ctx.directoryPath,
|
|
111
|
+
indexReady: ctx.indexReady,
|
|
112
|
+
uptime: Math.floor((Date.now() - ctx.startedAt) / 1e3),
|
|
113
|
+
indexing: {
|
|
114
|
+
...ctx.indexing,
|
|
115
|
+
elapsedMs: ctx.indexing.startedAt ? Date.now() - ctx.indexing.startedAt : null
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
onIndex: async (body) => {
|
|
119
|
+
const { resolve } = await import("node:path");
|
|
120
|
+
const { existsSync: existsSync2 } = await import("node:fs");
|
|
121
|
+
const dirPath = resolve(body.path);
|
|
122
|
+
if (!existsSync2(dirPath)) {
|
|
123
|
+
throw new Error(`Path does not exist: ${dirPath}`);
|
|
124
|
+
}
|
|
125
|
+
if (ctx.indexing.status === "indexing") {
|
|
126
|
+
throw new Error(`Indexing already in progress (phase: ${ctx.indexing.phase}). Wait for it to complete or check GET /status.`);
|
|
127
|
+
}
|
|
128
|
+
const project = body.project || dirPath.split("/").filter(Boolean).pop() || "project";
|
|
129
|
+
const stats = await trackedIndex(ctx, project, dirPath, {
|
|
130
|
+
force: body.force,
|
|
131
|
+
label: "admin-index"
|
|
132
|
+
});
|
|
133
|
+
ctx.projectName = project;
|
|
134
|
+
ctx.directoryPath = dirPath;
|
|
135
|
+
return {
|
|
136
|
+
status: "ok",
|
|
137
|
+
project,
|
|
138
|
+
path: dirPath,
|
|
139
|
+
chunks: stats.totalChunks,
|
|
140
|
+
repos: stats.repos.length,
|
|
141
|
+
crossRepoEdges: stats.crossRepoEdges,
|
|
142
|
+
durationMs: stats.indexDurationMs
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const reindexIntervalMs = parseReindexInterval();
|
|
148
|
+
if (reindexIntervalMs > 0 && ctx.directoryPath) {
|
|
149
|
+
console.error(`[code-search-mcp] Auto-reindex every ${Math.round(reindexIntervalMs / 6e4)}m`);
|
|
150
|
+
setInterval(async () => {
|
|
151
|
+
if (!ctx.directoryPath || ctx.indexing.status === "indexing") return;
|
|
152
|
+
try {
|
|
153
|
+
await trackedIndex(ctx, ctx.projectName, ctx.directoryPath, { label: "auto-reindex" });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error(`[auto-reindex] Failed:`, err);
|
|
156
|
+
}
|
|
157
|
+
}, reindexIntervalMs).unref();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function parseReindexInterval() {
|
|
161
|
+
const raw = process.env.CODE_SEARCH_REINDEX_INTERVAL?.trim();
|
|
162
|
+
if (!raw || raw === "0" || raw === "none") return 0;
|
|
163
|
+
const match = raw.match(/^(\d+)(m|h)$/);
|
|
164
|
+
if (!match) {
|
|
165
|
+
console.error(`[code-search-mcp] Invalid REINDEX_INTERVAL "${raw}" \u2014 use "30m", "1h", etc. Disabling.`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
const value = parseInt(match[1], 10);
|
|
169
|
+
const unit = match[2];
|
|
170
|
+
return unit === "h" ? value * 60 * 6e4 : value * 6e4;
|
|
171
|
+
}
|
|
172
|
+
const MAX_HISTORY = 50;
|
|
173
|
+
function pushHistory(ctx, type, message) {
|
|
174
|
+
ctx.indexing.history.push({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), type, message });
|
|
175
|
+
if (ctx.indexing.history.length > MAX_HISTORY) {
|
|
176
|
+
ctx.indexing.history = ctx.indexing.history.slice(-MAX_HISTORY);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function trackedIndex(ctx, project, dirPath, opts) {
|
|
180
|
+
const label = opts?.label ?? "index";
|
|
181
|
+
ctx.indexing.status = "indexing";
|
|
182
|
+
ctx.indexing.phase = "starting";
|
|
183
|
+
ctx.indexing.message = `Starting ${label}...`;
|
|
184
|
+
ctx.indexing.percent = 0;
|
|
185
|
+
ctx.indexing.startedAt = Date.now();
|
|
186
|
+
ctx.indexing.error = null;
|
|
187
|
+
pushHistory(ctx, "start", `${label}: started for "${project}" at ${dirPath}`);
|
|
188
|
+
try {
|
|
189
|
+
const stats = await indexFromPath(project, dirPath, {
|
|
190
|
+
force: opts?.force,
|
|
191
|
+
onProgress: (m) => {
|
|
192
|
+
ctx.indexing.message = m;
|
|
193
|
+
console.error(`[${label}] ${m}`);
|
|
194
|
+
},
|
|
195
|
+
onDetailedProgress: (p) => {
|
|
196
|
+
ctx.indexing.phase = p.phase;
|
|
197
|
+
ctx.indexing.percent = p.percent;
|
|
198
|
+
ctx.indexing.message = p.message;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
ctx.indexReady = true;
|
|
202
|
+
ctx.indexing.status = "idle";
|
|
203
|
+
ctx.indexing.phase = null;
|
|
204
|
+
ctx.indexing.percent = 100;
|
|
205
|
+
ctx.indexing.lastSuccess = (/* @__PURE__ */ new Date()).toISOString();
|
|
206
|
+
ctx.indexing.lastDurationMs = stats.indexDurationMs;
|
|
207
|
+
ctx.indexing.message = `Completed: ${stats.totalChunks} chunks, ${stats.repos.length} repos in ${Math.round(stats.indexDurationMs / 1e3)}s`;
|
|
208
|
+
pushHistory(ctx, "complete", ctx.indexing.message);
|
|
209
|
+
return stats;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
212
|
+
ctx.indexing.status = "error";
|
|
213
|
+
ctx.indexing.error = msg;
|
|
214
|
+
ctx.indexing.message = `Failed: ${msg}`;
|
|
215
|
+
pushHistory(ctx, "error", msg);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function autoIndex(ctx) {
|
|
220
|
+
try {
|
|
221
|
+
const kbPath = getKnowledgeBasePath(ctx.projectName);
|
|
222
|
+
const hasLanceDB = existsSync(join(kbPath, "lancedb"));
|
|
223
|
+
const hasGraph = existsSync(join(kbPath, "system_graph_v2.json"));
|
|
224
|
+
if (hasLanceDB && hasGraph) {
|
|
225
|
+
ctx.indexReady = true;
|
|
226
|
+
console.error(`[code-search-mcp] Index loaded for "${ctx.projectName}"`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!ctx.directoryPath) {
|
|
230
|
+
console.error(`[code-search-mcp] No index found and no directory path \u2014 tools will return empty results`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.error(`[code-search-mcp] No index found \u2014 building from ${ctx.directoryPath}...`);
|
|
234
|
+
await trackedIndex(ctx, ctx.projectName, ctx.directoryPath, { label: "auto-index" });
|
|
235
|
+
console.error(`[code-search-mcp] Index ready.`);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
console.error(`[code-search-mcp] Auto-index failed:`, err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
export {
|
|
241
|
+
startServer
|
|
242
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
async function cloneOrUpdateOrg(org, opts) {
|
|
6
|
+
const log = opts?.onProgress ?? ((m) => console.error(`[github] ${m}`));
|
|
7
|
+
const token = opts?.token ?? process.env.GITHUB_TOKEN;
|
|
8
|
+
const workspacePath = opts?.workspacePath ?? join(homedir(), ".code-search", org);
|
|
9
|
+
const maxRepos = opts?.maxRepos ?? 500;
|
|
10
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
11
|
+
log(`Fetching repos from github:${org}...`);
|
|
12
|
+
let orgRepos;
|
|
13
|
+
try {
|
|
14
|
+
orgRepos = listReposViaGhCli(org, maxRepos);
|
|
15
|
+
log(`Found ${orgRepos.length} repos via gh CLI`);
|
|
16
|
+
} catch {
|
|
17
|
+
if (!token) {
|
|
18
|
+
throw new Error("gh CLI not available and no GITHUB_TOKEN set. Install gh CLI or set GITHUB_TOKEN.");
|
|
19
|
+
}
|
|
20
|
+
orgRepos = await listReposViaApi(org, token, maxRepos);
|
|
21
|
+
log(`Found ${orgRepos.length} repos via GitHub API`);
|
|
22
|
+
}
|
|
23
|
+
if (opts?.pattern) {
|
|
24
|
+
const pattern = opts.pattern.replace(/\*/g, ".*");
|
|
25
|
+
const re = new RegExp(`^${pattern}$`, "i");
|
|
26
|
+
orgRepos = orgRepos.filter((r) => re.test(r.name));
|
|
27
|
+
log(`Filtered to ${orgRepos.length} repos matching "${opts.pattern}"`);
|
|
28
|
+
}
|
|
29
|
+
const results = [];
|
|
30
|
+
for (let i = 0; i < orgRepos.length; i++) {
|
|
31
|
+
const repo = orgRepos[i];
|
|
32
|
+
const repoPath = join(workspacePath, repo.name);
|
|
33
|
+
if (existsSync(join(repoPath, ".git"))) {
|
|
34
|
+
try {
|
|
35
|
+
execSync("git pull --ff-only --quiet", {
|
|
36
|
+
cwd: repoPath,
|
|
37
|
+
stdio: "pipe",
|
|
38
|
+
timeout: 3e4
|
|
39
|
+
});
|
|
40
|
+
log(` [${i + 1}/${orgRepos.length}] Updated ${repo.name}`);
|
|
41
|
+
} catch {
|
|
42
|
+
log(` [${i + 1}/${orgRepos.length}] ${repo.name} \u2014 pull failed (skipping)`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
try {
|
|
46
|
+
const cloneCmd = token ? `git -c "http.extraHeader=Authorization: Bearer ${token}" clone --depth 1 --quiet "${repo.cloneUrl}" "${repoPath}"` : `git clone --depth 1 --quiet "${repo.cloneUrl}" "${repoPath}"`;
|
|
47
|
+
execSync(cloneCmd, {
|
|
48
|
+
stdio: "pipe",
|
|
49
|
+
timeout: 12e4
|
|
50
|
+
});
|
|
51
|
+
log(` [${i + 1}/${orgRepos.length}] Cloned ${repo.name}`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
log(` [${i + 1}/${orgRepos.length}] ${repo.name} \u2014 clone failed: ${msg.slice(0, 80)}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
results.push({
|
|
59
|
+
name: repo.name,
|
|
60
|
+
path: repoPath,
|
|
61
|
+
language: repo.language || "unknown"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
log(`Ready: ${results.length} repos in ${workspacePath}`);
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
function listReposViaGhCli(org, limit) {
|
|
68
|
+
const output = execSync(
|
|
69
|
+
`gh repo list "${org}" --json name,url,primaryLanguage --limit ${limit} --no-archived`,
|
|
70
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 3e4 }
|
|
71
|
+
);
|
|
72
|
+
const repos = JSON.parse(output);
|
|
73
|
+
return repos.map((r) => ({
|
|
74
|
+
name: r.name,
|
|
75
|
+
cloneUrl: r.url.endsWith(".git") ? r.url : `${r.url}.git`,
|
|
76
|
+
language: (r.primaryLanguage?.name ?? "").toLowerCase()
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
async function listReposViaApi(org, token, limit) {
|
|
80
|
+
const allRepos = [];
|
|
81
|
+
let page = 1;
|
|
82
|
+
while (allRepos.length < limit) {
|
|
83
|
+
const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&type=sources`;
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `token ${token}`,
|
|
87
|
+
Accept: "application/vnd.github.v3+json"
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
|
|
92
|
+
}
|
|
93
|
+
const repos = await res.json();
|
|
94
|
+
if (repos.length === 0) break;
|
|
95
|
+
for (const r of repos) {
|
|
96
|
+
if (r.archived) continue;
|
|
97
|
+
allRepos.push({
|
|
98
|
+
name: r.name,
|
|
99
|
+
cloneUrl: r.clone_url,
|
|
100
|
+
language: (r.language ?? "").toLowerCase()
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
page++;
|
|
104
|
+
}
|
|
105
|
+
return allRepos.slice(0, limit);
|
|
106
|
+
}
|
|
107
|
+
export {
|
|
108
|
+
cloneOrUpdateOrg
|
|
109
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", ".next", "build", "__pycache__", ".venv", "vendor", "target"]);
|
|
4
|
+
function discoverLocalRepos(directoryPath) {
|
|
5
|
+
if (!existsSync(directoryPath)) return [];
|
|
6
|
+
const repos = [];
|
|
7
|
+
if (existsSync(join(directoryPath, ".git"))) {
|
|
8
|
+
repos.push({
|
|
9
|
+
name: basename(directoryPath),
|
|
10
|
+
path: directoryPath,
|
|
11
|
+
language: detectLanguage(directoryPath)
|
|
12
|
+
});
|
|
13
|
+
return repos;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
for (const entry of readdirSync(directoryPath)) {
|
|
17
|
+
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
18
|
+
const fullPath = join(directoryPath, entry);
|
|
19
|
+
try {
|
|
20
|
+
if (!statSync(fullPath).isDirectory()) continue;
|
|
21
|
+
} catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (!existsSync(join(fullPath, ".git"))) continue;
|
|
25
|
+
repos.push({ name: entry, path: fullPath, language: detectLanguage(fullPath) });
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
return repos;
|
|
30
|
+
}
|
|
31
|
+
function detectLanguage(repoPath) {
|
|
32
|
+
if (existsSync(join(repoPath, "go.mod"))) return "go";
|
|
33
|
+
if (existsSync(join(repoPath, "Cargo.toml"))) return "rust";
|
|
34
|
+
if (existsSync(join(repoPath, "pom.xml")) || existsSync(join(repoPath, "build.gradle"))) return "java";
|
|
35
|
+
if (existsSync(join(repoPath, "composer.json"))) return "php";
|
|
36
|
+
if (existsSync(join(repoPath, "pyproject.toml")) || existsSync(join(repoPath, "setup.py"))) return "python";
|
|
37
|
+
if (existsSync(join(repoPath, "package.json"))) {
|
|
38
|
+
return existsSync(join(repoPath, "tsconfig.json")) ? "typescript" : "javascript";
|
|
39
|
+
}
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
discoverLocalRepos
|
|
44
|
+
};
|