regen-koi-mcp 1.2.0 → 1.3.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/dist/index.js CHANGED
@@ -19,15 +19,14 @@ import dotenv from 'dotenv';
19
19
  import { TOOLS } from './tools.js';
20
20
  // Use enhanced SPARQL client with focused retrieval
21
21
  import { SPARQLClient } from './sparql-client-enhanced.js';
22
- import HybridSearchClient from './hybrid-client.js';
23
- import { QueryRouter } from './query_router.js';
24
- import { UnifiedSearch } from './unified_search.js';
25
22
  import { executeGraphTool } from './graph_tool.js';
26
23
  // Production hardening modules
27
24
  import { logger } from './logger.js';
28
25
  import { recordQuery, getMetricsMarkdown, getMetricsSummary } from './metrics.js';
29
26
  import { validateToolInput } from './validation.js';
30
27
  import { queryCache } from './cache.js';
28
+ // Shared auth module
29
+ import { USER_EMAIL, getAccessToken, setAccessToken } from './auth.js';
31
30
  // Load environment variables
32
31
  dotenv.config();
33
32
  // Configuration
@@ -35,54 +34,51 @@ const KOI_API_ENDPOINT = process.env.KOI_API_ENDPOINT || 'https://regen.gaiaai.x
35
34
  const KOI_API_KEY = process.env.KOI_API_KEY || '';
36
35
  const SERVER_NAME = process.env.MCP_SERVER_NAME || 'regen-koi';
37
36
  const SERVER_VERSION = process.env.MCP_SERVER_VERSION || '1.0.0';
37
+ console.error(`[${SERVER_NAME}] User email for auth: ${USER_EMAIL}`);
38
+ // Check if user is authenticated (with caching)
39
+ async function isUserAuthenticated() {
40
+ const token = getAccessToken();
41
+ if (!token)
42
+ return false;
43
+ // Validate token with server
44
+ try {
45
+ const response = await axios.get(`${KOI_API_ENDPOINT}/auth/status`, {
46
+ headers: { 'Authorization': `Bearer ${token}` },
47
+ timeout: 5000
48
+ });
49
+ return response.data.authenticated || false;
50
+ }
51
+ catch (error) {
52
+ return false;
53
+ }
54
+ }
38
55
  // API client configuration
56
+ // SECURITY: Authorization header is set dynamically based on access token
39
57
  const apiClient = axios.create({
40
58
  baseURL: KOI_API_ENDPOINT,
41
59
  timeout: 30000,
42
60
  headers: {
43
61
  'Content-Type': 'application/json',
44
- ...(KOI_API_KEY ? { 'Authorization': `Bearer ${KOI_API_KEY}` } : {})
62
+ 'X-User-Email': USER_EMAIL, // Kept for logging purposes only, not for auth
45
63
  }
46
64
  });
65
+ // Add request interceptor to dynamically include access token
66
+ apiClient.interceptors.request.use((config) => {
67
+ const token = getAccessToken();
68
+ if (token && config.headers) {
69
+ config.headers['Authorization'] = `Bearer ${token}`;
70
+ }
71
+ return config;
72
+ });
47
73
  // Tool definitions are imported from tools.ts
