maven-indexer-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2024 tangcent
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Maven Indexer MCP Server
2
+
3
+ A Model Context Protocol (MCP) server that indexes your local Maven repository (`~/.m2/repository`) and provides AI agents with tools to search for Java classes, method signatures, and source code.
4
+
5
+ ## Features
6
+
7
+ * **Semantic Class Search**: Search for classes by name (e.g., `StringUtils`) or purpose (e.g., `JsonToXml`).
8
+ * **On-Demand Analysis**: Extracts method signatures (`javap`) and Javadocs directly from JARs without extracting the entire archive.
9
+ * **Source Code Retrieval**: Provides full source code if `-sources.jar` is available.
10
+ * **Real-time Monitoring**: Watches the Maven repository for changes (e.g., new `mvn install`) and automatically updates the index.
11
+ * **Efficient Persistence**: Uses SQLite to store the index, handling large repositories with minimal memory footprint.
12
+
13
+ ## Getting Started
14
+
15
+ Add the following config to your MCP client:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "maven-indexer": {
21
+ "command": "npx",
22
+ "args": ["-y", "maven-indexer-mcp@latest"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ This will automatically download and run the latest version of the server. It will auto-detect your Maven repository location (usually `~/.m2/repository`).
29
+
30
+ ### Configuration (Optional)
31
+
32
+ If the auto-detection fails, or if you want to filter which packages are indexed, you can add environment variables to the configuration:
33
+
34
+ * **`MAVEN_REPO`**: Absolute path to your local Maven repository (e.g., `/Users/yourname/.m2/repository`). Use this if your repository is in a non-standard location.
35
+ * **`INCLUDED_PACKAGES`**: Comma-separated list of package patterns to index (e.g., `com.mycompany.*,org.example.*`). Default is `*` (index everything).
36
+
37
+ Example with optional configuration:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "maven-indexer": {
43
+ "command": "npx",
44
+ "args": ["-y", "maven-indexer-mcp@latest"],
45
+ "env": {
46
+ "MAVEN_REPO": "/Users/yourname/.m2/repository",
47
+ "INCLUDED_PACKAGES": "com.mycompany.*"
48
+ }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### Local Development
55
+
56
+ If you prefer to run from source:
57
+
58
+ 1. Clone the repository:
59
+ ```bash
60
+ git clone https://github.com/tangcent/maven-indexer-mcp.git
61
+ cd maven-indexer-mcp
62
+ ```
63
+
64
+ 2. Install dependencies and build:
65
+ ```bash
66
+ npm install
67
+ npm run build
68
+ ```
69
+
70
+ 3. Use the absolute path in your config:
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "maven-indexer": {
75
+ "command": "node",
76
+ "args": ["/absolute/path/to/maven-indexer-mcp/build/index.js"]
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ ## Available Tools
83
+
84
+ * **`search_classes`**: Search for Java classes.
85
+ * Input: `className` (e.g., "StringUtils", "Json parser")
86
+ * Output: List of matching classes with their artifacts.
87
+ * **`get_class_details`**: Get detailed information about a class.
88
+ * Input: `className`, `artifactId`, `type` ("signatures", "docs", "source")
89
+ * Output: Method signatures, Javadocs, or full source code.
90
+ * **`search_artifacts`**: Search for artifacts by coordinate (groupId, artifactId).
91
+
92
+ ## Development
93
+
94
+ * **Run tests**: `npm test`
95
+ * **Watch mode**: `npm run watch`
96
+
97
+ ## License
98
+
99
+ ISC
@@ -0,0 +1,99 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import xml2js from 'xml2js';
5
+ export class Config {
6
+ static instance;
7
+ localRepository = "";
8
+ javaBinary = "java";
9
+ includedPackages = ["*"];
10
+ constructor() { }
11
+ static async getInstance() {
12
+ if (!Config.instance) {
13
+ Config.instance = new Config();
14
+ await Config.instance.load();
15
+ }
16
+ return Config.instance;
17
+ }
18
+ // For testing
19
+ static reset() {
20
+ Config.instance = undefined;
21
+ }
22
+ async load() {
23
+ let repoPath = null;
24
+ // 1. Check environment variable
25
+ if (process.env.MAVEN_REPO_PATH) {
26
+ repoPath = process.env.MAVEN_REPO_PATH;
27
+ }
28
+ else if (process.env.MAVEN_REPO) {
29
+ repoPath = process.env.MAVEN_REPO;
30
+ }
31
+ // 2. Try user settings
32
+ if (!repoPath) {
33
+ const homeDir = os.homedir();
34
+ const userSettingsPath = path.join(homeDir, '.m2', 'settings.xml');
35
+ if (await this.fileExists(userSettingsPath)) {
36
+ repoPath = await this.parseSettings(userSettingsPath);
37
+ }
38
+ }
39
+ // 3. Try global settings if not found
40
+ if (!repoPath && process.env.M2_HOME) {
41
+ const globalSettingsPath = path.join(process.env.M2_HOME, 'conf', 'settings.xml');
42
+ if (await this.fileExists(globalSettingsPath)) {
43
+ repoPath = await this.parseSettings(globalSettingsPath);
44
+ }
45
+ }
46
+ // 4. Default
47
+ if (!repoPath) {
48
+ repoPath = path.join(os.homedir(), '.m2', 'repository');
49
+ }
50
+ this.localRepository = repoPath;
51
+ // Load Java Path
52
+ if (process.env.JAVA_HOME) {
53
+ this.javaBinary = path.join(process.env.JAVA_HOME, 'bin', 'java');
54
+ }
55
+ // Allow explicit override
56
+ if (process.env.JAVA_PATH) {
57
+ this.javaBinary = process.env.JAVA_PATH;
58
+ }
59
+ // Load Included Packages
60
+ if (process.env.INCLUDED_PACKAGES) {
61
+ this.includedPackages = process.env.INCLUDED_PACKAGES.split(',')
62
+ .map(p => p.trim())
63
+ .filter(p => p.length > 0);
64
+ }
65
+ // Log to stderr so it doesn't interfere with MCP protocol on stdout
66
+ console.error(`Using local repository: ${this.localRepository}`);
67
+ console.error(`Using Java binary: ${this.javaBinary}`);
68
+ console.error(`Included packages: ${JSON.stringify(this.includedPackages)}`);
69
+ }
70
+ getJavapPath() {
71
+ if (this.javaBinary === 'java')
72
+ return 'javap';
73
+ const dir = path.dirname(this.javaBinary);
74
+ return path.join(dir, 'javap');
75
+ }
76
+ async fileExists(filePath) {
77
+ try {
78
+ await fs.access(filePath);
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ async parseSettings(filePath) {
86
+ try {
87
+ const content = await fs.readFile(filePath, 'utf-8');
88
+ const parser = new xml2js.Parser();
89
+ const result = await parser.parseStringPromise(content);
90
+ if (result.settings && result.settings.localRepository && result.settings.localRepository[0]) {
91
+ return result.settings.localRepository[0];
92
+ }
93
+ }
94
+ catch (error) {
95
+ console.error(`Failed to parse ${filePath}:`, error);
96
+ }
97
+ return null;
98
+ }
99
+ }
@@ -0,0 +1,53 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'path';
3
+ export class DB {
4
+ static instance;
5
+ db;
6
+ constructor() {
7
+ // Check environment variable for DB path (useful for testing)
8
+ const dbName = process.env.DB_FILE || 'maven-index.sqlite';
9
+ const dbPath = path.join(process.cwd(), dbName);
10
+ this.db = new Database(dbPath);
11
+ this.initSchema();
12
+ }
13
+ static getInstance() {
14
+ if (!DB.instance) {
15
+ DB.instance = new DB();
16
+ }
17
+ return DB.instance;
18
+ }
19
+ initSchema() {
20
+ this.db.exec(`
21
+ CREATE TABLE IF NOT EXISTS artifacts (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ group_id TEXT NOT NULL,
24
+ artifact_id TEXT NOT NULL,
25
+ version TEXT NOT NULL,
26
+ abspath TEXT NOT NULL,
27
+ has_source INTEGER DEFAULT 0,
28
+ UNIQUE(group_id, artifact_id, version)
29
+ );
30
+
31
+ CREATE VIRTUAL TABLE IF NOT EXISTS classes_fts USING fts5(
32
+ artifact_id UNINDEXED,
33
+ class_name, -- Fully qualified name
34
+ simple_name, -- Just the class name
35
+ tokenize="trigram"
36
+ );
37
+
38
+ -- Helper table to track indexed artifacts to avoid re-indexing unchanged ones (simplification: just track ID)
39
+ CREATE TABLE IF NOT EXISTS indexed_artifacts (
40
+ artifact_id INTEGER PRIMARY KEY
41
+ );
42
+ `);
43
+ }
44
+ getDb() {
45
+ return this.db;
46
+ }
47
+ prepare(sql) {
48
+ return this.db.prepare(sql);
49
+ }
50
+ transaction(fn) {
51
+ return this.db.transaction(fn)();
52
+ }
53
+ }
package/build/index.js ADDED
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import path from 'path';
6
+ import { Indexer } from "./indexer.js";
7
+ import { SourceParser } from "./source_parser.js";
8
+ const server = new Server({
9
+ name: "maven-indexer",
10
+ version: "1.0.0",
11
+ }, {
12
+ capabilities: {
13
+ tools: {},
14
+ },
15
+ });
16
+ // Start indexing in the background
17
+ const indexer = Indexer.getInstance();
18
+ // We trigger indexing but don't await it so server can start
19
+ indexer.index().then(() => {
20
+ // Start watching for changes after initial index
21
+ return indexer.startWatch();
22
+ }).catch(err => console.error("Initial indexing failed:", err));
23
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
24
+ return {
25
+ tools: [
26
+ {
27
+ name: "search_artifacts",
28
+ description: "Search for artifacts in the local Maven repository",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: {
32
+ query: {
33
+ type: "string",
34
+ description: "Search query (groupId, artifactId, or keyword)",
35
+ },
36
+ },
37
+ required: ["query"],
38
+ },
39
+ },
40
+ {
41
+ name: "search_classes",
42
+ description: "Search for Java classes. Can be used to find classes by name or to find classes for a specific purpose (by searching keywords in class names).",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {
46
+ className: {
47
+ type: "string",
48
+ description: "Fully qualified class name, partial name, or keywords describing the class purpose (e.g. 'JsonToXml').",
49
+ },
50
+ },
51
+ required: ["className"],
52
+ },
53
+ },
54
+ {
55
+ name: "get_class_details",
56
+ description: "Get details about a specific class from an artifact, including method signatures and javadocs (if source is available).",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ className: {
61
+ type: "string",
62
+ description: "Fully qualified class name",
63
+ },
64
+ artifactId: {
65
+ type: "number",
66
+ description: "The internal ID of the artifact (returned by search_classes)",
67
+ },
68
+ type: {
69
+ type: "string",
70
+ enum: ["signatures", "docs", "source"],
71
+ description: "Type of detail to retrieve: 'signatures' (methods), 'docs' (javadocs + methods), 'source' (full source code).",
72
+ }
73
+ },
74
+ required: ["className", "artifactId", "type"],
75
+ },
76
+ },
77
+ {
78
+ name: "refresh_index",
79
+ description: "Trigger a re-scan of the Maven repository",
80
+ inputSchema: {
81
+ type: "object",
82
+ properties: {},
83
+ },
84
+ }
85
+ ],
86
+ };
87
+ });
88
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
89
+ if (request.params.name === "search_artifacts") {
90
+ const query = String(request.params.arguments?.query);
91
+ const matches = indexer.search(query);
92
+ // Limit results to avoid overflow
93
+ const limitedMatches = matches.slice(0, 20);
94
+ const text = limitedMatches.length > 0
95
+ ? limitedMatches.map(a => `[ID: ${a.id}] ${a.groupId}:${a.artifactId}:${a.version} (Has Source: ${a.hasSource})`).join("\n")
96
+ : "No artifacts found matching the query.";
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: `Found ${matches.length} matches${matches.length > 20 ? ' (showing first 20)' : ''}:\n${text}`,
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ if (request.params.name === "search_classes") {
107
+ const className = String(request.params.arguments?.className);
108
+ const matches = indexer.searchClass(className);
109
+ const text = matches.length > 0
110
+ ? matches.map(m => {
111
+ // Group by artifact ID to allow easy selection
112
+ const artifacts = m.artifacts.slice(0, 5).map(a => `[ID: ${a.id}] ${a.groupId}:${a.artifactId}:${a.version}${a.hasSource ? ' (Has Source)' : ''}`).join("\n ");
113
+ const more = m.artifacts.length > 5 ? `\n ... (${m.artifacts.length - 5} more versions)` : '';
114
+ return `Class: ${m.className}\n ${artifacts}${more}`;
115
+ }).join("\n\n")
116
+ : "No classes found matching the query. Try different keywords.";
117
+ return {
118
+ content: [{ type: "text", text }]
119
+ };
120
+ }
121
+ if (request.params.name === "get_class_details") {
122
+ const className = String(request.params.arguments?.className);
123
+ const artifactId = Number(request.params.arguments?.artifactId);
124
+ const type = String(request.params.arguments?.type);
125
+ const artifact = indexer.getArtifactById(artifactId);
126
+ if (!artifact) {
127
+ return { content: [{ type: "text", text: "Artifact not found." }] };
128
+ }
129
+ let jarPath;
130
+ if (type === 'signatures') {
131
+ // Use Main JAR for signatures (javap)
132
+ jarPath = path.join(artifact.abspath, `${artifact.artifactId}-${artifact.version}.jar`);
133
+ }
134
+ else {
135
+ // Use Source JAR for docs and full source
136
+ if (!artifact.hasSource) {
137
+ return { content: [{ type: "text", text: `Artifact ${artifact.groupId}:${artifact.artifactId}:${artifact.version} does not have a sources jar available locally.` }] };
138
+ }
139
+ jarPath = path.join(artifact.abspath, `${artifact.artifactId}-${artifact.version}-sources.jar`);
140
+ }
141
+ try {
142
+ const detail = await SourceParser.getClassDetail(jarPath, className, type);
143
+ if (!detail) {
144
+ return { content: [{ type: "text", text: `Class ${className} not found in ${type === 'signatures' ? 'artifact' : 'sources'} of ${artifact.artifactId}.` }] };
145
+ }
146
+ let resultText = `Class: ${detail.className}\n\n`;
147
+ if (type === 'source') {
148
+ resultText += "```java\n" + detail.source + "\n```";
149
+ }
150
+ else {
151
+ if (detail.doc) {
152
+ resultText += "Documentation:\n" + detail.doc + "\n\n";
153
+ }
154
+ if (detail.signatures) {
155
+ resultText += "Methods:\n" + detail.signatures.join("\n") + "\n";
156
+ }
157
+ }
158
+ return { content: [{ type: "text", text: resultText }] };
159
+ }
160
+ catch (e) {
161
+ return { content: [{ type: "text", text: `Error reading source: ${e.message}` }] };
162
+ }
163
+ }
164
+ if (request.params.name === "refresh_index") {
165
+ // Re-run index
166
+ indexer.index().catch(console.error);
167
+ return {
168
+ content: [{ type: "text", text: "Index refresh started." }]
169
+ };
170
+ }
171
+ throw new Error("Tool not found");
172
+ });
173
+ const transport = new StdioServerTransport();
174
+ await server.connect(transport);
@@ -0,0 +1,322 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import path from 'path';
4
+ import yauzl from 'yauzl';
5
+ import chokidar from 'chokidar';
6
+ import { Config } from './config.js';
7
+ import { DB } from './db/index.js';
8
+ export class Indexer {
9
+ static instance;
10
+ isIndexing = false;
11
+ watcher = null;
12
+ debounceTimer = null;
13
+ constructor() { }
14
+ static getInstance() {
15
+ if (!Indexer.instance) {
16
+ Indexer.instance = new Indexer();
17
+ }
18
+ return Indexer.instance;
19
+ }
20
+ async startWatch() {
21
+ const config = await Config.getInstance();
22
+ const repoPath = config.localRepository;
23
+ if (!repoPath || !fsSync.existsSync(repoPath)) {
24
+ console.error("Repository path not found, skipping watch mode.");
25
+ return;
26
+ }
27
+ if (this.watcher) {
28
+ return;
29
+ }
30
+ console.error(`Starting file watcher on ${repoPath}...`);
31
+ this.watcher = chokidar.watch(repoPath, {
32
+ ignored: /(^|[\/\\])\../, // ignore dotfiles
33
+ persistent: true,
34
+ ignoreInitial: true,
35
+ depth: 10 // Limit depth to avoid too much overhead? Standard maven repo depth is around 3-5
36
+ });
37
+ const onChange = () => {
38
+ if (this.debounceTimer)
39
+ clearTimeout(this.debounceTimer);
40
+ this.debounceTimer = setTimeout(() => {
41
+ console.error("Repository change detected. Triggering re-index...");
42
+ this.index().catch(console.error);
43
+ }, 5000); // Debounce for 5 seconds
44
+ };
45
+ this.watcher
46
+ .on('add', (path) => {
47
+ if (path.endsWith('.jar') || path.endsWith('.pom'))
48
+ onChange();
49
+ })
50
+ .on('unlink', (path) => {
51
+ if (path.endsWith('.jar') || path.endsWith('.pom'))
52
+ onChange();
53
+ });
54
+ }
55
+ async index() {
56
+ if (this.isIndexing)
57
+ return;
58
+ this.isIndexing = true;
59
+ console.error("Starting index...");
60
+ const config = await Config.getInstance();
61
+ const repoPath = config.localRepository;
62
+ const db = DB.getInstance();
63
+ if (!repoPath) {
64
+ console.error("No local repository path found.");
65
+ this.isIndexing = false;
66
+ return;
67
+ }
68
+ try {
69
+ // 1. Scan for artifacts
70
+ console.error("Scanning repository structure...");
71
+ const artifacts = await this.scanRepository(repoPath);
72
+ console.error(`Found ${artifacts.length} artifacts on disk.`);
73
+ // 2. Persist artifacts and determine what needs indexing
74
+ const artifactsToIndex = [];
75
+ db.transaction(() => {
76
+ const insertArtifact = db.prepare(`
77
+ INSERT OR IGNORE INTO artifacts (group_id, artifact_id, version, abspath, has_source)
78
+ VALUES (@groupId, @artifactId, @version, @abspath, @hasSource)
79
+ `);
80
+ const selectId = db.prepare(`
81
+ SELECT id FROM artifacts
82
+ WHERE group_id = @groupId AND artifact_id = @artifactId AND version = @version
83
+ `);
84
+ const checkIndexed = db.prepare(`
85
+ SELECT 1 FROM indexed_artifacts WHERE artifact_id = ?
86
+ `);
87
+ for (const art of artifacts) {
88
+ insertArtifact.run({
89
+ ...art,
90
+ hasSource: art.hasSource ? 1 : 0
91
+ });
92
+ const row = selectId.get({
93
+ groupId: art.groupId,
94
+ artifactId: art.artifactId,
95
+ version: art.version
96
+ });
97
+ if (row) {
98
+ art.id = row.id;
99
+ const isIndexed = checkIndexed.get(art.id);
100
+ if (!isIndexed) {
101
+ artifactsToIndex.push(art);
102
+ }
103
+ }
104
+ }
105
+ });
106
+ console.error(`${artifactsToIndex.length} artifacts need indexing.`);
107
+ // 3. Scan JARs for classes and update DB
108
+ const CHUNK_SIZE = 50;
109
+ let processedCount = 0;
110
+ for (let i = 0; i < artifactsToIndex.length; i += CHUNK_SIZE) {
111
+ const chunk = artifactsToIndex.slice(i, i + CHUNK_SIZE);
112
+ await Promise.all(chunk.map(artifact => this.indexArtifactClasses(artifact)));
113
+ processedCount += chunk.length;
114
+ if (processedCount % 100 === 0) {
115
+ console.error(`Processed ${processedCount}/${artifactsToIndex.length} artifacts...`);
116
+ }
117
+ }
118
+ console.error(`Indexing complete.`);
119
+ }
120
+ catch (e) {
121
+ console.error("Indexing failed", e);
122
+ }
123
+ finally {
124
+ this.isIndexing = false;
125
+ }
126
+ }
127
+ async scanRepository(rootDir) {
128
+ const results = [];
129
+ const scanDir = async (dir) => {
130
+ let entries;
131
+ try {
132
+ entries = await fs.readdir(dir, { withFileTypes: true });
133
+ }
134
+ catch (e) {
135
+ return;
136
+ }
137
+ const pomFiles = entries.filter(e => e.isFile() && e.name.endsWith('.pom'));
138
+ if (pomFiles.length > 0) {
139
+ const version = path.basename(dir);
140
+ const artifactDir = path.dirname(dir);
141
+ const artifactId = path.basename(artifactDir);
142
+ const groupDir = path.dirname(artifactDir);
143
+ const relGroupPath = path.relative(rootDir, groupDir);
144
+ const groupId = relGroupPath.split(path.sep).join('.');
145
+ if (groupId && artifactId && version && !groupId.startsWith('..')) {
146
+ const sourceJarPath = path.join(dir, `${artifactId}-${version}-sources.jar`);
147
+ // Use sync check for speed in this context or cache it
148
+ const hasSource = fsSync.existsSync(sourceJarPath);
149
+ results.push({
150
+ id: 0, // Placeholder
151
+ groupId,
152
+ artifactId,
153
+ version,
154
+ abspath: dir,
155
+ hasSource
156
+ });
157
+ return;
158
+ }
159
+ }
160
+ for (const entry of entries) {
161
+ if (entry.isDirectory()) {
162
+ if (entry.name.startsWith('.'))
163
+ continue;
164
+ await scanDir(path.join(dir, entry.name));
165
+ }
166
+ }
167
+ };
168
+ await scanDir(rootDir);
169
+ return results;
170
+ }
171
+ async indexArtifactClasses(artifact) {
172
+ const jarPath = path.join(artifact.abspath, `${artifact.artifactId}-${artifact.version}.jar`);
173
+ const db = DB.getInstance();
174
+ const config = await Config.getInstance();
175
+ try {
176
+ await fs.access(jarPath);
177
+ }
178
+ catch {
179
+ // If jar missing, mark as indexed so we don't retry endlessly?
180
+ // Or maybe it's a pom-only artifact.
181
+ db.prepare('INSERT OR IGNORE INTO indexed_artifacts (artifact_id) VALUES (?)').run(artifact.id);
182
+ return;
183
+ }
184
+ return new Promise((resolve) => {
185
+ yauzl.open(jarPath, { lazyEntries: true, autoClose: true }, (err, zipfile) => {
186
+ if (err || !zipfile) {
187
+ resolve();
188
+ return;
189
+ }
190
+ const classes = [];
191
+ zipfile.readEntry();
192
+ zipfile.on('entry', (entry) => {
193
+ if (entry.fileName.endsWith('.class')) {
194
+ // Convert path/to/MyClass.class -> path.to.MyClass
195
+ const className = entry.fileName.slice(0, -6).replace(/\//g, '.');
196
+ // Simple check to avoid module-info or invalid names
197
+ if (!className.includes('$') && className.length > 0) {
198
+ // Filter by includedPackages
199
+ if (this.isPackageIncluded(className, config.includedPackages)) {
200
+ classes.push(className);
201
+ }
202
+ }
203
+ }
204
+ zipfile.readEntry();
205
+ });
206
+ zipfile.on('end', () => {
207
+ // Batch insert classes
208
+ try {
209
+ db.transaction(() => {
210
+ const insertClass = db.prepare(`
211
+ INSERT INTO classes_fts (artifact_id, class_name, simple_name)
212
+ VALUES (?, ?, ?)
213
+ `);
214
+ for (const cls of classes) {
215
+ const simpleName = cls.split('.').pop() || cls;
216
+ insertClass.run(artifact.id, cls, simpleName);
217
+ }
218
+ db.prepare('INSERT OR IGNORE INTO indexed_artifacts (artifact_id) VALUES (?)').run(artifact.id);
219
+ });
220
+ }
221
+ catch (e) {
222
+ console.error(`Failed to insert classes for ${artifact.groupId}:${artifact.artifactId}`, e);
223
+ }
224
+ resolve();
225
+ });
226
+ zipfile.on('error', () => {
227
+ resolve();
228
+ });
229
+ });
230
+ });
231
+ }
232
+ isPackageIncluded(className, patterns) {
233
+ if (patterns.length === 0 || (patterns.length === 1 && patterns[0] === '*'))
234
+ return true;
235
+ for (const pattern of patterns) {
236
+ if (pattern === '*')
237
+ return true;
238
+ if (pattern.endsWith('.*')) {
239
+ const prefix = pattern.slice(0, -2); // "com.example"
240
+ // Match exact package or subpackage
241
+ if (className === prefix || className.startsWith(prefix + '.'))
242
+ return true;
243
+ }
244
+ else {
245
+ // Exact match
246
+ if (className === pattern)
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }
252
+ search(query) {
253
+ // Artifact coordinates search
254
+ const db = DB.getInstance();
255
+ const rows = db.prepare(`
256
+ SELECT id, group_id as groupId, artifact_id as artifactId, version, abspath, has_source as hasSource
257
+ FROM artifacts
258
+ WHERE group_id LIKE ? OR artifact_id LIKE ?
259
+ LIMIT 50
260
+ `).all(`%${query}%`, `%${query}%`);
261
+ return rows;
262
+ }
263
+ searchClass(classNamePattern) {
264
+ const db = DB.getInstance();
265
+ // Use FTS for smart matching
266
+ // If pattern has no spaces, we assume it's a prefix or exact match query
267
+ // "String" -> match "String" in simple_name or class_name
268
+ const query = `"${classNamePattern}"* OR ${classNamePattern}`;
269
+ try {
270
+ const rows = db.prepare(`
271
+ SELECT c.class_name, c.simple_name, a.id, a.group_id, a.artifact_id, a.version, a.abspath, a.has_source
272
+ FROM classes_fts c
273
+ JOIN artifacts a ON c.artifact_id = a.id
274
+ WHERE c.classes_fts MATCH ?
275
+ ORDER BY rank
276
+ LIMIT 100
277
+ `).all(query);
278
+ // Group by class name
279
+ const resultMap = new Map();
280
+ for (const row of rows) {
281
+ const art = {
282
+ id: row.id,
283
+ groupId: row.group_id,
284
+ artifactId: row.artifact_id,
285
+ version: row.version,
286
+ abspath: row.abspath,
287
+ hasSource: Boolean(row.has_source)
288
+ };
289
+ if (!resultMap.has(row.class_name)) {
290
+ resultMap.set(row.class_name, []);
291
+ }
292
+ resultMap.get(row.class_name).push(art);
293
+ }
294
+ return Array.from(resultMap.entries()).map(([className, artifacts]) => ({
295
+ className,
296
+ artifacts
297
+ }));
298
+ }
299
+ catch (e) {
300
+ console.error("Search failed", e);
301
+ return [];
302
+ }
303
+ }
304
+ getArtifactById(id) {
305
+ const db = DB.getInstance();
306
+ const row = db.prepare(`
307
+ SELECT id, group_id as groupId, artifact_id as artifactId, version, abspath, has_source as hasSource
308
+ FROM artifacts WHERE id = ?
309
+ `).get(id);
310
+ if (row) {
311
+ return {
312
+ id: row.id,
313
+ groupId: row.groupId,
314
+ artifactId: row.artifactId,
315
+ version: row.version,
316
+ abspath: row.abspath,
317
+ hasSource: Boolean(row.hasSource)
318
+ };
319
+ }
320
+ return undefined;
321
+ }
322
+ }
@@ -0,0 +1,127 @@
1
+ import yauzl from 'yauzl';
2
+ import { exec } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import { Config } from './config.js';
5
+ const execAsync = promisify(exec);
6
+ export class SourceParser {
7
+ static async getClassDetail(jarPath, className, type) {
8
+ if (type === 'signatures') {
9
+ return this.getSignaturesWithJavap(jarPath, className);
10
+ }
11
+ // className: com.example.MyClass
12
+ // internalPath: com/example/MyClass.java
13
+ const internalPath = className.replace(/\./g, '/') + '.java';
14
+ return new Promise((resolve, reject) => {
15
+ yauzl.open(jarPath, { lazyEntries: true, autoClose: true }, (err, zipfile) => {
16
+ if (err || !zipfile) {
17
+ resolve(null);
18
+ return;
19
+ }
20
+ let found = false;
21
+ zipfile.readEntry();
22
+ zipfile.on('entry', (entry) => {
23
+ if (entry.fileName === internalPath) {
24
+ found = true;
25
+ zipfile.openReadStream(entry, (err, readStream) => {
26
+ if (err || !readStream) {
27
+ resolve(null);
28
+ return;
29
+ }
30
+ const chunks = [];
31
+ readStream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
32
+ readStream.on('end', () => {
33
+ const source = Buffer.concat(chunks).toString('utf-8');
34
+ resolve(SourceParser.parse(className, source, type));
35
+ });
36
+ });
37
+ }
38
+ else {
39
+ zipfile.readEntry();
40
+ }
41
+ });
42
+ zipfile.on('end', () => {
43
+ if (!found)
44
+ resolve(null);
45
+ });
46
+ });
47
+ });
48
+ }
49
+ static async getSignaturesWithJavap(jarPath, className) {
50
+ try {
51
+ const config = await Config.getInstance();
52
+ const javap = config.getJavapPath();
53
+ // Use -public to show public members (closest to API surface)
54
+ // or default (protected)
55
+ const { stdout } = await execAsync(`"${javap}" -cp "${jarPath}" "${className}"`);
56
+ const lines = stdout.split('\n')
57
+ .map(l => l.trim())
58
+ .filter(l => l.length > 0 &&
59
+ !l.startsWith('Compiled from') &&
60
+ !l.includes('static {};') &&
61
+ l !== '}');
62
+ return {
63
+ className,
64
+ signatures: lines
65
+ };
66
+ }
67
+ catch (e) {
68
+ // Fallback or error
69
+ // If javap fails (e.g. class not found in main jar?), return null
70
+ // console.error(`javap failed for ${className} in ${jarPath}:`, e);
71
+ return null;
72
+ }
73
+ }
74
+ static parse(className, source, type) {
75
+ if (type === 'source') {
76
+ return { className, source };
77
+ }
78
+ // Very simple regex-based parsing to extract methods and javadocs
79
+ // This is heuristic and won't be perfect, but it's fast and dependency-free
80
+ const signatures = [];
81
+ let doc = "";
82
+ const allDocs = [];
83
+ const lines = source.split('\n');
84
+ let currentDoc = [];
85
+ let inDoc = false;
86
+ // Regex to match method signatures (public/protected, return type, name, args)
87
+ // ignoring annotations for simplicity
88
+ const methodRegex = /^\s*(public|protected)\s+(?:[\w<>?\[\]]+\s+)+(\w+)\s*\([^)]*\)\s*(?:throws\s+[\w,.\s]+)?\s*\{?/;
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ // Javadoc extraction
92
+ if (trimmed.startsWith('/**')) {
93
+ inDoc = true;
94
+ currentDoc = [];
95
+ }
96
+ if (inDoc) {
97
+ currentDoc.push(trimmed.replace(/^\/\*\*|\*\/|^\*\s?/g, '').trim());
98
+ }
99
+ if (trimmed.endsWith('*/')) {
100
+ inDoc = false;
101
+ if (currentDoc.length > 0) {
102
+ const docBlock = currentDoc.filter(s => s.length > 0).join('\n');
103
+ allDocs.push(docBlock);
104
+ // If we found a class doc (usually before class definition), keep it as primary doc
105
+ if (doc === "") {
106
+ doc = docBlock;
107
+ }
108
+ }
109
+ }
110
+ // Method extraction
111
+ const match = line.match(methodRegex);
112
+ if (match) {
113
+ // match[0] is the whole line up to {
114
+ // Clean it up
115
+ let sig = match[0].trim();
116
+ if (sig.endsWith('{'))
117
+ sig = sig.slice(0, -1).trim();
118
+ signatures.push(sig);
119
+ }
120
+ }
121
+ return {
122
+ className,
123
+ signatures,
124
+ doc: type === 'docs' ? allDocs.join('\n\n') : undefined
125
+ };
126
+ }
127
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "maven-indexer-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for indexing local Maven repository",
5
+ "main": "build/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "maven-indexer-mcp": "build/index.js"
9
+ },
10
+ "files": [
11
+ "build",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "directories": {
16
+ "doc": "doc"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "watch": "tsc --watch",
21
+ "start": "node build/index.js",
22
+ "test": "vitest run",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/tangcent/maven-indexer-mcp.git"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "maven",
32
+ "index"
33
+ ],
34
+ "author": "",
35
+ "license": "ISC",
36
+ "bugs": {
37
+ "url": "https://github.com/tangcent/maven-indexer-mcp/issues"
38
+ },
39
+ "homepage": "https://github.com/tangcent/maven-indexer-mcp#readme",
40
+ "devDependencies": {
41
+ "@types/better-sqlite3": "^7.6.13",
42
+ "@types/node": "^25.0.2",
43
+ "typescript": "^5.9.3",
44
+ "vitest": "^4.0.15"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.24.3",
48
+ "@types/xml2js": "^0.4.14",
49
+ "@types/yauzl": "^2.10.3",
50
+ "better-sqlite3": "^12.5.0",
51
+ "chokidar": "^5.0.0",
52
+ "xml2js": "^0.6.2",
53
+ "yauzl": "^3.2.0",
54
+ "zod": "^4.1.13"
55
+ }
56
+ }