@grafema/mcp 0.1.0-alpha.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/dist/utils.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * MCP Server Utilities
3
+ */
4
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
5
+ import { join } from 'path';
6
+ // === CONSTANTS ===
7
+ export const DEFAULT_LIMIT = 10;
8
+ export const MAX_LIMIT = 500;
9
+ export const DEFAULT_RESPONSE_SIZE_LIMIT = 100_000;
10
+ // === LOGGING ===
11
+ let logsDir = null;
12
+ const MAX_LOG_FILES = 7; // Keep logs for 7 days
13
+ function getLogFilePath() {
14
+ const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
15
+ const dir = logsDir || '/tmp';
16
+ return join(dir, `mcp-${date}.log`);
17
+ }
18
+ function cleanupOldLogs() {
19
+ if (!logsDir || !existsSync(logsDir))
20
+ return;
21
+ try {
22
+ const files = readdirSync(logsDir)
23
+ .filter(f => f.startsWith('mcp-') && f.endsWith('.log'))
24
+ .map(f => ({
25
+ name: f,
26
+ path: join(logsDir, f),
27
+ mtime: statSync(join(logsDir, f)).mtime.getTime()
28
+ }))
29
+ .sort((a, b) => b.mtime - a.mtime); // newest first
30
+ // Remove files beyond MAX_LOG_FILES
31
+ for (const file of files.slice(MAX_LOG_FILES)) {
32
+ unlinkSync(file.path);
33
+ }
34
+ }
35
+ catch {
36
+ // Ignore cleanup errors
37
+ }
38
+ }
39
+ export function initLogger(grafemaDir) {
40
+ logsDir = join(grafemaDir, 'logs');
41
+ if (!existsSync(logsDir)) {
42
+ mkdirSync(logsDir, { recursive: true });
43
+ }
44
+ cleanupOldLogs();
45
+ log(`[Grafema MCP] Logging initialized: ${logsDir}`);
46
+ }
47
+ export function getLogsDir() {
48
+ return logsDir;
49
+ }
50
+ export function log(msg) {
51
+ const timestamp = new Date().toISOString();
52
+ const line = `[${timestamp}] ${msg}\n`;
53
+ try {
54
+ appendFileSync(getLogFilePath(), line);
55
+ }
56
+ catch {
57
+ // Fallback to /tmp if logs dir not initialized
58
+ appendFileSync('/tmp/grafema-mcp.log', line);
59
+ }
60
+ }
61
+ // === PAGINATION ===
62
+ export function guardResponseSize(text, maxSize = DEFAULT_RESPONSE_SIZE_LIMIT) {
63
+ if (text.length > maxSize) {
64
+ const truncated = text.slice(0, maxSize);
65
+ const remaining = text.length - maxSize;
66
+ return truncated + `\n\n... [TRUNCATED: ${remaining.toLocaleString()} chars remaining. Use limit/offset for pagination]`;
67
+ }
68
+ return text;
69
+ }
70
+ export function normalizeLimit(limit) {
71
+ if (limit === undefined || limit === null)
72
+ return DEFAULT_LIMIT;
73
+ return Math.min(Math.max(1, Math.floor(limit)), MAX_LIMIT);
74
+ }
75
+ export function formatPaginationInfo(params) {
76
+ const { limit, offset, returned, total, hasMore } = params;
77
+ let info = `\nšŸ“„ Pagination: showing ${returned}`;
78
+ if (total !== undefined) {
79
+ info += ` of ${total}`;
80
+ }
81
+ if (offset > 0) {
82
+ info += ` (offset: ${offset})`;
83
+ }
84
+ if (hasMore) {
85
+ info += ` — use offset=${offset + returned} to get more`;
86
+ }
87
+ return info;
88
+ }
89
+ // === TYPE HELPERS ===
90
+ export function findSimilarTypes(queriedType, availableTypes, maxDistance = 2) {
91
+ const queriedLower = queriedType.toLowerCase();
92
+ const similar = [];
93
+ for (const type of availableTypes) {
94
+ const dist = levenshtein(queriedLower, type.toLowerCase());
95
+ if (dist > 0 && dist <= maxDistance) {
96
+ similar.push(type);
97
+ }
98
+ }
99
+ return similar;
100
+ }
101
+ // Levenshtein distance implementation
102
+ export function levenshtein(a, b) {
103
+ const m = a.length;
104
+ const n = b.length;
105
+ if (m === 0)
106
+ return n;
107
+ if (n === 0)
108
+ return m;
109
+ const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
110
+ for (let i = 0; i <= m; i++)
111
+ dp[i][0] = i;
112
+ for (let j = 0; j <= n; j++)
113
+ dp[0][j] = j;
114
+ for (let i = 1; i <= m; i++) {
115
+ for (let j = 1; j <= n; j++) {
116
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
117
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
118
+ }
119
+ }
120
+ return dp[m][n];
121
+ }
122
+ // === SERIALIZATION ===
123
+ export function serializeBigInt(obj) {
124
+ if (obj === null || obj === undefined)
125
+ return obj;
126
+ if (typeof obj === 'bigint')
127
+ return obj.toString();
128
+ if (Array.isArray(obj))
129
+ return obj.map(serializeBigInt);
130
+ if (typeof obj === 'object') {
131
+ const result = {};
132
+ for (const [key, value] of Object.entries(obj)) {
133
+ result[key] = serializeBigInt(value);
134
+ }
135
+ return result;
136
+ }
137
+ return obj;
138
+ }
139
+ // === TOOL RESULT HELPERS ===
140
+ export function textResult(text) {
141
+ return {
142
+ content: [{ type: 'text', text }],
143
+ };
144
+ }
145
+ export function errorResult(message) {
146
+ return {
147
+ content: [{ type: 'text', text: `Error: ${message}` }],
148
+ isError: true,
149
+ };
150
+ }
151
+ export function jsonResult(data, pretty = true) {
152
+ const text = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
153
+ return textResult(text);
154
+ }
155
+ // === PROCESS HELPERS ===
156
+ export function isProcessRunning(pid) {
157
+ try {
158
+ process.kill(pid, 0);
159
+ return true;
160
+ }
161
+ catch {
162
+ return false;
163
+ }
164
+ }
165
+ // === FORMATTING ===
166
+ export function formatDuration(ms) {
167
+ if (ms < 1000)
168
+ return `${ms}ms`;
169
+ if (ms < 60000)
170
+ return `${(ms / 1000).toFixed(1)}s`;
171
+ return `${(ms / 60000).toFixed(1)}m`;
172
+ }
173
+ export function formatNumber(n) {
174
+ return n.toLocaleString();
175
+ }
176
+ export function truncate(s, maxLen) {
177
+ if (s.length <= maxLen)
178
+ return s;
179
+ return s.slice(0, maxLen - 3) + '...';
180
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@grafema/mcp",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "MCP server for Grafema code analysis toolkit",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "types": "./dist/server.d.ts",
8
+ "bin": {
9
+ "grafema-mcp": "./dist/server.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/server.d.ts",
14
+ "import": "./dist/server.js"
15
+ },
16
+ "./handlers": {
17
+ "types": "./dist/handlers.d.ts",
18
+ "import": "./dist/handlers.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "clean": "rm -rf dist",
28
+ "start": "node dist/server.js"
29
+ },
30
+ "keywords": [
31
+ "grafema",
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "code-analysis"
35
+ ],
36
+ "license": "Apache-2.0",
37
+ "author": "Vadim Reshetnikov",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/Disentinel/grafema.git",
41
+ "directory": "packages/mcp"
42
+ },
43
+ "dependencies": {
44
+ "@grafema/core": "workspace:*",
45
+ "@grafema/types": "workspace:*",
46
+ "@modelcontextprotocol/sdk": "^1.25.1",
47
+ "ajv": "^8.17.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^25.0.8",
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ 118083cf-1249-4311-b452-79bb704fcf13
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Analysis Worker - runs in separate process to avoid blocking MCP server
3
+ *
4
+ * Usage: node analysis-worker.js <projectPath> [serviceName]
5
+ *
6
+ * Sends progress updates via IPC to parent process
7
+ */
8
+
9
+ import { join } from 'path';
10
+ import { existsSync, readdirSync, readFileSync } from 'fs';
11
+ import { pathToFileURL } from 'url';
12
+
13
+ import {
14
+ Orchestrator,
15
+ RFDBServerBackend,
16
+ Plugin,
17
+ // Indexing
18
+ JSModuleIndexer,
19
+ // Analysis
20
+ JSASTAnalyzer,
21
+ ExpressRouteAnalyzer,
22
+ SocketIOAnalyzer,
23
+ DatabaseAnalyzer,
24
+ FetchAnalyzer,
25
+ ServiceLayerAnalyzer,
26
+ ReactAnalyzer,
27
+ // Enrichment
28
+ MethodCallResolver,
29
+ AliasTracker,
30
+ ValueDomainAnalyzer,
31
+ MountPointResolver,
32
+ PrefixEvaluator,
33
+ HTTPConnectionEnricher,
34
+ // Validation
35
+ CallResolverValidator,
36
+ EvalBanValidator,
37
+ SQLInjectionValidator,
38
+ ShadowingDetector,
39
+ GraphConnectivityValidator,
40
+ DataFlowValidator,
41
+ } from '@grafema/core';
42
+ import type { ParallelConfig } from '@grafema/core';
43
+
44
+ /**
45
+ * Config structure
46
+ */
47
+ interface WorkerConfig {
48
+ plugins?: Record<string, string[]>;
49
+ analysis?: {
50
+ parallel?: ParallelConfig & { workers?: number; socketPath?: string };
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Progress message
56
+ */
57
+ interface ProgressMessage {
58
+ type: 'progress';
59
+ phase?: string;
60
+ message?: string;
61
+ servicesDiscovered?: number;
62
+ servicesAnalyzed?: number;
63
+ }
64
+
65
+ /**
66
+ * Complete message
67
+ */
68
+ interface CompleteMessage {
69
+ type: 'complete';
70
+ nodeCount: number;
71
+ edgeCount: number;
72
+ totalTime: string;
73
+ }
74
+
75
+ /**
76
+ * Error message
77
+ */
78
+ interface ErrorMessage {
79
+ type: 'error';
80
+ message: string;
81
+ stack?: string;
82
+ }
83
+
84
+ type WorkerMessage = ProgressMessage | CompleteMessage | ErrorMessage;
85
+
86
+ const projectPath = process.argv[2];
87
+ const serviceName = process.argv[3] && process.argv[3] !== '' ? process.argv[3] : null;
88
+ const indexOnly = process.argv[4] === 'indexOnly';
89
+
90
+ if (!projectPath) {
91
+ console.error('Usage: node analysis-worker.js <projectPath> [serviceName] [indexOnly]');
92
+ process.exit(1);
93
+ }
94
+
95
+ function sendProgress(data: Omit<ProgressMessage, 'type'>): void {
96
+ if (process.send) {
97
+ process.send({ type: 'progress', ...data } as ProgressMessage);
98
+ }
99
+ }
100
+
101
+ function sendComplete(data: Omit<CompleteMessage, 'type'>): void {
102
+ if (process.send) {
103
+ process.send({ type: 'complete', ...data } as CompleteMessage);
104
+ }
105
+ }
106
+
107
+ function sendError(error: Error): void {
108
+ if (process.send) {
109
+ process.send({ type: 'error', message: error.message, stack: error.stack } as ErrorMessage);
110
+ }
111
+ }
112
+
113
+ async function loadConfig(): Promise<WorkerConfig> {
114
+ const configPath = join(projectPath, '.grafema', 'config.json');
115
+ if (existsSync(configPath)) {
116
+ return JSON.parse(readFileSync(configPath, 'utf8')) as WorkerConfig;
117
+ }
118
+ return { plugins: {} };
119
+ }
120
+
121
+ async function loadCustomPlugins(): Promise<Record<string, new () => Plugin>> {
122
+ const pluginsDir = join(projectPath, '.grafema', 'plugins');
123
+ const customPlugins: Record<string, new () => Plugin> = {};
124
+
125
+ if (!existsSync(pluginsDir)) {
126
+ return customPlugins;
127
+ }
128
+
129
+ const files = readdirSync(pluginsDir).filter(f => f.endsWith('.mjs') || f.endsWith('.js'));
130
+ for (const file of files) {
131
+ try {
132
+ const module = await import(pathToFileURL(join(pluginsDir, file)).href);
133
+ const PluginClass = module.default as new () => Plugin;
134
+ if (PluginClass) {
135
+ customPlugins[PluginClass.name] = PluginClass;
136
+ }
137
+ } catch (err) {
138
+ console.error(`Failed to load plugin ${file}:`, (err as Error).message);
139
+ }
140
+ }
141
+
142
+ return customPlugins;
143
+ }
144
+
145
+ async function run(): Promise<void> {
146
+ const startTime = Date.now();
147
+ let db: RFDBServerBackend | null = null;
148
+
149
+ try {
150
+ sendProgress({ phase: 'starting', message: 'Loading configuration...' });
151
+
152
+ const config = await loadConfig();
153
+ const customPlugins = await loadCustomPlugins();
154
+
155
+ // Built-in plugins map
156
+ const builtinPlugins: Record<string, () => Plugin> = {
157
+ JSModuleIndexer: () => new JSModuleIndexer(),
158
+ JSASTAnalyzer: () => new JSASTAnalyzer(),
159
+ ExpressRouteAnalyzer: () => new ExpressRouteAnalyzer(),
160
+ SocketIOAnalyzer: () => new SocketIOAnalyzer(),
161
+ DatabaseAnalyzer: () => new DatabaseAnalyzer(),
162
+ FetchAnalyzer: () => new FetchAnalyzer(),
163
+ ServiceLayerAnalyzer: () => new ServiceLayerAnalyzer(),
164
+ ReactAnalyzer: () => new ReactAnalyzer(),
165
+ MethodCallResolver: () => new MethodCallResolver(),
166
+ AliasTracker: () => new AliasTracker(),
167
+ ValueDomainAnalyzer: () => new ValueDomainAnalyzer(),
168
+ MountPointResolver: () => new MountPointResolver(),
169
+ PrefixEvaluator: () => new PrefixEvaluator(),
170
+ HTTPConnectionEnricher: () => new HTTPConnectionEnricher(),
171
+ CallResolverValidator: () => new CallResolverValidator(),
172
+ EvalBanValidator: () => new EvalBanValidator(),
173
+ SQLInjectionValidator: () => new SQLInjectionValidator(),
174
+ ShadowingDetector: () => new ShadowingDetector(),
175
+ GraphConnectivityValidator: () => new GraphConnectivityValidator(),
176
+ DataFlowValidator: () => new DataFlowValidator(),
177
+ };
178
+
179
+ // Add custom plugins
180
+ for (const [name, PluginClass] of Object.entries(customPlugins)) {
181
+ builtinPlugins[name] = () => new PluginClass();
182
+ }
183
+
184
+ // Build plugins array from config
185
+ const plugins: Plugin[] = [];
186
+ for (const [phase, pluginNames] of Object.entries(config.plugins || {})) {
187
+ for (const name of pluginNames) {
188
+ if (builtinPlugins[name]) {
189
+ plugins.push(builtinPlugins[name]());
190
+ } else if (customPlugins[name]) {
191
+ plugins.push(new customPlugins[name]());
192
+ console.log(`[Worker] Loaded custom plugin: ${name}`);
193
+ } else {
194
+ console.warn(`[Worker] Plugin not found: ${name}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ console.log(`[Worker] Loaded ${plugins.length} plugins:`, plugins.map(p => p.metadata?.name || p.constructor?.name || 'unknown'));
200
+ sendProgress({ phase: 'starting', message: `Loaded ${plugins.length} plugins` });
201
+
202
+ // Get parallel analysis config
203
+ const parallelConfig = config.analysis?.parallel;
204
+ if (parallelConfig?.enabled) {
205
+ console.log(`[Worker] Queue-based parallel mode enabled: workers=${parallelConfig.workers}`);
206
+ }
207
+
208
+ // Connect to RFDB server (shared with MCP server)
209
+ // The MCP server starts the RFDB server if not running
210
+ const dbPath = join(projectPath, '.grafema', 'graph.rfdb');
211
+ const socketPath = config.analysis?.parallel?.socketPath || '/tmp/rfdb.sock';
212
+
213
+ console.log(`[Worker] Connecting to RFDB server: socket=${socketPath}, db=${dbPath}`);
214
+ db = new RFDBServerBackend({ socketPath, dbPath });
215
+ await db.connect();
216
+ await db.clear();
217
+
218
+ sendProgress({ phase: 'discovery', message: 'Starting analysis...' });
219
+
220
+ // Create orchestrator
221
+ const orchestrator = new Orchestrator({
222
+ graph: db,
223
+ plugins,
224
+ parallel: parallelConfig as ParallelConfig | undefined, // Pass parallel config for queue-based analysis
225
+ serviceFilter: serviceName,
226
+ indexOnly: indexOnly,
227
+ onProgress: (progress) => {
228
+ sendProgress({
229
+ phase: progress.phase,
230
+ message: progress.message,
231
+ servicesAnalyzed: progress.servicesAnalyzed
232
+ });
233
+ }
234
+ });
235
+
236
+ // Run analysis
237
+ await orchestrator.run(projectPath);
238
+
239
+ // Get final stats
240
+ let nodeCount = 0;
241
+ let edgeCount = 0;
242
+
243
+ // Use async methods for RFDBServerBackend
244
+ const allEdges = await db.getAllEdgesAsync();
245
+ edgeCount = allEdges.length;
246
+
247
+ for await (const _ of db.queryNodes({})) {
248
+ nodeCount++;
249
+ }
250
+
251
+ // Flush to disk using proper async method
252
+ console.log('[Worker] Flushing database to disk...');
253
+ await db.flush();
254
+ console.log('[Worker] Database flushed successfully');
255
+
256
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
257
+
258
+ sendComplete({
259
+ nodeCount,
260
+ edgeCount,
261
+ totalTime
262
+ });
263
+
264
+ // Close database properly before exit
265
+ await db.close();
266
+ console.log('[Worker] Database closed');
267
+
268
+ process.exit(0);
269
+ } finally {
270
+ // Ensure database connection is closed even on error
271
+ if (db && db.connected) {
272
+ try {
273
+ await db.close();
274
+ console.log('[Worker] Database connection closed in cleanup');
275
+ } catch (closeErr) {
276
+ console.error('[Worker] Error closing database connection:', (closeErr as Error).message);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ run().catch(err => {
283
+ sendError(err as Error);
284
+ console.error('Analysis failed:', err);
285
+ process.exit(1);
286
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * MCP Analysis Orchestration
3
+ */
4
+
5
+ import { Orchestrator, Plugin } from '@grafema/core';
6
+ import {
7
+ getOrCreateBackend,
8
+ getProjectPath,
9
+ getIsAnalyzed,
10
+ setIsAnalyzed,
11
+ getAnalysisStatus,
12
+ setAnalysisStatus,
13
+ } from './state.js';
14
+ import { loadConfig, loadCustomPlugins, BUILTIN_PLUGINS } from './config.js';
15
+ import { log } from './utils.js';
16
+ import type { GraphBackend } from '@grafema/types';
17
+
18
+ /**
19
+ * Ensure project is analyzed, optionally filtering to a single service
20
+ */
21
+ export async function ensureAnalyzed(serviceName: string | null = null): Promise<GraphBackend> {
22
+ const db = await getOrCreateBackend();
23
+ const projectPath = getProjectPath();
24
+ const isAnalyzed = getIsAnalyzed();
25
+
26
+ if (!isAnalyzed || serviceName) {
27
+ log(
28
+ `[Grafema MCP] Analyzing project: ${projectPath}${serviceName ? ` (service: ${serviceName})` : ''}`
29
+ );
30
+
31
+ const config = loadConfig(projectPath);
32
+ const { pluginMap: customPluginMap } = await loadCustomPlugins(projectPath);
33
+
34
+ // Merge builtin and custom plugins
35
+ const availablePlugins: Record<string, () => unknown> = {
36
+ ...BUILTIN_PLUGINS,
37
+ ...Object.fromEntries(
38
+ Object.entries(customPluginMap).map(([name, PluginClass]) => [
39
+ name,
40
+ () => new PluginClass(),
41
+ ])
42
+ ),
43
+ };
44
+
45
+ // Build plugin list from config
46
+ const plugins: unknown[] = [];
47
+ for (const [phase, pluginNames] of Object.entries(config.plugins || {})) {
48
+ for (const name of pluginNames as string[]) {
49
+ const factory = availablePlugins[name];
50
+ if (factory) {
51
+ plugins.push(factory());
52
+ log(`[Grafema MCP] Enabled plugin: ${name} (${phase})`);
53
+ } else {
54
+ log(`[Grafema MCP] Warning: Unknown plugin ${name} in config`);
55
+ }
56
+ }
57
+ }
58
+
59
+ log(`[Grafema MCP] Total plugins: ${plugins.length}`);
60
+
61
+ // Check for parallel analysis config
62
+ const parallelConfig = (config as any).analysis?.parallel;
63
+ log(`[Grafema MCP] Config analysis section: ${JSON.stringify((config as any).analysis)}`);
64
+
65
+ if (parallelConfig?.enabled) {
66
+ log(
67
+ `[Grafema MCP] Parallel analysis enabled: maxWorkers=${parallelConfig.maxWorkers || 'auto'}, socket=${parallelConfig.socketPath || '/tmp/rfdb.sock'}`
68
+ );
69
+ }
70
+
71
+ const analysisStatus = getAnalysisStatus();
72
+ const startTime = Date.now();
73
+
74
+ const orchestrator = new Orchestrator({
75
+ graph: db,
76
+ plugins: plugins as Plugin[],
77
+ parallel: parallelConfig,
78
+ serviceFilter: serviceName,
79
+ onProgress: (progress: any) => {
80
+ log(`[Grafema MCP] ${progress.phase}: ${progress.message}`);
81
+
82
+ setAnalysisStatus({
83
+ phase: progress.phase,
84
+ message: progress.message,
85
+ servicesDiscovered: progress.servicesDiscovered || analysisStatus.servicesDiscovered,
86
+ servicesAnalyzed: progress.servicesAnalyzed || analysisStatus.servicesAnalyzed,
87
+ });
88
+ },
89
+ });
90
+
91
+ await orchestrator.run(projectPath);
92
+
93
+ // Flush if available
94
+ if ('flush' in db && typeof db.flush === 'function') {
95
+ await (db as any).flush();
96
+ }
97
+
98
+ setIsAnalyzed(true);
99
+
100
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
101
+ setAnalysisStatus({
102
+ timings: {
103
+ ...analysisStatus.timings,
104
+ total: parseFloat(totalTime),
105
+ },
106
+ });
107
+
108
+ log(`[Grafema MCP] āœ… Analysis complete in ${totalTime}s`);
109
+ }
110
+
111
+ return db;
112
+ }
113
+
114
+ /**
115
+ * Discover services without running full analysis
116
+ */
117
+ export async function discoverServices(): Promise<unknown[]> {
118
+ const db = await getOrCreateBackend();
119
+ const projectPath = getProjectPath();
120
+
121
+ log(`[Grafema MCP] Discovering services in: ${projectPath}`);
122
+
123
+ const config = loadConfig(projectPath);
124
+ const { pluginMap: customPluginMap } = await loadCustomPlugins(projectPath);
125
+
126
+ const availablePlugins: Record<string, () => unknown> = {
127
+ ...Object.fromEntries(
128
+ Object.entries(customPluginMap).map(([name, PluginClass]) => [
129
+ name,
130
+ () => new PluginClass(),
131
+ ])
132
+ ),
133
+ };
134
+
135
+ const plugins: unknown[] = [];
136
+ const discoveryPluginNames = (config.plugins as any)?.discovery || [];
137
+
138
+ for (const name of discoveryPluginNames) {
139
+ const factory = availablePlugins[name];
140
+ if (factory) {
141
+ plugins.push(factory());
142
+ log(`[Grafema MCP] Enabled discovery plugin: ${name}`);
143
+ } else {
144
+ log(`[Grafema MCP] Warning: Unknown discovery plugin ${name}`);
145
+ }
146
+ }
147
+
148
+ const orchestrator = new Orchestrator({
149
+ graph: db,
150
+ plugins: plugins as Plugin[],
151
+ });
152
+
153
+ const manifest = await orchestrator.discover(projectPath);
154
+
155
+ log(`[Grafema MCP] Discovery complete: found ${manifest.services?.length || 0} services`);
156
+
157
+ return manifest.services || [];
158
+ }