48
74
  class KOIServer {
49
75
  server;
50
76
  sparqlClient;
51
- hybridClient;
52
- queryRouter = null;
53
- unifiedSearch = null;
54
77
  constructor() {
55
78
  this.sparqlClient = new SPARQLClient();
56
- this.hybridClient = new HybridSearchClient();
57
- // Initialize QueryRouter and UnifiedSearch if database config is available
58
- try {
59
- if (process.env.GRAPH_DB_HOST && process.env.GRAPH_DB_NAME) {
60
- this.queryRouter = new QueryRouter({
61
- host: process.env.GRAPH_DB_HOST,
62
- port: parseInt(process.env.GRAPH_DB_PORT || '5432'),
63
- database: process.env.GRAPH_DB_NAME,
64
- user: process.env.GRAPH_DB_USER,
65
- password: process.env.GRAPH_DB_PASSWORD,
66
- entitySimilarityThreshold: parseFloat(process.env.ENTITY_SIMILARITY_THRESHOLD || '0.15'),
67
- });
68
- this.unifiedSearch = new UnifiedSearch({
69
- host: process.env.GRAPH_DB_HOST,
70
- port: parseInt(process.env.GRAPH_DB_PORT || '5432'),
71
- database: process.env.GRAPH_DB_NAME,
72
- user: process.env.GRAPH_DB_USER,
73
- password: process.env.GRAPH_DB_PASSWORD,
74
- graphName: process.env.GRAPH_NAME || 'regen_graph',
75
- embeddingDimension: parseInt(process.env.EMBEDDING_DIM || '1536'),
76
- rrfConstant: parseInt(process.env.RRF_K || '60'),
77
- });
78
- console.error(`[${SERVER_NAME}] Initialized QueryRouter and UnifiedSearch`);
79
- }
80
- else {
81
- console.error(`[${SERVER_NAME}] Graph database configuration not found - hybrid_search and query_code_graph tools will be unavailable`);
82
- }
83
- }
84
- catch (error) {
85
- console.error(`[${SERVER_NAME}] Failed to initialize graph components:`, error);
79
+ // Check for graph database configuration (only for logging)
80
+ if (!(process.env.GRAPH_DB_HOST && process.env.GRAPH_DB_NAME)) {
81
+ console.error(`[${SERVER_NAME}] Graph database configuration not found - query_code_graph tool will rely on fallback`);
86
82
  }
87
83
  this.server = new Server({
88
84
  name: SERVER_NAME,
@@ -121,7 +117,7 @@ class KOIServer {
121
117
  }, `Executing tool: ${name}`);
122
118
  try {
123
119
  // Input validation for applicable tools
124
- const validationRequired = ['search_knowledge', 'hybrid_search', 'search_github_docs', 'get_repo_overview', 'get_tech_stack', 'generate_weekly_digest'];
120
+ const validationRequired = ['search', 'search_github_docs', 'get_repo_overview', 'get_tech_stack', 'generate_weekly_digest'];
125
121
  if (validationRequired.includes(name)) {
126
122
  const validation = validateToolInput(name, args);
127
123
  if (!validation.success) {
@@ -144,11 +140,8 @@ class KOIServer {
144
140
  case 'query_code_graph':
145
141
  result = await executeGraphTool(args);
146
142
  break;
147
- case 'hybrid_search':
148
- result = await this.handleHybridSearch(args);
149
- break;
150
- case 'search_knowledge':
151
- result = await this.searchKnowledge(args);
143
+ case 'search':
144
+ result = await this.search(args);
152
145
  break;
153
146
  case 'get_stats':
154
147
  result = await this.getStats(args);
@@ -372,8 +365,8 @@ class KOIServer {
372
365
  return { content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : String(e)}` }] };
373
366
  }
374
367
  }
375
- async searchKnowledge(args) {
376
- const { query, limit = 5, published_from, published_to, include_undated = false, useHybrid = false } = args || {};
368
+ async search(args) {
369
+ const { query, limit = 10, published_from, published_to, include_undated = false } = args || {};
377
370
  const vectorFilters = {};
378
371
  // Respect explicit date filter
379
372
  if (published_from || published_to) {
@@ -395,30 +388,7 @@ class KOIServer {
395
388
  if (include_undated) {
396
389
  vectorFilters.include_undated = true;
397
390
  }
398
- // Use true hybrid search if enabled
399
- if (useHybrid) {
400
- try {
401
- const results = await this.hybridClient.hybridSearch(query, {
402
- sparqlLimit: limit * 2,
403
- vectorLimit: limit,
404
- fusionStrategy: 'rrf',
405
- filters: vectorFilters
406
- });
407
- const formattedResults = this.hybridClient.formatResults(results);
408
- return {
409
- content: [
410
- {
411
- type: 'text',
412
- text: formattedResults,
413
- },
414
- ],
415
- };
416
- }
417
- catch (error) {
418
- console.error('Hybrid search failed, falling back to vector-only:', error);
419
- }
420
- }
421
- // Fallback to original vector search
391
+ // Call the KOI API (which handles hybrid search with entity boosting)
422
392
  try {
423
393
  const body = { question: query, limit };
424
394
  if (Object.keys(vectorFilters).length > 0)
@@ -616,16 +586,23 @@ class KOIServer {
616
586
  async getStats(args) {
617
587
  const { detailed = false } = args;
618
588
  try {
619
- const response = await apiClient.get('/health');
620
- const health = response.data;
589
+ const response = await apiClient.get('/stats');
590
+ const stats = response.data;
621
591
  let formatted = `# KOI Knowledge Base Statistics\n\n`;
622
- formatted += `- **Status**: ${health.status || 'Unknown'}\n`;
623
- // Since /health is minimal, we'll provide estimated stats
624
- formatted += `- **Total Documents**: 15,000+\n`;
625
- formatted += `- **Topics Covered**: Regen Network, Carbon Credits, Ecological Assets\n`;
626
- formatted += `- **Data Sources**: Multiple (Websites, Podcasts, Documentation)\n`;
627
- // Display the resolved endpoint that this server is using
628
- formatted += `- **API Endpoint**: ${KOI_API_ENDPOINT}\n`;
592
+ // Main statistics
593
+ formatted += `- **Total Documents**: ${stats.total_documents?.toLocaleString() || 'Unknown'}\n`;
594
+ formatted += `- **Recent (7 days)**: ${stats.recent_7_days?.toLocaleString() || 'Unknown'}\n`;
595
+ formatted += `- **API Endpoint**: ${KOI_API_ENDPOINT}\n\n`;
596
+ // Source breakdown
597
+ if (stats.by_source && Object.keys(stats.by_source).length > 0) {
598
+ formatted += `## Documents by Source\n\n`;
599
+ // Sort sources by count (descending)
600
+ const sortedSources = Object.entries(stats.by_source)
601
+ .sort(([, a], [, b]) => b - a);
602
+ for (const [source, count] of sortedSources) {
603
+ formatted += `- **${source}**: ${count.toLocaleString()}\n`;
604
+ }
605
+ }
629
606
  return {
630
607
  content: [
631
608
  {
@@ -656,17 +633,13 @@ class KOIServer {
656
633
  output += `\n## System Health\n\n`;
657
634
  output += `- **Graph DB Configured:** ${!!(process.env.GRAPH_DB_HOST && process.env.GRAPH_DB_NAME)}\n`;
658
635
  output += `- **KOI API Endpoint:** ${KOI_API_ENDPOINT}\n`;
659
- output += `- **Query Router Available:** ${!!this.queryRouter}\n`;
660
- output += `- **Unified Search Available:** ${!!this.unifiedSearch}\n`;
661
636
  // Add raw JSON for programmatic access
662
637
  const jsonData = JSON.stringify({
663
638
  metrics: metricsSummary,
664
639
  cache: cacheStats,
665
640
  config: {
666
641
  api_endpoint: KOI_API_ENDPOINT,
667
- graph_db_configured: !!(process.env.GRAPH_DB_HOST && process.env.GRAPH_DB_NAME),
668
- query_router_available: !!this.queryRouter,
669
- unified_search_available: !!this.unifiedSearch
642
+ graph_db_configured: !!(process.env.GRAPH_DB_HOST && process.env.GRAPH_DB_NAME)
670
643
  }
671
644
  }, null, 2);
672
645
  return {
@@ -921,7 +894,7 @@ class KOIServer {
921
894
  catch (apiError) {
922
895
  console.error(`[${SERVER_NAME}] API weekly-digest endpoint failed, using search fallback:`, apiError);
923
896
  // Final fallback: use search
924
- const searchResults = await this.searchKnowledge({
897
+ const searchResults = await this.search({
925
898
  query: 'Regen Network activity updates discussions governance',
926
899
  limit: 100,
927
900
  published_from: startDate,
@@ -1443,67 +1416,132 @@ class KOIServer {
1443
1416
  }
1444
1417
  /**
1445
1418
  * Authenticate user with @regen.network email for access to private documentation
1419
+ *
1420
+ * RFC 8628 Device Authorization Grant:
1421
+ * 1. Server generates device_code (secret) and user_code (public)
1422
+ * 2. User manually goes to verification_uri and enters user_code
1423
+ * 3. MCP polls for completion using device_code
1424
+ *
1425
+ * SECURITY: Prevents phishing because user must manually type code from their device.
1426
+ * Attacker cannot force victim to authorize attacker's device_code.
1446
1427
  */
1447
1428
  async authenticateUser() {
1448
1429
  const startTime = Date.now();
1449
1430
  try {
1450
1431
  console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=start`);
1451
- // Get user email (same logic as graph_tool.ts uses)
1452
- const userEmail = process.env.REGEN_USER_EMAIL ||
1453
- process.env.USER_EMAIL ||
1454
- `${process.env.USER}@regen.network`;
1455
- console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate UserEmail=${userEmail}`);
1456
- // Call the auth initiate endpoint
1457
- const response = await axios.get(`${KOI_API_ENDPOINT}/auth/initiate`, {
1458
- params: { user_email: userEmail }
1459
- });
1460
- const { auth_url, state } = response.data;
1461
- if (!auth_url) {
1462
- throw new Error('No auth URL returned from server');
1432
+ // Load saved auth state from disk
1433
+ const { loadAuthState, saveAuthState, clearDeviceCode, hasValidAccessToken, hasValidDeviceCode } = await import('./auth-store.js');
1434
+ const state = loadAuthState();
1435
+ // Check 1: Already authenticated?
1436
+ if (hasValidAccessToken(state)) {
1437
+ console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=already_authenticated User=${state.userEmail}`);
1438
+ return {
1439
+ content: [{
1440
+ type: 'text',
1441
+ text: `## Already Authenticated\n\nYou are already authenticated as **${state.userEmail}**.\n\nYour session is valid until ${new Date(state.accessTokenExpiresAt).toLocaleString()}.\n\n✅ You have access to private Regen Network documentation.`
1442
+ }]
1443
+ };
1463
1444
  }
1464
- // Open browser for OAuth
1465
- console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=opening_browser URL=${auth_url}`);
1466
- const open = (await import('open')).default;
1467
- await open(auth_url);
1468
- let output = `## Authentication Started\n\n`;
1469
- output += `✅ Opening browser for OAuth login...\n\n`;
1470
- output += `**Please:**\n`;
1471
- output += `1. Log in with your **@regen.network** email\n`;
1472
- output += `2. Grant the requested permissions (email, profile)\n`;
1473
- output += `3. The browser will show a success message when complete\n\n`;
1474
- output += `**After authenticating:**\n`;
1475
- output += `- Your token is saved on the server\n`;
1476
- output += `- Future queries will automatically include private Drive data\n`;
1477
- output += `- You won't need to authenticate again unless the token expires\n\n`;
1478
- output += `**Polling for authentication completion...**\n`;
1479
- // Poll for authentication status
1480
- const pollUrl = `${KOI_API_ENDPOINT}/auth/status?user_email=${encodeURIComponent(userEmail)}`;
1481
- const maxAttempts = 60; // 2 minutes
1482
- const pollInterval = 2000; // 2 seconds
1483
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
1484
- await new Promise(resolve => setTimeout(resolve, pollInterval));
1445
+ // Check 2: Have pending device code? Check its status
1446
+ if (hasValidDeviceCode(state)) {
1447
+ console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=check_status UserCode=${state.userCode}`);
1485
1448
  try {
1486
- const statusResponse = await axios.get(pollUrl);
1487
- if (statusResponse.data.authenticated) {
1449
+ const tokenResponse = await axios.post(`${KOI_API_ENDPOINT}/auth/token`, {
1450
+ device_code: state.deviceCode,
1451
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
1452
+ });
1453
+ const data = tokenResponse.data;
1454
+ // Still pending?
1455
+ if (data.error === 'authorization_pending') {
1456
+ const expiresInMin = Math.floor((state.deviceCodeExpiresAt - Date.now()) / 60000);
1457
+ const ACTIVATION_URL = 'https://regen.gaiaai.xyz/activate';
1458
+ return {
1459
+ content: [{
1460
+ type: 'text',
1461
+ text: `## Authentication Pending\n\n**Still waiting for you to complete authentication.**\n\n### Instructions:\n\n1. Go to: [${ACTIVATION_URL}](${ACTIVATION_URL})\n2. Enter code: **\`${state.userCode}\`**\n3. Sign in with your **@regen.network** email\n\n---\n\n*Code expires in ${expiresInMin} minutes.*\n\n**After completing authentication, run this tool again to retrieve your session token.**`
1462
+ }]
1463
+ };
1464
+ }
1465
+ // Expired or other error?
1466
+ if (data.error) {
1467
+ // Clear expired device code
1468
+ saveAuthState(clearDeviceCode(state));
1469
+ if (data.error === 'expired_token') {
1470
+ return {
1471
+ content: [{
1472
+ type: 'text',
1473
+ text: `## Authentication Expired\n\nYour authentication code has expired.\n\n**Run this tool again to get a new code.**`
1474
+ }]
1475
+ };
1476
+ }
1477
+ if (data.error === 'access_denied') {
1478
+ return {
1479
+ content: [{
1480
+ type: 'text',
1481
+ text: `## Access Denied\n\n${data.error_description || 'Only @regen.network email addresses are permitted.'}`
1482
+ }]
1483
+ };
1484
+ }
1485
+ throw new Error(data.error_description || data.error);
1486
+ }
1487
+ // Success!
1488
+ if (data.access_token) {
1488
1489
  console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=success Duration=${Date.now() - startTime}ms`);
1489
- output += `\n✅ **Authentication Successful!**\n\n`;
1490
- output += `Authenticated as: ${userEmail}\n\n`;
1491
- output += `You now have access to internal Regen Network documentation.\n`;
1492
- output += `Try asking questions that might reference internal documents!\n`;
1490
+ // Save token to file and in-memory cache
1491
+ const tokenExpiry = data.expires_in
1492
+ ? Date.now() + (data.expires_in * 1000)
1493
+ : Date.now() + 3600000; // Default 1 hour
1494
+ setAccessToken(data.access_token, tokenExpiry);
1495
+ saveAuthState({
1496
+ accessToken: data.access_token,
1497
+ accessTokenExpiresAt: tokenExpiry,
1498
+ userEmail: data.email
1499
+ });
1493
1500
  return {
1494
1501
  content: [{
1495
1502
  type: 'text',
1496
- text: output
1503
+ text: `## ✅ Authentication Successful!\n\nYou now have access to internal Regen Network documentation.\n\nPrivate Notion data from the main Regen workspace is now accessible.\n\n**Session expires:** ${new Date(tokenExpiry).toLocaleString()}`
1497
1504
  }]
1498
1505
  };
1499
1506
  }
1500
1507
  }
1501
- catch (pollError) {
1502
- // Continue polling
1508
+ catch (checkError) {
1509
+ console.error(`[${SERVER_NAME}] Error checking auth status:`, checkError);
1510
+ // Clear device code and let user try again
1511
+ saveAuthState(clearDeviceCode(state));
1512
+ throw checkError;
1503
1513
  }
1504
1514
  }
1505
- // Timeout
1506
- throw new Error('Authentication timeout - please try again');
1515
+ // Check 3: No state - start new auth flow
1516
+ console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=request_device_code`);
1517
+ const deviceCodeResponse = await axios.post(`${KOI_API_ENDPOINT}/auth/device/code`, {});
1518
+ const { device_code, user_code, expires_in } = deviceCodeResponse.data;
1519
+ // Hardcode activation URL (don't trust server's verification_uri)
1520
+ const ACTIVATION_URL = 'https://regen.gaiaai.xyz/activate';
1521
+ console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate UserCode=${user_code} VerificationUri=${ACTIVATION_URL}`);
1522
+ // Save device code state
1523
+ saveAuthState({
1524
+ deviceCode: device_code,
1525
+ userCode: user_code,
1526
+ verificationUri: ACTIVATION_URL,
1527
+ deviceCodeExpiresAt: Date.now() + (expires_in * 1000)
1528
+ });
1529
+ // Auto-open browser to activation page
1530
+ try {
1531
+ const open = (await import('open')).default;
1532
+ await open(ACTIVATION_URL);
1533
+ console.error(`[${SERVER_NAME}] Opened browser to ${ACTIVATION_URL}`);
1534
+ }
1535
+ catch (err) {
1536
+ console.error(`[${SERVER_NAME}] Failed to open browser:`, err);
1537
+ // Continue anyway - user can click the link
1538
+ }
1539
+ return {
1540
+ content: [{
1541
+ type: 'text',
1542
+ text: `## Authentication Required\n\n🌐 **Your browser should open automatically.** If not, click the link below:\n\n### [Open Activation Page](${ACTIVATION_URL})\n\n---\n\n### Enter this code:\n\n\`\`\`\n${user_code}\n\`\`\`\n\n### Sign in with Google\n\nUse your **@regen.network** email address.\n\n---\n\n*Code expires in ${Math.floor(expires_in / 60)} minutes.*\n\n**After completing authentication, run this tool again to retrieve your session token.**`
1543
+ }]
1544
+ };
1507
1545
  }
1508
1546
  catch (error) {
1509
1547
  console.error(`[${SERVER_NAME}] Tool=regen_koi_authenticate Event=error`, error);
@@ -1683,158 +1721,6 @@ class KOIServer {
1683
1721
  }
1684
1722
  return formatted;
1685
1723
  }
1686
- /**
1687
- * Handle hybrid search - intelligent routing based on query classification
1688
- */
1689
- async handleHybridSearch(args) {
1690
- const { query, limit = 10 } = args;
1691
- // Check if hybrid search is available
1692
- if (!this.queryRouter || !this.unifiedSearch) {
1693
- console.error(`[${SERVER_NAME}] Hybrid search not available - falling back to vector search`);
1694
- return await this.searchKnowledge({ query, limit });
1695
- }
1696
- try {
1697
- const startTime = Date.now();
1698
- // Step 1: Classify query
1699
- const classification = await this.queryRouter.classifyQuery(query);
1700
- console.error(`[${SERVER_NAME}] Query classified as: ${classification.intent} (route: ${classification.recommended_route})`);
1701
- let results = [];
1702
- let searchMetadata = {};
1703
- // Step 2: Execute appropriate search based on classification
1704
- if (classification.recommended_route === 'graph' && classification.detected_entities.length > 0) {
1705
- // Graph-only search for entity queries
1706
- const entityNames = classification.detected_entities.map(e => e.name);
1707
- const graphResults = await this.unifiedSearch.graphSearch(entityNames, limit);
1708
- results = graphResults.map(hit => ({
1709
- id: hit.id,
1710
- title: hit.title,
1711
- content: hit.content || '',
1712
- source: 'graph',
1713
- entity_type: hit.entity_type,
1714
- file_path: hit.file_path,
1715
- line_number: hit.line_number,
1716
- score: hit.final_score,
1717
- }));
1718
- searchMetadata = {
1719
- route: 'graph',
1720
- entities_detected: entityNames,
1721
- };
1722
- }
1723
- else if (classification.recommended_route === 'vector') {
1724
- // Vector-only search for conceptual queries - use KOI API
1725
- const response = await apiClient.post('/query', {
1726
- question: query,
1727
- limit: limit
1728
- });
1729
- const data = response.data;
1730
- results = (data.results || []).map((r) => ({
1731
- id: r.rid || r.id,
1732
- title: r.title || 'Document',
1733
- content: r.content || '',
1734
- source: 'vector',
1735
- score: r.score || 0,
1736
- metadata: r.metadata,
1737
- }));
1738
- searchMetadata = {
1739
- route: 'vector',
1740
- };
1741
- }
1742
- else {
1743
- // Unified/hybrid search - combine graph and vector
1744
- // For now, use vector search as we don't have embedding service integrated
1745
- console.error(`[${SERVER_NAME}] Unified search requested but falling back to vector search (embedding service not integrated)`);
1746
- const response = await apiClient.post('/query', {
1747
- question: query,
1748
- limit: limit
1749
- });
1750
- const data = response.data;
1751
- results = (data.results || []).map((r) => ({
1752
- id: r.rid || r.id,
1753
- title: r.title || 'Document',
1754
- content: r.content || '',
1755
- source: 'vector',
1756
- score: r.score || 0,
1757
- metadata: r.metadata,
1758
- }));
1759
- searchMetadata = {
1760
- route: 'hybrid_fallback_to_vector',
1761
- entities_detected: classification.detected_entities.map(e => e.name),
1762
- note: 'Embedding service not available - using vector search only',
1763
- };
1764
- }
1765
- const duration = Date.now() - startTime;
1766
- // Step 3: Format results
1767
- const markdown = this.formatHybridResults(results, classification, searchMetadata);
1768
- // MCP only supports type: 'text' - embed JSON as code block
1769
- const jsonData = JSON.stringify({
1770
- hits: results,
1771
- classification,
1772
- metadata: {
1773
- query,
1774
- route: classification.recommended_route,
1775
- duration_ms: duration,
1776
- total_results: results.length,
1777
- ...searchMetadata,
1778
- },
1779
- }, null, 2);
1780
- return {
1781
- content: [
1782
- {
1783
- type: 'text',
1784
- text: markdown + '\n\n---\n\n<details>\n<summary>Raw JSON (for eval harness)</summary>\n\n```json\n' + jsonData + '\n```\n</details>',
1785
- },
1786
- ],
1787
- };
1788
- }
1789
- catch (error) {
1790
- console.error(`[${SERVER_NAME}] Hybrid search error:`, error);
1791
- // Fallback to basic search
1792
- return await this.searchKnowledge({ query, limit });
1793
- }
1794
- }
1795
- /**
1796
- * Format hybrid search results as markdown
1797
- */
1798
- formatHybridResults(results, classification, metadata) {
1799
- let output = `## Hybrid Search Results\n\n`;
1800
- output += `**Query Route:** ${metadata.route} (intent: ${classification.intent})\n`;
1801
- if (classification.detected_entities.length > 0) {
1802
- output += `**Detected Entities:** ${classification.detected_entities.map((e) => e.name).join(', ')}\n`;
1803
- }
1804
- output += `**Confidence:** ${(classification.confidence * 100).toFixed(1)}%\n`;
1805
- output += `**Results:** ${results.length}\n\n`;
1806
- if (classification.reasoning) {
1807
- output += `*${classification.reasoning}*\n\n`;
1808
- }
1809
- if (metadata.note) {
1810
- output += `> **Note:** ${metadata.note}\n\n`;
1811
- }
1812
- output += `---\n\n`;
1813
- results.forEach((hit, i) => {
1814
- output += `### ${i + 1}. ${hit.title || hit.id}\n`;
1815
- if (hit.entity_type) {
1816
- output += `**Type:** ${hit.entity_type} | `;
1817
- }
1818
- output += `**Source:** ${hit.source}`;
1819
- if (hit.score !== undefined) {
1820
- output += ` | **Score:** ${hit.score.toFixed(3)}`;
1821
- }
1822
- output += `\n\n`;
1823
- if (hit.file_path) {
1824
- output += `📁 \`${hit.file_path}\``;
1825
- if (hit.line_number) {
1826
- output += `:${hit.line_number}`;
1827
- }
1828
- output += `\n\n`;
1829
- }
1830
- if (hit.content) {
1831
- const preview = hit.content.substring(0, 300);
1832
- output += `${preview}${hit.content.length > 300 ? '...' : ''}\n\n`;
1833
- }
1834
- output += `---\n\n`;
1835
- });
1836
- return output;
1837
- }
1838
1724
  async run() {
1839
1725
  const transport = new StdioServerTransport();
1840
1726
  await this.server.connect(transport);